/* * (c) Copyright Ascensio System SIA 2010-2024 * * This program is a free software product. You can redistribute it and/or * modify it under the terms of the GNU Affero General Public License (AGPL) * version 3 as published by the Free Software Foundation. In accordance with * Section 7(a) of the GNU AGPL its Section 15 shall be amended to the effect * that Ascensio System SIA expressly excludes the warranty of non-infringement * of any third-party rights. * * This program is distributed WITHOUT ANY WARRANTY; without even the implied * warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. For * details, see the GNU AGPL at: http://www.gnu.org/licenses/agpl-3.0.html * * You can contact Ascensio System SIA at 20A-6 Ernesta Birznieka-Upish * street, Riga, Latvia, EU, LV-1050. * * The interactive user interfaces in modified source and object code versions * of the Program must display Appropriate Legal Notices, as required under * Section 5 of the GNU AGPL version 3. * * Pursuant to Section 7(b) of the License you must retain the original Product * logo when distributing the program. Pursuant to Section 7(e) we decline to * grant you any rights under trademark law for use of our trademarks. * * All the Product's GUI elements, including illustrations and icon sets, as * well as technical writing content are licensed under the terms of the * Creative Commons Attribution-ShareAlike 4.0 International. See the License * terms at http://creativecommons.org/licenses/by-sa/4.0/legalcode * */ /* -------------------------------------------------- --view-mode----------------------------------------------------- ------------ * 1) For the view mode, we update the page (without a quick transition) so that the user is not considered editable and does not * held the document for assembly (if you do not wait, then the quick transition from view to edit is incomprehensible when the document has already been assembled) * 2) If the user is in view mode, then he does not participate in editing (only in chat). When opened, it receives * all current changes in the document at the time of opening. For view-mode we do not accept changes and do not send them * view-users (because it is not clear what to do in a situation where 1-user has made changes, * saved and made undo). *---------------------------------------------------------------- -------------------------------------------------- -------------------- *------------------------------------------------Scheme save------------------------------------------------- ------ * a) One user - the first time changes come without an index, then changes come with an index, you can do * undo-redo (history is not rubbed). If autosave is enabled, then it is for any action (no more than 5 seconds). * b) As soon as the second user enters, co-editing begins. A lock is placed on the document so that * the first user managed to save the document (or send unlock) * c) When there are 2 or more users, each save rubs the history and is sent in its entirety (no index). If * autosave is enabled, it is saved no more than once every 10 minutes. * d) When the user is left alone, after accepting someone else's changes, point 'a' begins *---------------------------------------------------------------- -------------------------------------------------- -------------------- *-------------------------------------------- Scheme of working with the server- -------------------------------------------------- - * a) When everyone leaves, after the cfgAscSaveTimeOutDelay time, the assembly command is sent to the document server. * b) If the status '1' comes to CommandService.ashx, then it was possible to save and raise the version. Clear callbacks and * changes from base and from memory. * c) If a status other than '1' arrives (this can include both the generation of the file and the work of an external subscriber * with the finished result), then three callbacks, and leave the changes. Because you can go to the old * version and get uncompiled changes. We also reset the status of the file to unassembled so that it can be * open without version error message. *---------------------------------------------------------------- -------------------------------------------------- -------------------- *------------------------------------------------Start server------------------------------------------------- --------- * 1) Loading information about the collector * 2) Loading information about callbacks * 3) We collect only those files that have a callback and information for building *---------------------------------------------------------------- -------------------------------------------------- -------------------- *------------------------------------------------Reconnect when disconnected--- ------------------------------------ * 1) Check the file for assembly. If it starts, then stop. * 2) If the assembly has already completed, then we send the user a notification that it is impossible to edit further * 3) Next, check the time of the last save and lock-and user. If someone has already managed to save or * lock objects, then we can't edit further. *---------------------------------------------------------------- -------------------------------------------------- -------------------- * */ 'use strict'; const {Server} = require('socket.io'); const _ = require('underscore'); const url = require('url'); const crypto = require('crypto'); const pathModule = require('path'); const {isDeepStrictEqual} = require('util'); const co = require('co'); const jwt = require('jsonwebtoken'); const ms = require('ms'); const bytes = require('bytes'); const storage = require('./../../Common/sources/storage/storage-base'); const constants = require('./../../Common/sources/constants'); const utils = require('./../../Common/sources/utils'); const utilsDocService = require('./utilsDocService'); const commonDefines = require('./../../Common/sources/commondefines'); const statsDClient = require('./../../Common/sources/statsdclient'); const config = require('config'); const sqlBase = require('./databaseConnectors/baseConnector'); const canvasService = require('./canvasservice'); const converterService = require('./converterservice'); const taskResult = require('./taskresult'); const gc = require('./gc'); const shutdown = require('./shutdown'); const pubsubService = require('./pubsubRabbitMQ'); const wopiClient = require('./wopiClient'); const queueService = require('./../../Common/sources/taskqueueRabbitMQ'); const operationContext = require('./../../Common/sources/operationContext'); const tenantManager = require('./../../Common/sources/tenantManager'); const {notificationTypes, ...notificationService} = require('../../Common/sources/notificationService'); const aiProxyHandler = require('./ai/aiProxyHandler'); const cfgEditorDataStorage = config.get('services.CoAuthoring.server.editorDataStorage'); const cfgEditorStatStorage = config.get('services.CoAuthoring.server.editorStatStorage'); const editorDataStorage = require('./' + cfgEditorDataStorage); const editorStatStorage = require('./' + (cfgEditorStatStorage || cfgEditorDataStorage)); const util = require('util'); const cfgEditSingleton = config.get('services.CoAuthoring.server.edit_singleton'); const cfgEditor = config.get('services.CoAuthoring.editor'); const cfgCallbackRequestTimeout = config.get('services.CoAuthoring.server.callbackRequestTimeout'); //The waiting time to document assembly when all out(not 0 in case of F5 in the browser) const cfgAscSaveTimeOutDelay = config.get('services.CoAuthoring.server.savetimeoutdelay'); const cfgPubSubMaxChanges = config.get('services.CoAuthoring.pubsub.maxChanges'); const cfgExpSaveLock = config.get('services.CoAuthoring.expire.saveLock'); const cfgExpLockDoc = config.get('services.CoAuthoring.expire.lockDoc'); const cfgExpSessionIdle = config.get('services.CoAuthoring.expire.sessionidle'); const cfgExpSessionAbsolute = config.get('services.CoAuthoring.expire.sessionabsolute'); const cfgExpSessionCloseCommand = config.get('services.CoAuthoring.expire.sessionclosecommand'); const cfgExpUpdateVersionStatus = config.get('services.CoAuthoring.expire.updateVersionStatus'); const cfgTokenEnableBrowser = config.get('services.CoAuthoring.token.enable.browser'); const cfgTokenEnableRequestInbox = config.get('services.CoAuthoring.token.enable.request.inbox'); const cfgTokenSessionAlgorithm = config.get('services.CoAuthoring.token.session.algorithm'); const cfgTokenSessionExpires = config.get('services.CoAuthoring.token.session.expires'); const cfgTokenInboxHeader = config.get('services.CoAuthoring.token.inbox.header'); const cfgTokenInboxPrefix = config.get('services.CoAuthoring.token.inbox.prefix'); const cfgTokenVerifyOptions = config.util.cloneDeep(config.get('services.CoAuthoring.token.verifyOptions')); const cfgForceSaveEnable = config.get('services.CoAuthoring.autoAssembly.enable'); const cfgForceSaveInterval = config.get('services.CoAuthoring.autoAssembly.interval'); const cfgQueueRetentionPeriod = config.get('queue.retentionPeriod'); const cfgForgottenFiles = config.get('services.CoAuthoring.server.forgottenfiles'); const cfgForgottenFilesName = config.get('services.CoAuthoring.server.forgottenfilesname'); const cfgMaxRequestChanges = config.get('services.CoAuthoring.server.maxRequestChanges'); const cfgWarningLimitPercents = config.get('license.warning_limit_percents'); const cfgNotificationRuleLicenseLimitEdit = config.get('notification.rules.licenseLimitEdit.template'); const cfgNotificationRuleLicenseLimitLiveViewer = config.get('notification.rules.licenseLimitLiveViewer.template'); const cfgErrorFiles = config.get('FileConverter.converter.errorfiles'); const cfgOpenProtectedFile = config.get('services.CoAuthoring.server.openProtectedFile'); const cfgIsAnonymousSupport = config.get('services.CoAuthoring.server.isAnonymousSupport'); const cfgTokenRequiredParams = config.get('services.CoAuthoring.server.tokenRequiredParams'); const cfgImageSize = config.get('services.CoAuthoring.server.limits_image_size'); const cfgTypesUpload = config.get('services.CoAuthoring.utils.limits_image_types_upload'); const cfgForceSaveUsingButtonWithoutChanges = config.get('services.CoAuthoring.server.forceSaveUsingButtonWithoutChanges'); //todo tenant const cfgExpDocumentsCron = config.get('services.CoAuthoring.expire.documentsCron'); const cfgRefreshLockInterval = ms(config.get('wopi.refreshLockInterval')); const cfgSocketIoConnection = config.util.cloneDeep(config.get('services.CoAuthoring.socketio.connection')); const cfgTableResult = config.get('services.CoAuthoring.sql.tableResult'); const cfgTableChanges = config.get('services.CoAuthoring.sql.tableChanges'); const EditorTypes = { document: 0, spreadsheet: 1, presentation: 2, diagram: 3 }; const defaultHttpPort = 80, defaultHttpsPort = 443; // Default ports (for http and https) //todo remove editorDataStorage constructor usage after 8.1 const editorData = editorDataStorage.EditorData ? new editorDataStorage.EditorData() : new editorDataStorage(); const editorStat = editorStatStorage.EditorStat ? new editorStatStorage.EditorStat() : new editorDataStorage(); let editorStatProxy = null; if (process.env.REDIS_SERVER_DB_KEYS_NUM) { editorStatProxy = new editorStatStorage.EditorStat(process.env.REDIS_SERVER_DB_KEYS_NUM); } const clientStatsD = statsDClient.getClient(); let connections = []; // Active connections const lockDocumentsTimerId = {}; //to drop connection that can't unlockDocument let pubsub; let queue; let shutdownFlag = false; let preStopFlag = false; const expDocumentsStep = gc.getCronStep(cfgExpDocumentsCron); const MIN_SAVE_EXPIRATION = 60000; const SHARD_ID = crypto.randomBytes(16).toString('base64'); //16 as guid const PRECISION = [ {name: 'hour', val: ms('1h')}, {name: 'day', val: ms('1d')}, {name: 'week', val: ms('7d')}, {name: 'month', val: ms('31d')} ]; function getIsShutdown() { return shutdownFlag; } function getIsPreStop() { return preStopFlag; } function getEditorConfig(ctx) { let tenEditor = ctx.getCfg('services.CoAuthoring.editor', cfgEditor); tenEditor = JSON.parse(JSON.stringify(tenEditor)); tenEditor['reconnection']['delay'] = ms(tenEditor['reconnection']['delay']); tenEditor['websocketMaxPayloadSize'] = bytes.parse(tenEditor['websocketMaxPayloadSize']); tenEditor['maxChangesSize'] = bytes.parse(tenEditor['maxChangesSize']); return tenEditor; } function getForceSaveExpiration(ctx) { const tenForceSaveInterval = ms(ctx.getCfg('services.CoAuthoring.autoAssembly.interval', cfgForceSaveInterval)); const tenQueueRetentionPeriod = ctx.getCfg('queue.retentionPeriod', cfgQueueRetentionPeriod); return Math.min(Math.max(tenForceSaveInterval, MIN_SAVE_EXPIRATION), tenQueueRetentionPeriod * 1000); } function DocumentChanges(docId) { this.docId = docId; this.arrChanges = []; return this; } DocumentChanges.prototype.getLength = function () { return this.arrChanges.length; }; DocumentChanges.prototype.push = function (change) { this.arrChanges.push(change); }; DocumentChanges.prototype.splice = function (start, deleteCount) { this.arrChanges.splice(start, deleteCount); }; DocumentChanges.prototype.slice = function (start, end) { return this.arrChanges.splice(start, end); }; DocumentChanges.prototype.concat = function (item) { this.arrChanges = this.arrChanges.concat(item); }; const c_oAscServerStatus = { NotFound: 0, Editing: 1, MustSave: 2, Corrupted: 3, Closed: 4, MailMerge: 5, MustSaveForce: 6, CorruptedForce: 7 }; const c_oAscChangeBase = { No: 0, Delete: 1, All: 2 }; const c_oAscLockTimeOutDelay = 500; // Timeout to save when database is clamped const c_oAscRecalcIndexTypes = { RecalcIndexAdd: 1, RecalcIndexRemove: 2 }; /** * lock types * @const */ const c_oAscLockTypes = { kLockTypeNone: 1, // no one has locked this object kLockTypeMine: 2, // this object is locked by the current user kLockTypeOther: 3, // this object is locked by another (not the current) user kLockTypeOther2: 4, // this object is locked by another (not the current) user (updates have already arrived) kLockTypeOther3: 5 // this object has been locked (updates have arrived) and is now locked again }; const c_oAscLockTypeElem = { Range: 1, Object: 2, Sheet: 3 }; const c_oAscLockTypeElemSubType = { DeleteColumns: 1, InsertColumns: 2, DeleteRows: 3, InsertRows: 4, ChangeProperties: 5 }; const c_oAscLockTypeElemPresentation = { Object: 1, Slide: 2, Presentation: 3 }; function CRecalcIndexElement(recalcType, position, bIsSaveIndex) { if (!(this instanceof CRecalcIndexElement)) { return new CRecalcIndexElement(recalcType, position, bIsSaveIndex); } this._recalcType = recalcType; // Type of changes (removal or addition) this._position = position; // The position where the changes happened this._count = 1; // We consider all changes as the simplest this.m_bIsSaveIndex = !!bIsSaveIndex; // These are indexes from other users' changes (that we haven't applied yet) return this; } CRecalcIndexElement.prototype = { constructor: CRecalcIndexElement, // recalculate for others getLockOther(position, type) { const inc = c_oAscRecalcIndexTypes.RecalcIndexAdd === this._recalcType ? +1 : -1; if (position === this._position && c_oAscRecalcIndexTypes.RecalcIndexRemove === this._recalcType && true === this.m_bIsSaveIndex) { // We haven't applied someone else's changes yet (so insert doesn't need to be rendered) // RecalcIndexRemove (because we flip it for proper processing, from another user // RecalcIndexAdd arrived return null; } else if ( position === this._position && c_oAscRecalcIndexTypes.RecalcIndexRemove === this._recalcType && c_oAscLockTypes.kLockTypeMine === type && false === this.m_bIsSaveIndex ) { // For the user who deleted the column, draw previously locked cells in this column // no need return null; } else if (position < this._position) { return position; } else { return position + inc; } }, // Recalculation for others (save only) getLockSaveOther(position, type) { if (this.m_bIsSaveIndex) { return position; } const inc = c_oAscRecalcIndexTypes.RecalcIndexAdd === this._recalcType ? +1 : -1; if (position === this._position && c_oAscRecalcIndexTypes.RecalcIndexRemove === this._recalcType && true === this.m_bIsSaveIndex) { // We haven't applied someone else's changes yet (so insert doesn't need to be rendered) // RecalcIndexRemove (because we flip it for proper processing, from another user // RecalcIndexAdd arrived return null; } else if ( position === this._position && c_oAscRecalcIndexTypes.RecalcIndexRemove === this._recalcType && c_oAscLockTypes.kLockTypeMine === type && false === this.m_bIsSaveIndex ) { // For the user who deleted the column, draw previously locked cells in this column // no need return null; } else if (position < this._position) { return position; } else { return position + inc; } }, // recalculate for ourselves getLockMe(position) { const inc = c_oAscRecalcIndexTypes.RecalcIndexAdd === this._recalcType ? -1 : +1; if (position < this._position) { return position; } else { return position + inc; } }, // Only when other users change (for recalculation) getLockMe2(position) { const inc = c_oAscRecalcIndexTypes.RecalcIndexAdd === this._recalcType ? -1 : +1; if (true !== this.m_bIsSaveIndex || position < this._position) { return position; } else { return position + inc; } } }; function CRecalcIndex() { if (!(this instanceof CRecalcIndex)) { return new CRecalcIndex(); } this._arrElements = []; // CRecalcIndexElement array return this; } CRecalcIndex.prototype = { constructor: CRecalcIndex, add(recalcType, position, count, bIsSaveIndex) { for (let i = 0; i < count; ++i) { this._arrElements.push(new CRecalcIndexElement(recalcType, position, bIsSaveIndex)); } }, clear() { this._arrElements.length = 0; }, getLockOther(position, type) { let newPosition = position; const count = this._arrElements.length; for (let i = 0; i < count; ++i) { newPosition = this._arrElements[i].getLockOther(newPosition, type); if (null === newPosition) { break; } } return newPosition; }, // Recalculation for others (save only) getLockSaveOther(position, type) { let newPosition = position; const count = this._arrElements.length; for (let i = 0; i < count; ++i) { newPosition = this._arrElements[i].getLockSaveOther(newPosition, type); if (null === newPosition) { break; } } return newPosition; }, // recalculate for ourselves getLockMe(position) { let newPosition = position; const count = this._arrElements.length; for (let i = count - 1; i >= 0; --i) { newPosition = this._arrElements[i].getLockMe(newPosition); if (null === newPosition) { break; } } return newPosition; }, // Only when other users change (for recalculation) getLockMe2(position) { let newPosition = position; const count = this._arrElements.length; for (let i = count - 1; i >= 0; --i) { newPosition = this._arrElements[i].getLockMe2(newPosition); if (null === newPosition) { break; } } return newPosition; } }; function updatePresenceCounters(ctx, conn, val) { return co(function* () { let aggregationCtx; if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) { //aggregated server stats aggregationCtx = new operationContext.Context(); aggregationCtx.init(tenantManager.getDefautTenant(), ctx.docId, ctx.userId); //yield ctx.initTenantCache(); //no need.only global config } if (utils.isLiveViewer(conn)) { yield editorStat.incrLiveViewerConnectionsCountByShard(ctx, SHARD_ID, val); if (aggregationCtx) { yield editorStat.incrLiveViewerConnectionsCountByShard(aggregationCtx, SHARD_ID, val); } if (clientStatsD) { const countLiveView = yield editorStat.getLiveViewerConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.liveview', countLiveView); } } else if (conn.isCloseCoAuthoring || (conn.user && conn.user.view)) { yield editorStat.incrViewerConnectionsCountByShard(ctx, SHARD_ID, val); if (aggregationCtx) { yield editorStat.incrViewerConnectionsCountByShard(aggregationCtx, SHARD_ID, val); } if (clientStatsD) { const countView = yield editorStat.getViewerConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.view', countView); } } else { yield editorStat.incrEditorConnectionsCountByShard(ctx, SHARD_ID, val); if (aggregationCtx) { yield editorStat.incrEditorConnectionsCountByShard(aggregationCtx, SHARD_ID, val); } if (clientStatsD) { const countEditors = yield editorStat.getEditorConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.edit', countEditors); } } }); } function addPresence(ctx, conn, updateCunters) { return co(function* () { yield editorData.addPresence(ctx, conn.docId, conn.user.id, utils.getConnectionInfoStr(conn)); if (updateCunters) { yield updatePresenceCounters(ctx, conn, 1); } }); } async function updatePresence(ctx, conn) { if (editorData.updatePresence) { return await editorData.updatePresence(ctx, conn.docId, conn.user.id); } else { //todo remove if after 7.6. code for backward compatibility, because redis in separate repo return await editorData.addPresence(ctx, conn.docId, conn.user.id, utils.getConnectionInfoStr(conn)); } } function removePresence(ctx, conn) { return co(function* () { yield editorData.removePresence(ctx, conn.docId, conn.user.id); yield updatePresenceCounters(ctx, conn, -1); }); } const changeConnectionInfo = co.wrap(function* (ctx, conn, cmd) { if (!conn.denyChangeName && conn.user) { yield publish(ctx, {type: commonDefines.c_oPublishType.changeConnecitonInfo, ctx, docId: conn.docId, useridoriginal: conn.user.idOriginal, cmd}); return true; } return false; }); function signToken(ctx, payload, algorithm, expiresIn, secretElem) { return co(function* () { const options = {algorithm, expiresIn}; const secret = yield tenantManager.getTenantSecret(ctx, secretElem); return jwt.sign(payload, utils.getJwtHsKey(secret), options); }); } function needSendChanges(conn) { return !conn.user?.view || utils.isLiveViewer(conn); } function fillJwtByConnection(ctx, conn) { return co(function* () { const tenTokenSessionAlgorithm = ctx.getCfg('services.CoAuthoring.token.session.algorithm', cfgTokenSessionAlgorithm); const tenTokenSessionExpires = ms(ctx.getCfg('services.CoAuthoring.token.session.expires', cfgTokenSessionExpires)); const payload = {document: {}, editorConfig: {user: {}}}; const doc = payload.document; doc.key = conn.docId; doc.permissions = conn.permissions; doc.ds_encrypted = conn.encrypted; const edit = payload.editorConfig; //todo //edit.callbackUrl = callbackUrl; //edit.lang = conn.lang; //edit.mode = conn.mode; const user = edit.user; user.id = conn.user.idOriginal; user.name = conn.user.username; user.index = conn.user.indexUser; user.customerId = conn.user.customerId; if (conn.coEditingMode) { edit.coEditing = {mode: conn.coEditingMode}; } //no standart edit.ds_isCloseCoAuthoring = conn.isCloseCoAuthoring; edit.ds_isEnterCorrectPassword = conn.isEnterCorrectPassword; // presenter viewer opens with same session jwt. do not put sessionId to jwt // edit.ds_sessionId = conn.sessionId; edit.ds_sessionTimeConnect = conn.sessionTimeConnect; return yield signToken(ctx, payload, tenTokenSessionAlgorithm, tenTokenSessionExpires / 1000, commonDefines.c_oAscSecretType.Session); }); } function sendData(ctx, conn, data) { conn.emit('message', data); const type = data ? data.type : null; ctx.logger.debug('sendData: type = %s', type); } function sendDataWarning(ctx, conn, code, description) { sendData(ctx, conn, {type: 'warning', code, message: description}); } function sendDataMessage(ctx, conn, msg) { if (!conn.permissions || false !== conn.permissions.chat) { sendData(ctx, conn, {type: 'message', messages: msg}); } else { ctx.logger.debug('sendDataMessage permissions.chat==false'); } } function sendDataCursor(ctx, conn, msg) { sendData(ctx, conn, {type: 'cursor', messages: msg}); } function sendDataMeta(ctx, conn, msg) { sendData(ctx, conn, {type: 'meta', messages: msg}); } function sendDataSession(ctx, conn, msg) { sendData(ctx, conn, {type: 'session', messages: msg}); } function sendDataRefreshToken(ctx, conn, msg) { sendData(ctx, conn, {type: 'refreshToken', messages: msg}); } function sendDataRpc(ctx, conn, responseKey, data) { sendData(ctx, conn, {type: 'rpc', responseKey, data}); } function sendDataDrop(ctx, conn, code, description) { sendData(ctx, conn, {type: 'drop', code, description}); } function sendDataDisconnectReason(ctx, conn, code, description) { sendData(ctx, conn, {type: 'disconnectReason', code, description}); } function sendReleaseLock(ctx, conn, userLocks) { sendData(ctx, conn, { type: 'releaseLock', locks: _.map(userLocks, e => { return { block: e.block, user: e.user, time: Date.now(), changes: null }; }) }); } function modifyConnectionForPassword(ctx, conn, isEnterCorrectPassword) { return co(function* () { if (isEnterCorrectPassword) { conn.isEnterCorrectPassword = true; const sessionToken = yield fillJwtByConnection(ctx, conn); sendDataRefreshToken(ctx, conn, sessionToken); } }); } function modifyConnectionEditorToView(ctx, conn) { if (conn.user) { conn.user.view = true; } delete conn.coEditingMode; } function getParticipants(docId, excludeClosed, excludeUserId, excludeViewer) { return _.filter(connections, el => { return el.docId === docId && el.isCloseCoAuthoring !== excludeClosed && el.user.id !== excludeUserId && el.user.view !== excludeViewer; }); } function getParticipantUser(docId, includeUserId) { return _.filter(connections, el => { return el.docId === docId && el.user.id === includeUserId; }); } function* updateEditUsers(ctx, licenseInfo, userId, anonym, isLiveViewer) { if (!licenseInfo.usersCount) { return; } const now = new Date(); const expireAt = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1) / 1000 + licenseInfo.usersExpire - 1; const period = utils.getLicensePeriod(licenseInfo.startDate, now); if (isLiveViewer) { yield editorStat.addPresenceUniqueViewUser(ctx, userId, expireAt, {anonym}); yield editorStat.addPresenceUniqueViewUsersOfMonth(ctx, userId, period, {anonym, firstOpenDate: now.toISOString()}); } else { yield editorStat.addPresenceUniqueUser(ctx, userId, expireAt, {anonym}); yield editorStat.addPresenceUniqueUsersOfMonth(ctx, userId, period, {anonym, firstOpenDate: now.toISOString()}); } } function* getEditorsCount(ctx, docId, opt_hvals) { let elem, editorsCount = 0; let hvals; if (opt_hvals) { hvals = opt_hvals; } else { hvals = yield editorData.getPresence(ctx, docId, connections); } for (let i = 0; i < hvals.length; ++i) { elem = JSON.parse(hvals[i]); if (!elem.view && !elem.isCloseCoAuthoring) { editorsCount++; break; } } return editorsCount; } function* hasEditors(ctx, docId, opt_hvals) { const editorsCount = yield* getEditorsCount(ctx, docId, opt_hvals); return editorsCount > 0; } function* isUserReconnect(ctx, docId, userId, connectionId) { let elem; const hvals = yield editorData.getPresence(ctx, docId, connections); for (let i = 0; i < hvals.length; ++i) { elem = JSON.parse(hvals[i]); if (userId === elem.id && connectionId !== elem.connectionId) { return true; } } return false; } let pubsubOnMessage = null; //todo move function async function publish(ctx, data, optDocId, optUserId, opt_pubsub) { let needPublish = true; let hvals; if (optDocId && optUserId) { needPublish = false; hvals = await editorData.getPresence(ctx, optDocId, connections); for (let i = 0; i < hvals.length; ++i) { const elem = JSON.parse(hvals[i]); if (optUserId != elem.id) { needPublish = true; break; } } } if (needPublish) { const msg = JSON.stringify(data); const realPubsub = opt_pubsub ? opt_pubsub : pubsub; //don't use pubsub if all connections are local if (pubsubOnMessage && hvals && hvals.length === getLocalConnectionCount(ctx, optDocId)) { ctx.logger.debug('pubsub locally'); //todo send connections from getLocalConnectionCount to pubsubOnMessage pubsubOnMessage(msg); } else if (realPubsub) { await realPubsub.publish(msg); } } return needPublish; } function* addTask(data, priority, opt_queue, opt_expiration) { const realQueue = opt_queue ? opt_queue : queue; yield realQueue.addTask(data, priority, opt_expiration); } function* addResponse(data, opt_queue) { const realQueue = opt_queue ? opt_queue : queue; yield realQueue.addResponse(data); } function* addDelayed(data, ttl, opt_queue) { const realQueue = opt_queue ? opt_queue : queue; yield realQueue.addDelayed(data, ttl); } function* removeResponse(data) { yield queue.removeResponse(data); } async function getOriginalParticipantsId(ctx, docId) { const result = [], tmpObject = {}; const hvals = await editorData.getPresence(ctx, docId, connections); for (let i = 0; i < hvals.length; ++i) { const elem = JSON.parse(hvals[i]); if (!elem.view && !elem.isCloseCoAuthoring) { tmpObject[elem.idOriginal] = 1; } } for (const name in tmpObject) { if (Object.hasOwn(tmpObject, name)) { result.push(name); } } return result; } async function sendServerRequest(ctx, uri, dataObject, opt_checkAndFixAuthorizationLength) { const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); const tenTokenEnableRequestInbox = ctx.getCfg('services.CoAuthoring.token.enable.request.inbox', cfgTokenEnableRequestInbox); ctx.logger.debug('postData request: url = %s;data = %j', uri, dataObject); let auth; if (utils.canIncludeOutboxAuthorization(ctx, uri)) { const secret = await tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Outbox); const bodyToken = utils.fillJwtForRequest(ctx, dataObject, secret, true); auth = utils.fillJwtForRequest(ctx, dataObject, secret, false); const authLen = auth.length; if (opt_checkAndFixAuthorizationLength && !opt_checkAndFixAuthorizationLength(auth, dataObject)) { auth = utils.fillJwtForRequest(ctx, dataObject, secret, false); ctx.logger.warn('authorization too large. Use body token instead. size reduced from %d to %d', authLen, auth.length); } dataObject.setToken(bodyToken); } const headers = {'Content-Type': 'application/json'}; //isInJwtToken is true because callbackUrl is required field in jwt token const postRes = await utils.postRequestPromise( ctx, uri, JSON.stringify(dataObject), undefined, undefined, tenCallbackRequestTimeout, auth, tenTokenEnableRequestInbox, headers ); ctx.logger.debug('postData response: data = %s', postRes.body); return postRes.body; } function parseUrl(ctx, callbackUrl) { let result = null; try { //no need to do decodeURIComponent http://expressjs.com/en/4x/api.html#app.settings.table //by default express uses 'query parser' = 'extended', but even in 'simple' version decode is done //percent-encoded characters within the query string will be assumed to use UTF-8 encoding const parseObject = url.parse(callbackUrl); const isHttps = 'https:' === parseObject.protocol; let port = parseObject.port; if (!port) { port = isHttps ? defaultHttpsPort : defaultHttpPort; } result = { https: isHttps, host: parseObject.hostname, port, path: parseObject.path, href: parseObject.href }; } catch (e) { ctx.logger.error('error parseUrl %s: %s', callbackUrl, e.stack); result = null; } return result; } async function getCallback(ctx, id, opt_userIndex) { let callbackUrl = null; let baseUrl = null; let wopiParams = null; const selectRes = await taskResult.select(ctx, id); if (selectRes.length > 0) { const row = selectRes[0]; if (row.callback) { callbackUrl = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback, opt_userIndex); wopiParams = wopiClient.parseWopiCallback(ctx, callbackUrl, row.callback); } if (row.baseurl) { baseUrl = row.baseurl; } } if (null != callbackUrl && null != baseUrl) { return {server: parseUrl(ctx, callbackUrl), baseUrl, wopiParams}; } else { return null; } } function* getChangesIndex(ctx, docId) { let res = 0; const getRes = yield sqlBase.getChangesIndexPromise(ctx, docId); if (getRes && getRes.length > 0 && null != getRes[0]['change_id']) { res = getRes[0]['change_id'] + 1; } return res; } const hasChanges = co.wrap(function* (ctx, docId) { //todo check editorData.getForceSave in case of "undo all changes" const puckerIndex = yield* getChangesIndex(ctx, docId); if (0 === puckerIndex) { const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0 && selectRes[0].password) { return sqlBase.DocumentPassword.prototype.hasPasswordChanges(ctx, selectRes[0].password); } return false; } return true; }); function* setForceSave(ctx, docId, forceSave, cmd, success, url) { const forceSaveType = forceSave.getType(); let end = success; if (commonDefines.c_oAscForceSaveTypes.Form === forceSaveType || commonDefines.c_oAscForceSaveTypes.Internal === forceSaveType) { const forceSave = yield editorData.getForceSave(ctx, docId); end = forceSave.ended; } const convertInfo = new commonDefines.InputCommand(cmd, true); //remove request specific fields from cmd convertInfo.setUserConnectionDocId(undefined); convertInfo.setUserConnectionId(undefined); convertInfo.setResponseKey(undefined); convertInfo.setFormData(undefined); if (convertInfo.getForceSave()) { //type must be saved to distinguish c_oAscForceSaveTypes.Form //convertInfo.getForceSave().setType(undefined); convertInfo.getForceSave().setAuthorUserId(undefined); convertInfo.getForceSave().setAuthorUserIndex(undefined); } yield editorData.checkAndSetForceSave(ctx, docId, forceSave.getTime(), forceSave.getIndex(), end, end, convertInfo); if (commonDefines.c_oAscForceSaveTypes.Command !== forceSaveType) { let data = {type: forceSaveType, time: forceSave.getTime(), success}; if (commonDefines.c_oAscForceSaveTypes.Form === forceSaveType || commonDefines.c_oAscForceSaveTypes.Internal === forceSaveType) { const code = success ? commonDefines.c_oAscServerCommandErrors.NoError : commonDefines.c_oAscServerCommandErrors.UnknownError; data = {code, time: forceSave.getTime(), inProgress: false}; if (commonDefines.c_oAscForceSaveTypes.Internal === forceSaveType) { data.url = url; } const userId = cmd.getUserConnectionId(); docId = cmd.getUserConnectionDocId() || docId; yield publish(ctx, {type: commonDefines.c_oPublishType.rpc, ctx, docId, userId, data, responseKey: cmd.getResponseKey()}); } else { yield publish(ctx, {type: commonDefines.c_oPublishType.forceSave, ctx, docId, data}, cmd.getUserConnectionId()); } } } /** * @param {commonDefines.InputCommand} cmd - Information about the document conversion * @returns {string|null} The constructed document path if saveKey and outputPath exist, null otherwise */ function getForceSaveDocPath(cmd) { if (cmd) { const saveKey = cmd.getDocId() + cmd.getSaveKey(); const outputPath = cmd.getOutputPath(); if (saveKey && outputPath) { return saveKey + '/' + outputPath; } } return null; } /** * Checks if a force save cache exists and is valid for the provided conversion information * @param {operationContext.Context} ctx - The request context * @param {Object} convertInfo - Information about the document conversion * @returns {Promise} Object containing cache status information: * - hasCache {boolean} - Whether cache information exists * - hasValidCache {boolean} - Whether the cache is valid * - cmd {commonDefines.InputCommand|null} - Command object (if available) */ async function checkForceSaveCache(ctx, convertInfo) { const res = {hasCache: false, hasValidCache: false, cmd: null}; if (convertInfo) { res.hasCache = true; const cmd = new commonDefines.InputCommand(convertInfo, true); const docPath = getForceSaveDocPath(cmd); if (docPath) { const metadata = await storage.headObject(ctx, docPath); res.hasValidCache = !!metadata; res.cmd = cmd; } } return res; } /** * Generates a signed URL for accessing a force-saved document * @param {operationContext.Context} ctx - The request context * @param {string} baseUrl - Base URL for the document * @param {Object} convertInfo - Information about the document conversion * @returns {Promise} The signed URL for the force-saved document or null if path cannot be generated */ async function getForceSaveUrl(ctx, baseUrl, convertInfo) { const cmd = new commonDefines.InputCommand(convertInfo, true); const docPath = getForceSaveDocPath(cmd); if (docPath) { return await storage.getSignedUrl(ctx, baseUrl, docPath, commonDefines.c_oAscUrlTypes.Temporary); } return null; } async function applyForceSaveCache( ctx, docId, forceSave, type, opt_userConnectionId, opt_userConnectionDocId, opt_responseKey, opt_formdata, opt_userId, opt_userIndex, opt_prevTime ) { const res = {ok: false, notModified: false, inProgress: false, startedForceSave: null}; if (!forceSave) { res.notModified = true; return res; } const forceSaveCache = await checkForceSaveCache(ctx, forceSave.convertInfo); if (forceSaveCache.hasCache || forceSave.ended) { if (commonDefines.c_oAscForceSaveTypes.Form === type || commonDefines.c_oAscForceSaveTypes.Internal === type || !forceSave.ended) { //c_oAscForceSaveTypes.Form has uniqueue options {'documentLayout': {'isPrint': true}}; dont use it for other types const forceSaveCached = forceSaveCache.cmd?.getForceSave()?.getType(); const cacheHasSameOptions = (commonDefines.c_oAscForceSaveTypes.Form === type && commonDefines.c_oAscForceSaveTypes.Form === forceSaveCached) || (commonDefines.c_oAscForceSaveTypes.Form !== type && commonDefines.c_oAscForceSaveTypes.Form !== forceSaveCached); if (forceSaveCache.hasValidCache && cacheHasSameOptions) { //compare opt_prevTime because Internal command can be called by different users if (commonDefines.c_oAscForceSaveTypes.Internal === type && forceSave.time === opt_prevTime) { res.notModified = true; } else { const cmd = forceSaveCache.cmd; cmd.setUserConnectionDocId(opt_userConnectionDocId); cmd.setUserConnectionId(opt_userConnectionId); cmd.setResponseKey(opt_responseKey); cmd.setFormData(opt_formdata); if (cmd.getForceSave()) { cmd.getForceSave().setType(type); cmd.getForceSave().setAuthorUserId(opt_userId); cmd.getForceSave().setAuthorUserIndex(opt_userIndex); } //todo timeout because commandSfcCallback make request? await canvasService.commandSfcCallback(ctx, cmd, true, false); res.ok = true; } } else { await editorData.checkAndSetForceSave(ctx, docId, forceSave.time, forceSave.index, false, false, null); res.startedForceSave = await editorData.checkAndStartForceSave(ctx, docId); res.ok = !!res.startedForceSave; } } else { res.notModified = true; } } else if (!forceSave.started) { const isTypeToSendFile = commonDefines.c_oAscForceSaveTypes.Command === type || commonDefines.c_oAscForceSaveTypes.Button === type || commonDefines.c_oAscForceSaveTypes.Timeout === type || commonDefines.c_oAscForceSaveTypes.Form === type; if (isTypeToSendFile) { const selectRes = await taskResult.selectWithCache(ctx, docId); if (selectRes.length > 0 && !selectRes[0].callback) { ctx.logger.debug('applyForceSaveCache empty callback: %s', docId); res.notModified = true; return res; } } res.startedForceSave = await editorData.checkAndStartForceSave(ctx, docId); res.ok = !!res.startedForceSave; return res; } else if (commonDefines.c_oAscForceSaveTypes.Form === type || commonDefines.c_oAscForceSaveTypes.Internal === type) { res.ok = true; res.inProgress = true; } else { res.notModified = true; } return res; } async function startForceSave( ctx, docId, type, opt_userdata, opt_formdata, opt_userId, opt_userConnectionId, opt_userConnectionDocId, opt_userIndex, opt_responseKey, opt_baseUrl, opt_queue, opt_pubsub, opt_conn, opt_initShardKey, opt_jsonParams, opt_changeInfo, opt_prevTime ) { const tenForceSaveUsingButtonWithoutChanges = ctx.getCfg( 'services.CoAuthoring.server.forceSaveUsingButtonWithoutChanges', cfgForceSaveUsingButtonWithoutChanges ); ctx.logger.debug('startForceSave start'); const res = {code: commonDefines.c_oAscServerCommandErrors.NoError, time: null, inProgress: false}; let startedForceSave; let hasEncrypted = false; if (!shutdownFlag) { const hvals = await editorData.getPresence(ctx, docId, connections); hasEncrypted = hvals.some(currentValue => { return !!JSON.parse(currentValue).encrypted; }); if (!hasEncrypted) { let baseUrl = opt_baseUrl || ''; if (opt_conn) { baseUrl = utils.getBaseUrlByConnection(ctx, opt_conn); } let forceSave = await editorData.getForceSave(ctx, docId); const forceSaveWithConnection = opt_conn && (commonDefines.c_oAscForceSaveTypes.Form === type || (commonDefines.c_oAscForceSaveTypes.Button === type && tenForceSaveUsingButtonWithoutChanges)); const startWithoutChanges = !forceSave && (forceSaveWithConnection || opt_changeInfo); if (startWithoutChanges) { //stub to send forms without changes const newChangesLastDate = new Date(); newChangesLastDate.setMilliseconds(0); //remove milliseconds avoid issues with MySQL datetime rounding const newChangesLastTime = newChangesLastDate.getTime(); let changeInfo = opt_changeInfo; if (opt_conn) { changeInfo = getExternalChangeInfo(opt_conn.user, newChangesLastTime, opt_conn.lang); } await editorData.setForceSave(ctx, docId, newChangesLastTime, 0, baseUrl, changeInfo, null); forceSave = await editorData.getForceSave(ctx, docId); } const applyCacheRes = await applyForceSaveCache( ctx, docId, forceSave, type, opt_userConnectionId, opt_userConnectionDocId, opt_responseKey, opt_formdata, opt_userId, opt_userIndex, opt_prevTime ); startedForceSave = applyCacheRes.startedForceSave; if (applyCacheRes.notModified) { const selectRes = await taskResult.select(ctx, docId); if (selectRes.length > 0) { res.code = commonDefines.c_oAscServerCommandErrors.NotModified; if (forceSave) { res.url = await getForceSaveUrl(ctx, baseUrl, forceSave.convertInfo); } } else { res.code = commonDefines.c_oAscServerCommandErrors.DocumentIdError; } } else if (!applyCacheRes.ok) { res.code = commonDefines.c_oAscServerCommandErrors.UnknownError; } res.inProgress = applyCacheRes.inProgress; } } ctx.logger.debug('startForceSave canStart: hasEncrypted = %s; applyCacheRes = %j; startedForceSave = %j', hasEncrypted, res, startedForceSave); if (startedForceSave) { const baseUrl = opt_baseUrl || startedForceSave.baseUrl; const forceSave = new commonDefines.CForceSaveData(startedForceSave); forceSave.setType(type); forceSave.setAuthorUserId(opt_userId); forceSave.setAuthorUserIndex(opt_userIndex); let priority; let expiration; if (commonDefines.c_oAscForceSaveTypes.Timeout === type) { priority = constants.QUEUE_PRIORITY_VERY_LOW; expiration = getForceSaveExpiration(ctx); } else { priority = constants.QUEUE_PRIORITY_LOW; } //start new convert const status = await converterService.convertFromChanges( ctx, docId, baseUrl, forceSave, startedForceSave.changeInfo, opt_userdata, opt_formdata, opt_userConnectionId, opt_userConnectionDocId, opt_responseKey, priority, expiration, opt_queue, undefined, opt_initShardKey, opt_jsonParams ); if (constants.NO_ERROR === status.err) { res.time = forceSave.getTime(); if (commonDefines.c_oAscForceSaveTypes.Timeout === type) { await publish( ctx, { type: commonDefines.c_oPublishType.forceSave, ctx, docId, data: {type, time: forceSave.getTime(), start: true} }, undefined, undefined, opt_pubsub ); } } else { res.code = commonDefines.c_oAscServerCommandErrors.UnknownError; } ctx.logger.debug('startForceSave convertFromChanges: status = %d', status.err); } ctx.logger.debug('startForceSave end'); return res; } function getExternalChangeInfo(user, date, lang) { return {user_id: user.id, user_id_original: user.idOriginal, user_name: user.username, lang, change_date: date}; } const resetForceSaveAfterChanges = co.wrap(function* (ctx, docId, newChangesLastTime, puckerIndex, baseUrl, changeInfo) { const tenForceSaveEnable = ctx.getCfg('services.CoAuthoring.autoAssembly.enable', cfgForceSaveEnable); const tenForceSaveInterval = ms(ctx.getCfg('services.CoAuthoring.autoAssembly.interval', cfgForceSaveInterval)); //last save if (newChangesLastTime) { yield editorData.setForceSave(ctx, docId, newChangesLastTime, puckerIndex, baseUrl, changeInfo, null); if (tenForceSaveEnable) { const expireAt = newChangesLastTime + tenForceSaveInterval; yield editorData.addForceSaveTimerNX(ctx, docId, expireAt); } } }); async function saveRelativeFromChanges(ctx, conn, responseKey, data) { const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser); let docId = data.docId; const token = data.token; let forceSaveRes; if (tenTokenEnableBrowser || token) { docId = null; const checkJwtRes = await checkJwt(ctx, token, commonDefines.c_oAscSecretType.Browser); if (checkJwtRes.decoded) { docId = checkJwtRes.decoded.key; } else { ctx.logger.warn('Error saveRelativeFromChanges jwt: %s', checkJwtRes.description); forceSaveRes = {code: commonDefines.c_oAscServerCommandErrors.Token, time: null, inProgress: false}; } } if (!forceSaveRes) { forceSaveRes = await startForceSave( ctx, docId, commonDefines.c_oAscForceSaveTypes.Internal, undefined, undefined, undefined, conn.user.id, conn.docId, undefined, responseKey, undefined, undefined, undefined, conn, undefined, undefined, undefined, data.time ); } if (commonDefines.c_oAscServerCommandErrors.NoError !== forceSaveRes.code || forceSaveRes.inProgress) { sendDataRpc(ctx, conn, responseKey, forceSaveRes); } } async function startWopiRPC(ctx, docId, userId, userIdOriginal, data) { let res; const selectRes = await taskResult.select(ctx, docId); const row = selectRes.length > 0 ? selectRes[0] : null; if (row) { if (row.callback) { const userIndex = utils.getIndexFromUserId(userId, userIdOriginal); const uri = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback, userIndex); const wopiParams = wopiClient.parseWopiCallback(ctx, uri, row.callback); if (wopiParams) { switch (data.type) { case 'wopi_RenameFile': res = await wopiClient.renameFile(ctx, wopiParams, data.name); //publish for coeditors if (res?.Name) { const meta = {title: res.Name}; await publish(ctx, {type: commonDefines.c_oPublishType.meta, ctx, docId, meta}); } break; case 'wopi_RefreshFile': res = await wopiClient.refreshFile(ctx, wopiParams, row.baseurl); break; } } } } return res; } function* startRPC(ctx, conn, responseKey, data) { const docId = conn.docId; ctx.logger.debug('startRPC start responseKey:%s , %j', responseKey, data); switch (data.type) { case 'sendForm': { let forceSaveRes; if (conn.user) { //isPrint - to remove forms const jsonParams = {documentLayout: {isPrint: true}}; forceSaveRes = yield startForceSave( ctx, docId, commonDefines.c_oAscForceSaveTypes.Form, undefined, data.formdata, conn.user.idOriginal, conn.user.id, undefined, conn.user.indexUser, responseKey, undefined, undefined, undefined, conn, undefined, jsonParams ); } if (!forceSaveRes || commonDefines.c_oAscServerCommandErrors.NoError !== forceSaveRes.code || forceSaveRes.inProgress) { sendDataRpc(ctx, conn, responseKey, forceSaveRes); } break; } case 'saveRelativeFromChanges': { yield saveRelativeFromChanges(ctx, conn, responseKey, data); break; } case 'wopi_RenameFile': case 'wopi_RefreshFile': { const res = yield startWopiRPC(ctx, conn.docId, conn.user.id, conn.user.idOriginal, data); sendDataRpc(ctx, conn, responseKey, res); break; } case 'pathurls': { const outputData = new canvasService.OutputData(data.type); yield* canvasService.commandPathUrls(ctx, conn, data.data, outputData); sendDataRpc(ctx, conn, responseKey, outputData); break; } } ctx.logger.debug('startRPC end'); } function handleDeadLetter(data, ack) { return co(function* () { const ctx = new operationContext.Context(); try { let isRequeued = false; const task = new commonDefines.TaskQueueData(JSON.parse(data)); if (task) { ctx.initFromTaskQueueData(task); yield ctx.initTenantCache(); const cmd = task.getCmd(); ctx.logger.warn('handleDeadLetter start: %s', data); const forceSave = cmd.getForceSave(); if (forceSave && commonDefines.c_oAscForceSaveTypes.Timeout == forceSave.getType()) { const actualForceSave = yield editorData.getForceSave(ctx, cmd.getDocId()); //check that there are no new changes if (actualForceSave && forceSave.getTime() === actualForceSave.time && forceSave.getIndex() === actualForceSave.index) { //requeue task yield* addTask(task, constants.QUEUE_PRIORITY_VERY_LOW, undefined, getForceSaveExpiration(ctx)); isRequeued = true; } } else if (!forceSave && task.getFromChanges()) { yield* addTask(task, constants.QUEUE_PRIORITY_NORMAL, undefined); isRequeued = true; } else if (cmd.getAttempt()) { ctx.logger.warn('handleDeadLetter addResponse delayed = %d', cmd.getAttempt()); yield* addResponse(task); } else { //simulate error response cmd.setStatusInfo(constants.CONVERT_DEAD_LETTER); canvasService.receiveTask(JSON.stringify(task), () => {}); } } ctx.logger.warn('handleDeadLetter end: requeue = %s', isRequeued); } catch (err) { ctx.logger.error('handleDeadLetter error: %s', err.stack); } finally { ack(); } }); } /** * Sending status to know when the document started editing and when it ended * @param docId * @param {number} bChangeBase * @param callback * @param baseUrl */ async function sendStatusDocument(ctx, docId, bChangeBase, opt_userAction, opt_userIndex, opt_callback, opt_baseUrl, opt_userData, opt_forceClose) { if (!opt_callback) { const getRes = await getCallback(ctx, docId, opt_userIndex); if (getRes) { opt_callback = getRes.server; if (!opt_baseUrl) { opt_baseUrl = getRes.baseUrl; } if (getRes.wopiParams) { ctx.logger.debug('sendStatusDocument wopi stub'); return opt_callback; } } } if (null == opt_callback) { return; } let status = c_oAscServerStatus.Editing; const participants = await getOriginalParticipantsId(ctx, docId); if (0 === participants.length) { const bHasChanges = await hasChanges(ctx, docId); if (!bHasChanges || opt_forceClose) { status = c_oAscServerStatus.Closed; } } if (c_oAscChangeBase.No !== bChangeBase) { //update callback even if the connection is closed to avoid script: //open->make changes->disconnect->subscription from community->reconnect if (c_oAscChangeBase.All === bChangeBase) { //always override callback to avoid expired callbacks const updateTask = new taskResult.TaskResultData(); updateTask.tenant = ctx.tenant; updateTask.key = docId; updateTask.callback = opt_callback.href; updateTask.baseurl = opt_baseUrl; const updateIfRes = await taskResult.update(ctx, updateTask); if (updateIfRes.affectedRows > 0) { ctx.logger.debug('sendStatusDocument updateIf'); } else { ctx.logger.debug('sendStatusDocument updateIf no effect'); } } } const sendData = new commonDefines.OutputSfcData(docId); sendData.setStatus(status); if (c_oAscServerStatus.Closed !== status) { sendData.setUsers(participants); } if (opt_userAction) { sendData.setActions([opt_userAction]); } if (opt_userData) { sendData.setUserData(opt_userData); } const uri = opt_callback.href; let replyData = null; try { replyData = await sendServerRequest(ctx, uri, sendData); } catch (err) { replyData = null; ctx.logger.error('postData error: url = %s;data = %j %s', uri, sendData, err.stack); } await onReplySendStatusDocument(ctx, docId, replyData); return sendData; } function parseReplyData(ctx, replyData) { let res = null; if (replyData) { try { res = JSON.parse(replyData); } catch (e) { ctx.logger.error('error parseReplyData: data = %s %s', replyData, e.stack); res = null; } } return res; } const onReplySendStatusDocument = co.wrap(function* (ctx, docId, replyData) { const oData = parseReplyData(ctx, replyData); if (!(oData && commonDefines.c_oAscServerCommandErrors.NoError == oData.error)) { // Error subscribing to callback, send warning yield publish(ctx, {type: commonDefines.c_oPublishType.warning, ctx, docId, description: 'Error on save server subscription!'}); } }); function* publishCloseUsersConnection(ctx, docId, users, isOriginalId, code, description) { if (Array.isArray(users)) { const usersMap = users.reduce((map, val) => { map[val] = 1; return map; }, {}); yield publish(ctx, { type: commonDefines.c_oPublishType.closeConnection, ctx, docId, usersMap, isOriginalId, code, description }); } } function closeUsersConnection(ctx, docId, usersMap, isOriginalId, code, description) { //close let conn; for (let i = connections.length - 1; i >= 0; --i) { conn = connections[i]; if (conn.docId === docId) { if (isOriginalId ? usersMap[conn.user.idOriginal] : usersMap[conn.user.id]) { sendDataDisconnectReason(ctx, conn, code, description); conn.disconnect(true); } } } } async function dropUsersFromDocument(ctx, docId, opt_users) { await publish(ctx, {type: commonDefines.c_oPublishType.drop, ctx, docId, users: opt_users, description: ''}); } function dropUserFromDocument(ctx, docId, users, description) { let elConnection; for (let i = 0, length = connections.length; i < length; ++i) { elConnection = connections[i]; if (elConnection.docId === docId && !elConnection.isCloseCoAuthoring && (!users || users.includes(elConnection.user.idOriginal))) { sendDataDrop(ctx, elConnection, description); } } } function getLocalConnectionCount(ctx, docId) { return connections.reduce((count, conn) => { if (conn.docId === docId && conn.tenant === ctx.tenant) { count++; } return count; }, 0); } // Event subscription: function* bindEvents(ctx, docId, callback, baseUrl, opt_userAction, opt_userData) { // Subscribe to events: // - if there are no users and no changes, then send the status "closed" and do not add to the database // - if there are no users, but there are changes, then send the "editing" status without users, but add it to the database // - if there are users, then just add to the database let bChangeBase; let oCallbackUrl; if (!callback) { const getRes = yield getCallback(ctx, docId); if (getRes && !getRes.wopiParams) { oCallbackUrl = getRes.server; bChangeBase = c_oAscChangeBase.Delete; } } else { oCallbackUrl = parseUrl(ctx, callback); bChangeBase = c_oAscChangeBase.No; if (null !== oCallbackUrl) { const filterStatus = yield* utils.checkHostFilter(ctx, oCallbackUrl.host); if (filterStatus > 0) { ctx.logger.warn('checkIpFilter error: url = %s', callback); //todo add new error type oCallbackUrl = null; } } } if (null !== oCallbackUrl) { return yield sendStatusDocument(ctx, docId, bChangeBase, opt_userAction, undefined, oCallbackUrl, baseUrl, opt_userData); } return null; } const unlockWopiDoc = co.wrap(function* (ctx, docId, opt_userIndex) { //wopi unlock const getRes = yield getCallback(ctx, docId, opt_userIndex); if (getRes && getRes.wopiParams && getRes.wopiParams.userAuth && 'view' !== getRes.wopiParams.userAuth.mode) { const unlockRes = yield wopiClient.unlock(ctx, getRes.wopiParams); const unlockInfo = wopiClient.getWopiUnlockMarker(getRes.wopiParams); if (unlockInfo && unlockRes) { yield canvasService.commandOpenStartPromise(ctx, docId, undefined, unlockInfo); } } }); function* cleanDocumentOnExit(ctx, docId, deleteChanges, opt_userIndex) { const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles); //clean redis (redisKeyPresenceSet and redisKeyPresenceHash removed with last element) yield editorData.cleanDocumentOnExit(ctx, docId); if (preStopFlag && editorStatProxy?.deleteKey) { yield editorStatProxy.deleteKey(docId); } //remove changes if (deleteChanges) { yield taskResult.restoreInitialPassword(ctx, docId); sqlBase.deleteChanges(ctx, docId, null); //delete forgotten after successful send on callbackUrl yield storage.deletePath(ctx, docId, tenForgottenFiles); } yield unlockWopiDoc(ctx, docId, opt_userIndex); } function* cleanDocumentOnExitNoChanges(ctx, docId, opt_userId, opt_userIndex, opt_forceClose, opt_deleteChanges) { const userAction = opt_userId ? new commonDefines.OutputAction(commonDefines.c_oAscUserAction.Out, opt_userId) : null; // We send that everyone is gone and there are no changes (to set the status on the server about the end of editing) yield sendStatusDocument(ctx, docId, c_oAscChangeBase.No, userAction, opt_userIndex, undefined, undefined, undefined, opt_forceClose); //if the user entered the document, the connection was broken, all information was deleted on the server, //when the connection is restored, the userIndex will be saved and it will match the userIndex of the next user yield* cleanDocumentOnExit(ctx, docId, opt_deleteChanges || false, opt_userIndex); } function createSaveTimer(ctx, docId, opt_userId, opt_userIndex, opt_userLcid, opt_queue, opt_noDelay, opt_initShardKey) { return co(function* () { const tenAscSaveTimeOutDelay = ctx.getCfg('services.CoAuthoring.server.savetimeoutdelay', cfgAscSaveTimeOutDelay); const updateMask = new taskResult.TaskResultData(); updateMask.tenant = ctx.tenant; updateMask.key = docId; updateMask.status = commonDefines.FileStatus.Ok; updateMask.callback = 'NOT_EMPTY'; const updateTask = new taskResult.TaskResultData(); updateTask.status = commonDefines.FileStatus.SaveVersion; updateTask.statusInfo = utils.getMillisecondsOfHour(new Date()); const updateIfRes = yield taskResult.updateIf(ctx, updateTask, updateMask); if (updateIfRes.affectedRows > 0) { if (!opt_noDelay) { yield utils.sleep(tenAscSaveTimeOutDelay); } while (true) { if (!sqlBase.isLockCriticalSection(docId)) { yield canvasService.saveFromChanges( ctx, docId, updateTask.statusInfo, null, opt_userId, opt_userIndex, opt_userLcid, opt_queue, opt_initShardKey ); break; } yield utils.sleep(c_oAscLockTimeOutDelay); } } else { const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0 && selectRes[0].callback) { //if it didn't work, it means FileStatus=SaveVersion(someone else started building) or UpdateVersion(build completed) // in this case, nothing needs to be done ctx.logger.debug('createSaveTimer updateIf no effect'); } else { ctx.logger.debug('createSaveTimer empty callback: %s', docId); yield* cleanDocumentOnExitNoChanges(ctx, docId, opt_userId, opt_userIndex, false, true); } } }); } function checkJwt(ctx, token, type) { return co(function* () { const tenTokenVerifyOptions = ctx.getCfg('services.CoAuthoring.token.verifyOptions', cfgTokenVerifyOptions); const res = {decoded: null, description: null, code: null, token}; const secret = yield tenantManager.getTenantSecret(ctx, type); if (undefined == secret) { ctx.logger.warn('empty secret: token = %s', token); } try { res.decoded = jwt.verify(token, utils.getJwtHsKey(secret), tenTokenVerifyOptions); ctx.logger.debug('checkJwt success: decoded = %j', res.decoded); } catch (err) { ctx.logger.warn('checkJwt error: name = %s message = %s token = %s', err.name, err.message, token); if ('TokenExpiredError' === err.name) { res.code = constants.JWT_EXPIRED_CODE; res.description = constants.JWT_EXPIRED_REASON + err.message; } else if ('JsonWebTokenError' === err.name) { res.code = constants.JWT_ERROR_CODE; res.description = constants.JWT_ERROR_REASON + err.message; } } return res; }); } function checkJwtHeader(ctx, req, opt_header, opt_prefix, opt_secretType) { return co(function* () { const tenTokenInboxHeader = ctx.getCfg('services.CoAuthoring.token.inbox.header', cfgTokenInboxHeader); const tenTokenInboxPrefix = ctx.getCfg('services.CoAuthoring.token.inbox.prefix', cfgTokenInboxPrefix); const header = opt_header || tenTokenInboxHeader; const prefix = opt_prefix || tenTokenInboxPrefix; const secretType = opt_secretType || commonDefines.c_oAscSecretType.Inbox; const authorization = req.get(header); if (authorization && authorization.startsWith(prefix)) { const token = authorization.substring(prefix.length); return yield checkJwt(ctx, token, secretType); } return null; }); } function getRequestParams(ctx, req, _opt_isNotInBody) { return co(function* () { const tenTokenEnableRequestInbox = ctx.getCfg('services.CoAuthoring.token.enable.request.inbox', cfgTokenEnableRequestInbox); const tenTokenRequiredParams = ctx.getCfg('services.CoAuthoring.server.tokenRequiredParams', cfgTokenRequiredParams); const res = {code: constants.NO_ERROR, description: '', isDecoded: false, params: undefined}; if (req.body && Buffer.isBuffer(req.body) && req.body.length > 0) { try { res.params = JSON.parse(req.body.toString('utf8')); } catch (err) { ctx.logger.debug('getRequestParams error parsing json body: %s', err.stack); } } if (!res.params) { res.params = req.query; } if (tenTokenEnableRequestInbox) { res.code = constants.VKEY; let checkJwtRes; if (res.params.token) { checkJwtRes = yield checkJwt(ctx, res.params.token, commonDefines.c_oAscSecretType.Inbox); } else { checkJwtRes = yield checkJwtHeader(ctx, req); } if (checkJwtRes) { if (checkJwtRes.decoded) { res.code = constants.NO_ERROR; res.isDecoded = true; if (tenTokenRequiredParams) { res.params = {}; } Object.assign(res.params, checkJwtRes.decoded); if (!utils.isEmptyObject(checkJwtRes.decoded.payload)) { Object.assign(res.params, checkJwtRes.decoded.payload); } if (!utils.isEmptyObject(checkJwtRes.decoded.query)) { Object.assign(res.params, checkJwtRes.decoded.query); } } else if (constants.JWT_EXPIRED_CODE == checkJwtRes.code) { res.code = constants.VKEY_KEY_EXPIRE; } res.description = checkJwtRes.description; } } return res; }); } function getLicenseNowUtc() { const now = new Date(); return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds()) / 1000; } const getParticipantMap = co.wrap(function* (ctx, docId, opt_hvals) { const participantsMap = []; let hvals; if (opt_hvals) { hvals = opt_hvals; } else { hvals = yield editorData.getPresence(ctx, docId, connections); } for (let i = 0; i < hvals.length; ++i) { const elem = JSON.parse(hvals[i]); if (!elem.isCloseCoAuthoring) { participantsMap.push(elem); } } return participantsMap; }); function getOpenFormatByEditor(editorType) { let res; switch (editorType) { case EditorTypes.spreadsheet: res = constants.AVS_OFFICESTUDIO_FILE_CANVAS_SPREADSHEET; break; case EditorTypes.presentation: res = constants.AVS_OFFICESTUDIO_FILE_CANVAS_PRESENTATION; break; case EditorTypes.diagram: res = constants.AVS_OFFICESTUDIO_FILE_DRAW_VSDX; break; default: res = constants.AVS_OFFICESTUDIO_FILE_CANVAS_WORD; break; } return res; } async function isSchemaCompatible([tableName, tableSchema]) { const resultSchema = await sqlBase.getTableColumns(operationContext.global, tableName); if (resultSchema.length === 0) { operationContext.global.logger.error('DB table "%s" does not exist', tableName); return false; } const columnArray = resultSchema.map(row => row['column_name']); const hashedResult = new Set(columnArray); const schemaDiff = tableSchema.filter(column => !hashedResult.has(column)); if (schemaDiff.length > 0) { operationContext.global.logger.error(`DB table "${tableName}" does not contain columns: ${schemaDiff}, columns info: ${columnArray}`); return false; } return true; } exports.c_oAscServerStatus = c_oAscServerStatus; exports.editorData = editorData; exports.editorStat = editorStat; exports.editorStatProxy = editorStatProxy; exports.sendData = sendData; exports.modifyConnectionForPassword = modifyConnectionForPassword; exports.parseUrl = parseUrl; exports.parseReplyData = parseReplyData; exports.sendServerRequest = sendServerRequest; exports.createSaveTimer = createSaveTimer; exports.changeConnectionInfo = changeConnectionInfo; exports.signToken = signToken; exports.publish = publish; exports.addTask = addTask; exports.addDelayed = addDelayed; exports.removeResponse = removeResponse; exports.hasEditors = hasEditors; exports.getEditorsCountPromise = co.wrap(getEditorsCount); exports.getCallback = getCallback; exports.getIsShutdown = getIsShutdown; exports.getIsPreStop = getIsPreStop; exports.hasChanges = hasChanges; exports.cleanDocumentOnExitPromise = co.wrap(cleanDocumentOnExit); exports.cleanDocumentOnExitNoChangesPromise = co.wrap(cleanDocumentOnExitNoChanges); exports.unlockWopiDoc = unlockWopiDoc; exports.setForceSave = setForceSave; exports.startForceSave = startForceSave; exports.resetForceSaveAfterChanges = resetForceSaveAfterChanges; exports.getExternalChangeInfo = getExternalChangeInfo; exports.checkJwt = checkJwt; exports.getRequestParams = getRequestParams; exports.checkJwtHeader = checkJwtHeader; async function encryptPasswordParams(ctx, data) { let dataWithPassword; if (data.type === 'openDocument' && data.message) { dataWithPassword = data.message; } else if (data.type === 'auth' && data.openCmd) { dataWithPassword = data.openCmd; } if (dataWithPassword && dataWithPassword.password) { if (dataWithPassword.password.length > constants.PASSWORD_MAX_LENGTH) { //todo send back error ctx.logger.warn( 'encryptPasswordParams password too long actual = %s; max = %s', dataWithPassword.password.length, constants.PASSWORD_MAX_LENGTH ); dataWithPassword.password = null; } else { dataWithPassword.password = await utils.encryptPassword(ctx, dataWithPassword.password); } } if (dataWithPassword && dataWithPassword.savepassword) { if (dataWithPassword.savepassword.length > constants.PASSWORD_MAX_LENGTH) { //todo send back error ctx.logger.warn( 'encryptPasswordParams password too long actual = %s; max = %s', dataWithPassword.savepassword.length, constants.PASSWORD_MAX_LENGTH ); dataWithPassword.savepassword = null; } else { dataWithPassword.savepassword = await utils.encryptPassword(ctx, dataWithPassword.savepassword); } } } exports.encryptPasswordParams = encryptPasswordParams; exports.getOpenFormatByEditor = getOpenFormatByEditor; exports.install = function (server, app, callbackFunction) { const io = new Server(server, cfgSocketIoConnection); io.use((socket, next) => { co(function* () { const ctx = new operationContext.Context(); let res; let checkJwtRes; try { ctx.initFromConnection(socket); yield ctx.initTenantCache(); ctx.logger.info('io.use start'); const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser); const handshake = socket.handshake; const token = handshake?.auth?.session || handshake?.auth?.token; if (tenTokenEnableBrowser || token) { const secretType = handshake?.auth?.session ? commonDefines.c_oAscSecretType.Session : commonDefines.c_oAscSecretType.Browser; checkJwtRes = yield checkJwt(ctx, token, secretType); if (!checkJwtRes.decoded) { res = new Error('not authorized'); res.data = {code: checkJwtRes.code, description: checkJwtRes.description}; } } } catch (err) { ctx.logger.error('io.use error: %s', err.stack); } finally { ctx.logger.info('io.use end'); next(res); } }); }); io.on('connection', async conn => { const ctx = new operationContext.Context(); try { if (!conn) { operationContext.global.logger.error('null == conn'); return; } ctx.initFromConnection(conn); await ctx.initTenantCache(); if (constants.DEFAULT_DOC_ID === ctx.docId) { ctx.logger.error('io.on connection unexpected key use key pattern = "%s" url = %s', constants.DOC_ID_PATTERN, conn.handshake?.url); sendDataDisconnectReason(ctx, conn, constants.ACCESS_DENIED_CODE, constants.ACCESS_DENIED_REASON); conn.disconnect(true); return; } if (getIsShutdown()) { sendDataDisconnectReason(ctx, conn, constants.SHUTDOWN_CODE, constants.SHUTDOWN_REASON); conn.disconnect(true); return; } conn.baseUrl = utils.getBaseUrlByConnection(ctx, conn); conn.sessionIsSendWarning = false; conn.sessionTimeConnect = conn.sessionTimeLastAction = new Date().getTime(); conn.on('message', data => { return co(function* () { let docId = 'null'; const ctx = new operationContext.Context(); try { ctx.initFromConnection(conn); yield ctx.initTenantCache(); const tenErrorFiles = ctx.getCfg('FileConverter.converter.errorfiles', cfgErrorFiles); let startDate = null; if (clientStatsD) { startDate = new Date(); } docId = conn.docId; ctx.logger.info('data.type = %s', data.type); if (getIsShutdown()) { ctx.logger.debug('Server shutdown receive data'); return; } if ( (conn.isCloseCoAuthoring || (conn.user && conn.user.view)) && ('getLock' == data.type || 'saveChanges' == data.type || 'isSaveLock' == data.type) ) { ctx.logger.warn('conn.user.view||isCloseCoAuthoring access deny: type = %s', data.type); sendDataDisconnectReason(ctx, conn, constants.ACCESS_DENIED_CODE, constants.ACCESS_DENIED_REASON); conn.disconnect(true); return; } yield encryptPasswordParams(ctx, data); switch (data.type) { case 'auth': try { yield* auth(ctx, conn, data); } catch (err) { ctx.logger.error('auth error: %s', err.stack); sendDataDisconnectReason(ctx, conn, constants.ACCESS_DENIED_CODE, constants.ACCESS_DENIED_REASON); conn.disconnect(true); return; } break; case 'message': yield* onMessage(ctx, conn, data); break; case 'cursor': yield* onCursor(ctx, conn, data); break; case 'getLock': yield getLock(ctx, conn, data, false); break; case 'saveChanges': yield* saveChanges(ctx, conn, data); break; case 'isSaveLock': yield* isSaveLock(ctx, conn, data); break; case 'unSaveLock': yield* unSaveLock(ctx, conn, -1, -1, -1); break; // The index is sent -1, because this is an emergency withdrawal without saving case 'getMessages': yield* getMessages(ctx, conn, data); break; case 'unLockDocument': yield* checkEndAuthLock(ctx, data.unlock, data.isSave, docId, conn.user.id, data.releaseLocks, data.deleteIndex, conn); break; case 'close': yield* closeDocument(ctx, conn); break; case 'openDocument': { const cmd = new commonDefines.InputCommand(data.message); cmd.fillFromConnection(conn); yield canvasService.openDocument(ctx, conn, cmd); break; } case 'clientLog': yield handleClientLog(ctx, conn, docId, data, tenErrorFiles); break; case 'extendSession': ctx.logger.debug('extendSession idletime: %d', data.idletime); conn.sessionIsSendWarning = false; conn.sessionTimeLastAction = new Date().getTime() - data.idletime; break; case 'forceSaveStart': { let forceSaveRes; if (conn.user) { forceSaveRes = yield startForceSave( ctx, docId, commonDefines.c_oAscForceSaveTypes.Button, undefined, undefined, conn.user.idOriginal, conn.user.id, undefined, conn.user.indexUser, undefined, undefined, undefined, undefined, conn ); } else { forceSaveRes = {code: commonDefines.c_oAscServerCommandErrors.UnknownError, time: null}; } sendData(ctx, conn, {type: 'forceSaveStart', messages: forceSaveRes}); break; } case 'rpc': yield* startRPC(ctx, conn, data.responseKey, data.data); break; case 'authChangesAck': delete conn.authChangesAck; break; default: ctx.logger.debug('unknown command %j', data); break; } if (clientStatsD) { const isSendMetric = 'auth' === data.type || 'getLock' === data.type || 'saveChanges' === data.type; if (isSendMetric) { clientStatsD.timing('coauth.data.' + data.type, new Date() - startDate); } } } catch (e) { ctx.logger.error('error receiving response: type = %s %s', data && data.type ? data.type : 'null', e.stack); } }); }); conn.on('disconnect', reason => { return co(function* () { const ctx = new operationContext.Context(); try { ctx.initFromConnection(conn); yield ctx.initTenantCache(); yield* closeDocument(ctx, conn, reason); } catch (err) { ctx.logger.error('Error conn close: %s', err.stack); } }); }); _checkLicense(ctx, conn); } catch (err) { ctx.logger.error('connection error: %s', err.stack); sendDataDisconnectReason(ctx, conn, constants.DROP_CODE, constants.DROP_REASON); conn.disconnect(true); } }); io.engine.on('connection_error', err => { let logger = operationContext.global.logger; if (err.req) { const ctx = new operationContext.Context(); // Ensure raw IncomingMessage has Express properties for consistent context init utils.expressifyIncomingMessage(err.req, app); ctx.initFromConnectionRequest(err.req); logger = ctx.logger; } logger.warn('io.connection_error code=%s, message=%s, url=%s', err?.code, err?.message, err?.req?.url); }); /** * * @param ctx * @param conn * @param reason - the reason of the disconnection (either client or server-side) */ function* closeDocument(ctx, conn, reason) { const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles); ctx.logger.info('Connection closed or timed out: reason = %s', reason); let userLocks, reconnected = false, bHasEditors, bHasChanges; const docId = conn.docId; if (null == docId) { return; } let hvals; let participantsTimestamp; const tmpUser = conn.user; const isView = tmpUser.view; const isCloseCoAuthoringTmp = conn.isCloseCoAuthoring; if (reason) { //Notify that participant has gone connections = _.reject(connections, el => { return el.id === conn.id; //Delete this connection }); //Check if it's not already reconnected reconnected = yield* isUserReconnect(ctx, docId, tmpUser.id, conn.id); if (reconnected) { ctx.logger.info('reconnected'); } else { yield removePresence(ctx, conn); hvals = yield editorData.getPresence(ctx, docId, connections); participantsTimestamp = Date.now(); if (hvals.length <= 0) { yield editorData.removePresenceDocument(ctx, docId); } } } else { if (!conn.isCloseCoAuthoring && !isView) { modifyConnectionEditorToView(ctx, conn); conn.isCloseCoAuthoring = true; yield addPresence(ctx, conn, true); const sessionToken = yield fillJwtByConnection(ctx, conn); sendDataRefreshToken(ctx, conn, sessionToken); } } if (isCloseCoAuthoringTmp) { //we already close connection return; } if (!reconnected) { //revert old view to send event const tmpView = tmpUser.view; tmpUser.view = isView; const participants = yield getParticipantMap(ctx, docId, hvals); if (!participantsTimestamp) { participantsTimestamp = Date.now(); } yield publish( ctx, {type: commonDefines.c_oPublishType.participantsState, ctx, docId, userId: tmpUser.id, participantsTimestamp, participants}, docId, tmpUser.id ); tmpUser.view = tmpView; // editors only if (false === isView) { // For this user, we remove the lock from saving yield editorData.unlockSave(ctx, docId, conn.user.id); bHasEditors = yield* hasEditors(ctx, docId, hvals); bHasChanges = yield hasChanges(ctx, docId); let needSendStatus = true; if (conn.encrypted) { const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0) { const row = selectRes[0]; if (commonDefines.FileStatus.UpdateVersion === row.status) { needSendStatus = false; } } } //Release locks userLocks = yield removeUserLocks(ctx, docId, conn.user.id); if (0 < userLocks.length) { //todo send nothing in case of close document //sendReleaseLock(conn, userLocks); yield publish( ctx, {type: commonDefines.c_oPublishType.releaseLock, ctx, docId, userId: conn.user.id, locks: userLocks}, docId, conn.user.id ); } // For this user, remove the Lock from the document yield* checkEndAuthLock(ctx, true, false, docId, conn.user.id); const userIndex = utils.getIndexFromUserId(tmpUser.id, tmpUser.idOriginal); // If we do not have users, then delete all messages if (!bHasEditors) { // Just in case, remove the lock yield editorData.unlockSave(ctx, docId, tmpUser.id); let needSaveChanges = bHasChanges; if (!needSaveChanges) { //start save changes if forgotten file exists. //more effective to send file without sfc, but this method is simpler by code const forgotten = yield storage.listObjects(ctx, docId, tenForgottenFiles); needSaveChanges = forgotten.length > 0; ctx.logger.debug('closeDocument hasForgotten %s', needSaveChanges); } if (needSaveChanges && !conn.encrypted) { // Send changes to save server const user_lcid = utilsDocService.localeToLCID(conn.lang); //noDelay=true if the client intentionally closes connection or server shuts down const noDelay = !reason || getIsShutdown(); yield createSaveTimer(ctx, docId, tmpUser.idOriginal, userIndex, user_lcid, undefined, noDelay); } else if (needSendStatus) { yield* cleanDocumentOnExitNoChanges(ctx, docId, tmpUser.idOriginal, userIndex); } else { yield* cleanDocumentOnExit(ctx, docId, false, userIndex); } } else if (needSendStatus) { yield sendStatusDocument( ctx, docId, c_oAscChangeBase.No, new commonDefines.OutputAction(commonDefines.c_oAscUserAction.Out, tmpUser.idOriginal), userIndex ); } } else { if (preStopFlag && hvals?.length <= 0 && editorStatProxy?.deleteKey) { yield editorStatProxy.deleteKey(docId); } } const sessionType = isView ? 'view' : 'edit'; const sessionTimeMs = new Date().getTime() - conn.sessionTimeConnect; ctx.logger.debug(`closeDocument %s session time:%s`, sessionType, sessionTimeMs); if (clientStatsD) { clientStatsD.timing(`coauth.session.${sessionType}`, sessionTimeMs); } } } /** * Handle client log message and create error files once per connection on first error. * @param {object} ctx - Operation context * @param {object} conn - Socket connection * @param {string} docId - Document identifier * @param {{level?: string, msg?: string}} data - Client log data * @param {object} tenErrorFiles - Error files storage configuration * @returns {Promise} */ async function handleClientLog(ctx, conn, docId, data, tenErrorFiles) { const level = data.level?.toLowerCase(); if ('trace' === level || 'debug' === level || 'info' === level || 'warn' === level || 'error' === level || 'fatal' === level) { ctx.logger[level]('clientLog: %s', data.msg); } if ('error' === level && tenErrorFiles && docId && !conn.clientError) { conn.clientError = true; const destDir = 'browser/' + docId; const list = await storage.listObjects(ctx, destDir, tenErrorFiles); if (list.length === 0) { await storage.copyPath(ctx, docId, destDir, undefined, tenErrorFiles); await saveErrorChanges(ctx, docId, destDir); } } } // Getting changes for the document (either from the cache or accessing the database, but only if there were saves) function* getDocumentChanges(ctx, docId, optStartIndex, optEndIndex) { // If during that moment, while we were waiting for a response from the database, everyone left, then nothing needs to be sent const arrayElements = yield sqlBase.getChangesPromise(ctx, docId, optStartIndex, optEndIndex); let j, element; const objChangesDocument = new DocumentChanges(docId); for (j = 0; j < arrayElements.length; ++j) { element = arrayElements[j]; // We add GMT, because. we write UTC to the database, but the string without UTC is saved there and the time will be wrong when reading objChangesDocument.push({ docid: docId, change: element['change_data'], time: element['change_date'].getTime(), user: element['user_id'], useridoriginal: element['user_id_original'] }); } return objChangesDocument; } async function removeUserLocks(ctx, docId, userId) { const locks = await editorData.getLocks(ctx, docId); const res = []; const toRemove = {}; for (const lockId in locks) { const lock = locks[lockId]; if (lock.user === userId) { toRemove[lockId] = lock; res.push(lock); } } await editorData.removeLocks(ctx, docId, toRemove); return res; } function* checkEndAuthLock(ctx, unlock, isSave, docId, userId, releaseLocks, deleteIndex, conn) { let result = false; if (null != deleteIndex && -1 !== deleteIndex) { let puckerIndex = yield* getChangesIndex(ctx, docId); const deleteCount = puckerIndex - deleteIndex; if (0 < deleteCount) { puckerIndex -= deleteCount; yield sqlBase.deleteChangesPromise(ctx, docId, deleteIndex); } else if (0 > deleteCount) { ctx.logger.error('Error checkEndAuthLock: deleteIndex: %s ; startIndex: %s ; deleteCount: %s', deleteIndex, puckerIndex, deleteCount); } } if (unlock) { const unlockRes = yield editorData.unlockAuth(ctx, docId, userId); if (commonDefines.c_oAscUnlockRes.Unlocked === unlockRes) { const participantsMap = yield getParticipantMap(ctx, docId); yield publish(ctx, { type: commonDefines.c_oPublishType.auth, ctx, docId, userId, participantsMap }); result = true; } } //Release locks if (releaseLocks && conn) { const userLocks = yield removeUserLocks(ctx, docId, userId); if (0 < userLocks.length) { sendReleaseLock(ctx, conn, userLocks); yield publish( ctx, { type: commonDefines.c_oPublishType.releaseLock, ctx, docId, userId, locks: userLocks }, docId, userId ); } } if (isSave && conn) { // Automatically remove the lock ourselves yield* unSaveLock(ctx, conn, -1, -1, -1); } return result; } /** * Schedule lock cleanup for a document after the configured timeout. * @param {operationContext} ctx - Operation context * @param {string} docId - Document identifier * @param {string} userId - User identifier associated with the lock */ function setLockDocumentTimer(ctx, docId, userId) { const tenExpLockDoc = ctx.getCfg('services.CoAuthoring.expire.lockDoc', cfgExpLockDoc); const timerId = setTimeout(() => { return co(function* () { try { ctx.logger.warn('lockDocumentsTimerId timeout'); delete lockDocumentsTimerId[docId]; //todo remove checkEndAuthLock(only needed for lost connections in redis) yield* checkEndAuthLock(ctx, true, false, docId, userId); yield* publishCloseUsersConnection(ctx, docId, [userId], false, constants.DROP_CODE, constants.DROP_REASON); } catch (e) { ctx.logger.error('lockDocumentsTimerId error: %s', e.stack); } }); }, 1000 * tenExpLockDoc); lockDocumentsTimerId[docId] = {timerId, userId}; ctx.logger.debug('lockDocumentsTimerId set'); } function cleanLockDocumentTimer(docId, lockDocumentTimer) { clearTimeout(lockDocumentTimer.timerId); delete lockDocumentsTimerId[docId]; } function sendParticipantsState(ctx, participants, data) { _.each(participants, participant => { sendData(ctx, participant, { type: 'connectState', participantsTimestamp: data.participantsTimestamp, participants: data.participants, waitAuth: !!data.waitAuthUserId }); }); } function sendFileError(ctx, conn, errorId, code, opt_notWarn) { if (opt_notWarn) { ctx.logger.debug('error description: errorId = %s', errorId); } else { ctx.logger.warn('error description: errorId = %s', errorId); } sendData(ctx, conn, {type: 'error', description: errorId, code}); } function* sendFileErrorAuth(ctx, conn, sessionId, errorId, code, opt_notWarn) { conn.sessionId = sessionId; //restore old //Kill previous connections connections = _.reject(connections, el => { return el.sessionId === sessionId; //Delete this connection }); //closing could happen during async action if (constants.CONN_CLOSED !== conn.conn.readyState) { modifyConnectionEditorToView(ctx, conn); conn.isCloseCoAuthoring = true; // We put it in an array, because we need to send data to open/save the document connections.push(conn); yield addPresence(ctx, conn, true); const sessionToken = yield fillJwtByConnection(ctx, conn); sendDataRefreshToken(ctx, conn, sessionToken); sendFileError(ctx, conn, errorId, code, opt_notWarn); } } // Recalculation only for foreign Lock when saving on a client that added/deleted rows or columns function _recalcLockArray(userId, _locks, oRecalcIndexColumns, oRecalcIndexRows) { const res = {}; if (null == _locks) { return res; } let element = null, oRangeOrObjectId = null; let sheetId = -1; for (const lockId in _locks) { let isModify = false; const lock = _locks[lockId]; // we do not count for ourselves if (userId === lock.user) { continue; } element = lock.block; if ( c_oAscLockTypeElem.Range !== element['type'] || c_oAscLockTypeElemSubType.InsertColumns === element['subType'] || c_oAscLockTypeElemSubType.InsertRows === element['subType'] ) { continue; } sheetId = element['sheetId']; oRangeOrObjectId = element['rangeOrObjectId']; if (oRecalcIndexColumns && Object.hasOwn(oRecalcIndexColumns, sheetId)) { // Column index recalculation oRangeOrObjectId['c1'] = oRecalcIndexColumns[sheetId].getLockMe2(oRangeOrObjectId['c1']); oRangeOrObjectId['c2'] = oRecalcIndexColumns[sheetId].getLockMe2(oRangeOrObjectId['c2']); isModify = true; } if (oRecalcIndexRows && Object.hasOwn(oRecalcIndexRows, sheetId)) { // row index recalculation oRangeOrObjectId['r1'] = oRecalcIndexRows[sheetId].getLockMe2(oRangeOrObjectId['r1']); oRangeOrObjectId['r2'] = oRecalcIndexRows[sheetId].getLockMe2(oRangeOrObjectId['r2']); isModify = true; } if (isModify) { res[lockId] = lock; } } return res; } function _addRecalcIndex(oRecalcIndex) { if (null == oRecalcIndex) { return null; } let nIndex = 0; let nRecalcType = c_oAscRecalcIndexTypes.RecalcIndexAdd; let oRecalcIndexElement = null; const oRecalcIndexResult = {}; for (const sheetId in oRecalcIndex) { if (Object.hasOwn(oRecalcIndex, sheetId)) { if (!Object.hasOwn(oRecalcIndexResult, sheetId)) { oRecalcIndexResult[sheetId] = new CRecalcIndex(); } for (; nIndex < oRecalcIndex[sheetId]._arrElements.length; ++nIndex) { oRecalcIndexElement = oRecalcIndex[sheetId]._arrElements[nIndex]; if (true === oRecalcIndexElement.m_bIsSaveIndex) { continue; } nRecalcType = c_oAscRecalcIndexTypes.RecalcIndexAdd === oRecalcIndexElement._recalcType ? c_oAscRecalcIndexTypes.RecalcIndexRemove : c_oAscRecalcIndexTypes.RecalcIndexAdd; // Duplicate to return the result (we only need to recalculate by the last index oRecalcIndexResult[sheetId].add(nRecalcType, oRecalcIndexElement._position, oRecalcIndexElement._count, /*bIsSaveIndex*/ true); } } } return oRecalcIndexResult; } function compareExcelBlock(newBlock, oldBlock) { // This is a lock to remove or add rows/columns if (null !== newBlock.subType && null !== oldBlock.subType) { return true; } // Ignore lock from ChangeProperties (only if it's not a leaf lock) if ( (c_oAscLockTypeElemSubType.ChangeProperties === oldBlock.subType && c_oAscLockTypeElem.Sheet !== newBlock.type) || (c_oAscLockTypeElemSubType.ChangeProperties === newBlock.subType && c_oAscLockTypeElem.Sheet !== oldBlock.type) ) { return false; } let resultLock = false; if (newBlock.type === c_oAscLockTypeElem.Range) { if (oldBlock.type === c_oAscLockTypeElem.Range) { // We do not take into account lock from Insert if (c_oAscLockTypeElemSubType.InsertRows === oldBlock.subType || c_oAscLockTypeElemSubType.InsertColumns === oldBlock.subType) { resultLock = false; } else if (isInterSection(newBlock.rangeOrObjectId, oldBlock.rangeOrObjectId)) { resultLock = true; } } else if (oldBlock.type === c_oAscLockTypeElem.Sheet) { resultLock = true; } } else if (newBlock.type === c_oAscLockTypeElem.Sheet) { resultLock = true; } else if (newBlock.type === c_oAscLockTypeElem.Object) { if (oldBlock.type === c_oAscLockTypeElem.Sheet) { resultLock = true; } else if (oldBlock.type === c_oAscLockTypeElem.Object && oldBlock.rangeOrObjectId === newBlock.rangeOrObjectId) { resultLock = true; } } return resultLock; } function isInterSection(range1, range2) { if (range2.c1 > range1.c2 || range2.c2 < range1.c1 || range2.r1 > range1.r2 || range2.r2 < range1.r1) { return false; } return true; } function comparePresentationBlock(newBlock, oldBlock) { let resultLock = false; switch (newBlock.type) { case c_oAscLockTypeElemPresentation.Presentation: if (c_oAscLockTypeElemPresentation.Presentation === oldBlock.type) { resultLock = newBlock.val === oldBlock.val; } break; case c_oAscLockTypeElemPresentation.Slide: if (c_oAscLockTypeElemPresentation.Slide === oldBlock.type) { resultLock = newBlock.val === oldBlock.val; } else if (c_oAscLockTypeElemPresentation.Object === oldBlock.type) { resultLock = newBlock.val === oldBlock.slideId; } break; case c_oAscLockTypeElemPresentation.Object: if (c_oAscLockTypeElemPresentation.Slide === oldBlock.type) { resultLock = newBlock.slideId === oldBlock.val; } else if (c_oAscLockTypeElemPresentation.Object === oldBlock.type) { resultLock = newBlock.objId === oldBlock.objId; } break; } return resultLock; } function* authRestore(ctx, conn, sessionId) { conn.sessionId = sessionId; //restore old //Kill previous connections connections = _.reject(connections, el => { return el.sessionId === sessionId; //Delete this connection }); yield* endAuth(ctx, conn, true); } function fillUsername(ctx, data) { let name; const user = data.user; if (user.firstname && user.lastname) { //as in web-apps/apps/common/main/lib/util/utils.js const isRu = data.lang && /^ru/.test(data.lang); name = isRu ? user.lastname + ' ' + user.firstname : user.firstname + ' ' + user.lastname; } else { name = user.username || 'Anonymous'; } if (name.length > constants.USER_NAME_MAX_LENGTH) { ctx.logger.warn('fillUsername user name too long actual = %s; max = %s', name.length, constants.USER_NAME_MAX_LENGTH); name = name.substr(0, constants.USER_NAME_MAX_LENGTH); } return name; } function isEditMode(permissions, mode) { //like this.api.asc_setViewMode(!this.appOptions.isEdit && !this.appOptions.isRestrictedEdit); //https://github.com/ONLYOFFICE/web-apps/blob/4a7879b4f88f315fe94d9f7d97c0ed8aa9f82221/apps/documenteditor/main/app/controller/Main.js#L1743 //todo permissions in embed editor //https://github.com/ONLYOFFICE/web-apps/blob/72b8350c71e7b314b63b8eec675e76156bb4a2e4/apps/documenteditor/forms/app/controller/ApplicationController.js#L627 return ( (!mode || mode !== 'view') && (!permissions || permissions.edit !== false || permissions.review === true || permissions.comment === true || permissions.fillForms === true) ); } function fillDataFromWopiJwt(decoded, data) { const res = true; const openCmd = data.openCmd; if (decoded.key) { data.docid = decoded.key; } if (decoded.userAuth) { data.documentCallbackUrl = JSON.stringify(decoded.userAuth); data.mode = decoded.userAuth.mode; data.forcedViewMode = decoded.userAuth.forcedViewMode; } if (decoded.queryParams) { const queryParams = decoded.queryParams; data.lang = queryParams.lang || queryParams.ui || constants.TEMPLATES_DEFAULT_LOCALE; } if (wopiClient.isWopiJwtToken(decoded)) { const fileInfo = decoded.fileInfo; const queryParams = decoded.queryParams; if (openCmd) { openCmd.format = wopiClient.getFileTypeByInfo(fileInfo); openCmd.title = fileInfo.BreadcrumbDocName || fileInfo.BaseFileName; } const name = fileInfo.IsAnonymousUser ? '' : fileInfo.UserFriendlyName; if (name) { data.user.username = name; data.denyChangeName = true; } if (null != fileInfo.UserId) { data.user.id = fileInfo.UserId; if (openCmd) { openCmd.userid = fileInfo.UserId; } } const permissionsEdit = !fileInfo.ReadOnly && !fileInfo.UserCanOnlyComment && fileInfo.UserCanWrite && queryParams?.formsubmit !== '1'; const permissionsReview = fileInfo.UserCanOnlyComment || fileInfo.SupportsReviewing === false ? false : fileInfo.UserCanReview === false ? false : fileInfo.UserCanReview; const permissionsComment = permissionsEdit || !!fileInfo.UserCanOnlyComment; const permissionsFillForm = permissionsEdit || queryParams?.formsubmit === '1'; const permissions = { edit: permissionsEdit, review: permissionsReview, comment: permissionsComment, copy: fileInfo.CopyPasteRestrictions !== 'CurrentDocumentOnly' && fileInfo.CopyPasteRestrictions !== 'BlockAll', print: !fileInfo.DisablePrint && !fileInfo.HidePrintOption, chat: queryParams?.dchat !== '1', fillForms: permissionsFillForm }; //todo (review: undefined) // res = isDeepStrictEqual(data.permissions, permissions); if (!data.permissions) { data.permissions = {}; } //not '=' because if it jwt from previous version, we must use values from data Object.assign(data.permissions, permissions); } return res; } function validateAuthToken(data, decoded) { let res = ''; if (!decoded?.document?.key) { res = 'document.key'; } else if (data.permissions && !decoded?.document?.permissions) { res = 'document.permissions'; } else if (!decoded?.document?.url) { res = 'document.url'; } else if (data.documentCallbackUrl && !decoded?.editorConfig?.callbackUrl) { //todo callbackUrl required res = 'editorConfig.callbackUrl'; } else if (data.mode && 'view' !== data.mode && !decoded?.editorConfig?.mode) { //allow to restrict rights to 'view' res = 'editorConfig.mode'; } return res; } function fillDataFromJwt(ctx, decoded, data) { let res = true; const openCmd = data.openCmd; if (decoded.document) { const doc = decoded.document; if (null != doc.key) { data.docid = doc.key; if (openCmd) { openCmd.id = doc.key; } } if (doc.permissions) { res = isDeepStrictEqual(data.permissions, doc.permissions); if (!res) { ctx.logger.warn('fillDataFromJwt token has modified permissions'); } if (!data.permissions) { data.permissions = {}; } //not '=' because if it jwt from previous version, we must use values from data Object.assign(data.permissions, doc.permissions); } if (openCmd) { if (null != doc.fileType) { openCmd.format = doc.fileType; } if (null != doc.title) { openCmd.title = doc.title; } if (null != doc.url) { openCmd.url = doc.url; } } if (null != doc.ds_encrypted) { data.encrypted = doc.ds_encrypted; } } if (decoded.editorConfig) { const edit = decoded.editorConfig; if (null != edit.callbackUrl) { data.documentCallbackUrl = edit.callbackUrl; } if (null != edit.lang) { data.lang = edit.lang; } //allow to restrict rights so don't use token mode in case of 'view' if (null != edit.mode && 'view' !== data.mode) { data.mode = edit.mode; } if (edit.coEditing?.mode) { data.coEditingMode = edit.coEditing.mode; if (edit.coEditing?.change) { data.coEditingMode = 'fast'; } //offline viewer for pdf|djvu|xps|oxps and embeded const type = constants.VIEWER_ONLY.exec(decoded.document?.fileType); if ((type && typeof type[1] === 'string') || 'embedded' === decoded.type) { data.coEditingMode = 'strict'; } } if (null != edit.ds_isCloseCoAuthoring) { data.isCloseCoAuthoring = edit.ds_isCloseCoAuthoring; } data.isEnterCorrectPassword = edit.ds_isEnterCorrectPassword; data.denyChangeName = edit.ds_denyChangeName; // data.sessionId = edit.ds_sessionId; data.sessionTimeConnect = edit.ds_sessionTimeConnect; if (edit.user) { const dataUser = data.user; const user = edit.user; if (user.id) { dataUser.id = user.id; if (openCmd) { openCmd.userid = user.id; } } if (null != user.index) { dataUser.indexUser = user.index; } if (user.firstname) { dataUser.firstname = user.firstname; } if (user.lastname) { dataUser.lastname = user.lastname; } if (user.name) { dataUser.username = user.name; } if (user.group) { //like in Common.Utils.fillUserInfo(web-apps/apps/common/main/lib/util/utils.js) dataUser.username = user.group.toString() + String.fromCharCode(160) + dataUser.username; } if (user.customerId) { dataUser.customerId = user.customerId; } } if (edit.user && edit.user.name) { data.denyChangeName = true; } } //todo make required fields if (decoded.url || decoded.payload || (decoded.key && !wopiClient.isWopiJwtToken(decoded))) { ctx.logger.warn('fillDataFromJwt token has invalid format'); res = false; } return res; } function fillVersionHistoryFromJwt(ctx, decoded, data) { const openCmd = data.openCmd; data.mode = 'view'; data.coEditingMode = 'strict'; data.docid = decoded.key; openCmd.url = decoded.url; if (decoded.changesUrl && decoded.previous) { const versionMatch = openCmd.serverVersion === commonDefines.buildVersion; const openPreviousVersion = openCmd.id === decoded.previous.key; if (versionMatch && openPreviousVersion) { data.docid = decoded.previous.key; openCmd.url = decoded.previous.url; } else { ctx.logger.warn( 'fillVersionHistoryFromJwt serverVersion mismatch or mismatch between previous url and changes. serverVersion=%s docId=%s', openCmd.serverVersion, openCmd.id ); } } return true; } function* auth(ctx, conn, data) { const tenExpUpdateVersionStatus = ms(ctx.getCfg('services.CoAuthoring.expire.updateVersionStatus', cfgExpUpdateVersionStatus)); const tenTokenEnableBrowser = ctx.getCfg('services.CoAuthoring.token.enable.browser', cfgTokenEnableBrowser); const tenIsAnonymousSupport = ctx.getCfg('services.CoAuthoring.server.isAnonymousSupport', cfgIsAnonymousSupport); const tenTokenRequiredParams = ctx.getCfg('services.CoAuthoring.server.tokenRequiredParams', cfgTokenRequiredParams); //TODO: Do authorization etc. check md5 or query db ctx.logger.debug('auth time: %d', data.time); if (data.token && data.user) { ctx.setUserId(data.user.id); const [licenseInfo] = yield tenantManager.getTenantLicense(ctx); let isDecoded = false; //check jwt const token = data.jwtSession || data.jwtOpen; if (tenTokenEnableBrowser || token) { const secretType = data.jwtSession ? commonDefines.c_oAscSecretType.Session : commonDefines.c_oAscSecretType.Browser; const checkJwtRes = yield checkJwt(ctx, token, secretType); if (checkJwtRes.decoded) { isDecoded = true; const decoded = checkJwtRes.decoded; let fillDataFromJwtRes = false; if (wopiClient.isWopiJwtToken(decoded)) { //wopi fillDataFromJwtRes = fillDataFromWopiJwt(decoded, data); } else if (decoded.editorConfig && undefined !== decoded.editorConfig.ds_sessionTimeConnect) { //reconnection fillDataFromJwtRes = fillDataFromJwt(ctx, decoded, data); } else if (decoded.version) { //version required, but maybe add new type like jwtSession? //version history fillDataFromJwtRes = fillVersionHistoryFromJwt(ctx, decoded, data); } else { //opening const validationErr = validateAuthToken(data, decoded); if (!validationErr) { fillDataFromJwtRes = fillDataFromJwt(ctx, decoded, data); } else { ctx.logger.error('auth missing required parameter %s (since 7.1 version)', validationErr); if (tenTokenRequiredParams) { sendDataDisconnectReason(ctx, conn, constants.JWT_ERROR_CODE, constants.JWT_ERROR_REASON); conn.disconnect(true); return; } else { fillDataFromJwtRes = fillDataFromJwt(ctx, decoded, data); } } } if (!fillDataFromJwtRes) { ctx.logger.warn('fillDataFromJwt return false'); sendDataDisconnectReason(ctx, conn, constants.ACCESS_DENIED_CODE, constants.ACCESS_DENIED_REASON); conn.disconnect(true); return; } } else { sendDataDisconnectReason(ctx, conn, checkJwtRes.code, checkJwtRes.description); conn.disconnect(true); return; } } ctx.setUserId(data.user.id); const docId = data.docid; const user = data.user; let wopiParams = null, wopiParamsFull = null, openedAtStr; if (data.documentCallbackUrl) { wopiParams = wopiClient.parseWopiCallback(ctx, data.documentCallbackUrl); if (wopiParams && wopiParams.userAuth) { conn.access_token_ttl = wopiParams.userAuth.access_token_ttl; } } let cmd = null; if (data.openCmd) { cmd = new commonDefines.InputCommand(data.openCmd); cmd.setDocId(docId); if (isDecoded) { cmd.setWithAuthorization(true); } } //todo minimize select calls on opening const result = yield taskResult.select(ctx, docId); const resultRow = result.length > 0 ? result[0] : null; if (wopiParams) { if (resultRow && resultRow.callback) { wopiParamsFull = wopiClient.parseWopiCallback(ctx, data.documentCallbackUrl, resultRow.callback); cmd?.setWopiParams(wopiParamsFull); } if (!wopiParamsFull || !wopiParamsFull.userAuth || !wopiParamsFull.commonInfo) { ctx.logger.warn('invalid wopi callback (maybe postgres<9.5) %j', wopiParams); sendDataDisconnectReason(ctx, conn, constants.DROP_CODE, constants.DROP_REASON); conn.disconnect(true); return; } } //get user index const bIsRestore = null != data.sessionId; let upsertRes = null; let curIndexUser, documentCallback; if (bIsRestore) { // If we restore, we also restore the index curIndexUser = user.indexUser; } else { if (data.documentCallbackUrl && !wopiParams) { documentCallback = url.parse(data.documentCallbackUrl); const filterStatus = yield* utils.checkHostFilter(ctx, documentCallback.hostname); if (0 !== filterStatus) { ctx.logger.warn('checkIpFilter error: url = %s', data.documentCallbackUrl); sendDataDisconnectReason(ctx, conn, constants.DROP_CODE, constants.DROP_REASON); conn.disconnect(true); return; } } const format = data.openCmd && data.openCmd.format; upsertRes = yield canvasService.commandOpenStartPromise( ctx, docId, utils.getBaseUrlByConnection(ctx, conn), data.documentCallbackUrl, format ); curIndexUser = upsertRes.insertId; //todo update additional in commandOpenStartPromise if ( (upsertRes.isInsert || (wopiParams && 2 === curIndexUser)) && (undefined !== data.timezoneOffset || data.headingsColor || ctx.shardKey || ctx.wopiSrc) ) { //todo insert in commandOpenStartPromise. insert here for database compatibility if (false === canvasService.hasAdditionalCol) { const selectRes = yield taskResult.select(ctx, docId); canvasService.hasAdditionalCol = selectRes.length > 0 && undefined !== selectRes[0].additional; } if (canvasService.hasAdditionalCol) { const task = new taskResult.TaskResultData(); task.tenant = ctx.tenant; task.key = docId; if (undefined !== data.timezoneOffset || data.headingsColor) { //todo duplicate created_at because CURRENT_TIMESTAMP uses server timezone openedAtStr = sqlBase.DocumentAdditional.prototype.setOpenedAt(Date.now(), data.timezoneOffset, data.headingsColor); task.additional = openedAtStr; } if (ctx.shardKey) { task.additional += sqlBase.DocumentAdditional.prototype.setShardKey(ctx.shardKey); } if (ctx.wopiSrc) { task.additional += sqlBase.DocumentAdditional.prototype.setWopiSrc(ctx.wopiSrc); } yield taskResult.update(ctx, task); } else { ctx.logger.warn('auth unknown column "additional"'); } } } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return; } const curUserIdOriginal = String(user.id); const curUserId = curUserIdOriginal + curIndexUser; conn.tenant = tenantManager.getTenantByConnection(ctx, conn); conn.docId = data.docid; conn.permissions = data.permissions; conn.user = { id: curUserId, idOriginal: curUserIdOriginal, username: fillUsername(ctx, data), customerId: user.customerId, indexUser: curIndexUser, view: !isEditMode(data.permissions, data.mode) }; if (conn.user.view && utils.isLiveViewerSupport(licenseInfo)) { conn.coEditingMode = data.coEditingMode; } conn.isCloseCoAuthoring = data.isCloseCoAuthoring; conn.isEnterCorrectPassword = data.isEnterCorrectPassword; conn.denyChangeName = data.denyChangeName; conn.editorType = data['editorType']; if (data.sessionTimeConnect) { conn.sessionTimeConnect = data.sessionTimeConnect; } if (data.sessionTimeIdle >= 0) { conn.sessionTimeLastAction = new Date().getTime() - data.sessionTimeIdle; } conn.unsyncTime = null; conn.encrypted = data.encrypted; conn.lang = data.lang; conn.supportAuthChangesAck = data.supportAuthChangesAck; const c_LR = constants.LICENSE_RESULT; conn.licenseType = c_LR.Success; const isLiveViewer = utils.isLiveViewer(conn); if (!conn.user.view || isLiveViewer) { let licenseType = yield* _checkLicenseAuth(ctx, licenseInfo, conn.user.idOriginal, isLiveViewer); let aggregationCtx, licenseInfoAggregation; if ( (c_LR.Success === licenseType || c_LR.SuccessLimit === licenseType) && tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx) ) { //check server aggregation license aggregationCtx = new operationContext.Context(); aggregationCtx.init(tenantManager.getDefautTenant(), ctx.docId, ctx.userId); //yield ctx.initTenantCache(); //no need licenseInfoAggregation = tenantManager.getServerLicense(); licenseType = yield* _checkLicenseAuth(aggregationCtx, licenseInfoAggregation, `${ctx.tenant}:${conn.user.idOriginal}`, isLiveViewer); } conn.licenseType = licenseType; if ((c_LR.Success !== licenseType && c_LR.SuccessLimit !== licenseType) || (!tenIsAnonymousSupport && data.IsAnonymousUser)) { if (!tenIsAnonymousSupport && data.IsAnonymousUser) { //do not modify the licenseType because this information is already sent in _checkLicense ctx.logger.error('auth: access to editor or live viewer is denied for anonymous users'); } modifyConnectionEditorToView(ctx, conn); } else { //don't check IsAnonymousUser via jwt because substituting it doesn't lead to any trouble yield* updateEditUsers(ctx, licenseInfo, conn.user.idOriginal, !!data.IsAnonymousUser, isLiveViewer); if (aggregationCtx && licenseInfoAggregation) { //update server aggregation license yield* updateEditUsers( aggregationCtx, licenseInfoAggregation, `${ctx.tenant}:${conn.user.idOriginal}`, !!data.IsAnonymousUser, isLiveViewer ); } } } // Situation when the user is already disabled from co-authoring if (bIsRestore && data.isCloseCoAuthoring) { conn.sessionId = data.sessionId; //restore old // delete previous connections connections = _.reject(connections, el => { return el.sessionId === data.sessionId; //Delete this connection }); //closing could happen during async action if (constants.CONN_CLOSED !== conn.conn.readyState) { // We put it in an array, because we need to send data to open/save the document connections.push(conn); yield addPresence(ctx, conn, true); // Sending a formal authorization to confirm the connection yield* sendAuthInfo(ctx, conn, bIsRestore, undefined); if (cmd) { yield canvasService.openDocument(ctx, conn, cmd, upsertRes, bIsRestore); } } return; } if (conn.user.idOriginal.length > constants.USER_ID_MAX_LENGTH) { //todo refactor DB and remove restrictions ctx.logger.warn('auth user id too long actual = %s; max = %s', curUserIdOriginal.length, constants.USER_ID_MAX_LENGTH); yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'User id too long'); return; } if (!conn.user.view) { const status = result && result.length > 0 ? result[0]['status'] : null; if (commonDefines.FileStatus.Ok === status) { // Everything is fine, the status does not need to be updated } else if ( commonDefines.FileStatus.SaveVersion === status || (!bIsRestore && commonDefines.FileStatus.UpdateVersion === status && Date.now() - result[0]['status_info'] * 60000 > tenExpUpdateVersionStatus) ) { let newStatus = commonDefines.FileStatus.Ok; if (commonDefines.FileStatus.UpdateVersion === status) { ctx.logger.warn('UpdateVersion expired'); //FileStatus.None to open file again from new url newStatus = commonDefines.FileStatus.None; } // Update the status of the file (the build is in progress, you need to stop it) const updateMask = new taskResult.TaskResultData(); updateMask.tenant = ctx.tenant; updateMask.key = docId; updateMask.status = status; updateMask.statusInfo = result[0]['status_info']; const updateTask = new taskResult.TaskResultData(); updateTask.status = newStatus; updateTask.statusInfo = constants.NO_ERROR; const updateIfRes = yield taskResult.updateIf(ctx, updateTask, updateMask); if (!(updateIfRes.affectedRows > 0)) { // error version //log level is debug because error handled via refreshFile yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Update Version error', constants.UPDATE_VERSION_CODE, true); return; } } else if (commonDefines.FileStatus.UpdateVersion === status) { modifyConnectionEditorToView(ctx, conn); conn.isCloseCoAuthoring = true; if (bIsRestore) { // error version yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Update Version error', constants.UPDATE_VERSION_CODE, true); return; } } else if (commonDefines.FileStatus.None === status && conn.encrypted) { //ok } else if (bIsRestore) { // Other error if (null === status) { yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Other error', constants.NO_CACHE_CODE, true); } else { yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Other error'); } return; } } else if (data.forcedViewMode) { sendDataWarning(ctx, conn, constants.FORCED_VIEW_MODE, 'Forced view mode'); } //Set the unique ID if (bIsRestore) { ctx.logger.info('restored old session: id = %s', data.sessionId); if (!conn.user.view) { // Stop the assembly (suddenly it started) // When reconnecting, we need to check for file assembly try { const puckerIndex = yield* getChangesIndex(ctx, docId); let bIsSuccessRestore = true; if (puckerIndex > 0) { const objChangesDocument = yield* getDocumentChanges(ctx, docId, puckerIndex - 1, puckerIndex); const change = objChangesDocument.arrChanges[objChangesDocument.getLength() - 1]; if (change) { if (change['change']) { if (change['user'] !== curUserId) { bIsSuccessRestore = 0 === ((data['lastOtherSaveTime'] - change['time']) / 1000) >> 0; } } } else { bIsSuccessRestore = false; } } if (bIsSuccessRestore) { // check locks const arrayBlocks = data['block']; const getLockRes = yield getLock(ctx, conn, data, true); if (arrayBlocks && (0 === arrayBlocks.length || getLockRes)) { let wopiLockRes = true; if (wopiParamsFull) { wopiLockRes = yield wopiClient.lock( ctx, 'LOCK', wopiParamsFull.commonInfo.lockId, wopiParamsFull.commonInfo.fileInfo, wopiParamsFull.userAuth ); } if (wopiLockRes) { yield* authRestore(ctx, conn, data.sessionId); } else { yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Restore error. Wopi lock error.', constants.RESTORE_CODE, true); } } else { yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Restore error. Locks not checked.', constants.RESTORE_CODE, true); } } else { yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'Restore error. Document modified.', constants.RESTORE_CODE, true); } } catch (err) { ctx.logger.error('DataBase error: %s', err.stack); yield* sendFileErrorAuth(ctx, conn, data.sessionId, 'DataBase error', constants.RESTORE_CODE, true); } } else { yield* authRestore(ctx, conn, data.sessionId); } } else { conn.sessionId = conn.id; const openedAt = openedAtStr ? sqlBase.DocumentAdditional.prototype.getOpenedAt(openedAtStr) : canvasService.getOpenedAt(resultRow); const endAuthRes = yield* endAuth(ctx, conn, false, documentCallback, openedAt); if (endAuthRes && cmd) { //todo to allow forcesave TemplateSource after convertion(move to better place) if (wopiParamsFull?.commonInfo?.fileInfo?.TemplateSource) { const newChangesLastDate = new Date(); newChangesLastDate.setMilliseconds(0); //remove milliseconds avoid issues with MySQL datetime rounding cmd.setExternalChangeInfo(getExternalChangeInfo(conn.user, newChangesLastDate.getTime(), conn.lang)); } yield canvasService.openDocument(ctx, conn, cmd, upsertRes, bIsRestore); } } } } function* endAuth(ctx, conn, bIsRestore, documentCallback, opt_openedAt) { const tenExpLockDoc = ctx.getCfg('services.CoAuthoring.expire.lockDoc', cfgExpLockDoc); const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles); const res = true; const docId = conn.docId; const tmpUser = conn.user; let hasForgotten; if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } connections.push(conn); let firstParticipantNoView, countNoView = 0; yield addPresence(ctx, conn, true); const participantsMap = yield getParticipantMap(ctx, docId); const participantsTimestamp = Date.now(); for (let i = 0; i < participantsMap.length; ++i) { const elem = participantsMap[i]; if (!elem.view) { ++countNoView; if (!firstParticipantNoView && elem.id !== tmpUser.id) { firstParticipantNoView = elem; } } } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } // Sending to an external callback only for those who edit if (!tmpUser.view) { const userIndex = utils.getIndexFromUserId(tmpUser.id, tmpUser.idOriginal); const userAction = new commonDefines.OutputAction(commonDefines.c_oAscUserAction.In, tmpUser.idOriginal); //make async request to speed up file opening sendStatusDocument(ctx, docId, c_oAscChangeBase.No, userAction, userIndex, documentCallback, conn.baseUrl).catch(err => ctx.logger.error('endAuth sendStatusDocument error: %s', err.stack) ); if (!bIsRestore) { //check forgotten file const forgotten = yield storage.listObjects(ctx, docId, tenForgottenFiles); hasForgotten = forgotten.length > 0; ctx.logger.debug('endAuth hasForgotten %s', hasForgotten); } } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } let lockDocument = null; let waitAuthUserId; if (!bIsRestore && 2 === countNoView && !tmpUser.view && firstParticipantNoView) { // lock a document const lockRes = yield editorData.lockAuth(ctx, docId, firstParticipantNoView.id, 2 * tenExpLockDoc); if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } if (lockRes) { lockDocument = firstParticipantNoView; waitAuthUserId = lockDocument.id; const lockDocumentTimer = lockDocumentsTimerId[docId]; if (lockDocumentTimer) { cleanLockDocumentTimer(docId, lockDocumentTimer); } setLockDocumentTimer(ctx, docId, lockDocument.id); } } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } if (lockDocument && !tmpUser.view) { // waiting for the editor to switch to co-editing mode const sendObject = { type: 'waitAuth', lockDocument }; sendData(ctx, conn, sendObject); //Or 0 if fails } else { if (!bIsRestore && needSendChanges(conn)) { yield* sendAuthChanges(ctx, conn.docId, [conn]); } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } yield* sendAuthInfo(ctx, conn, bIsRestore, participantsMap, hasForgotten, opt_openedAt); } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return false; } yield publish( ctx, { type: commonDefines.c_oPublishType.participantsState, ctx, docId, userId: tmpUser.id, participantsTimestamp, participants: participantsMap, waitAuthUserId }, docId, tmpUser.id ); return res; } /** * Save document changes to error files storage for debugging purposes. * Retrieves changes from database and creates JSON chunks stored as separate files. * * @param {object} ctx - Operation context with configuration and logger * @param {string} docId - Document identifier to retrieve changes for * @param {string} destDir - Destination directory path in storage for error files * @returns {Promise} Resolves when all changes are saved to storage */ async function saveErrorChanges(ctx, docId, destDir) { const tenEditor = getEditorConfig(ctx); const tenMaxRequestChanges = ctx.getCfg('services.CoAuthoring.server.maxRequestChanges', cfgMaxRequestChanges); const tenErrorFiles = ctx.getCfg('FileConverter.converter.errorfiles', cfgErrorFiles); let index = 0; let indexChunk = 1; let changes; const changesPrefix = destDir + '/' + constants.CHANGES_NAME + '/' + constants.CHANGES_NAME + '.json.'; do { changes = await sqlBase.getChangesPromise(ctx, docId, index, index + tenMaxRequestChanges); if (changes.length > 0) { let buffer; if (tenEditor['binaryChanges']) { const buffers = changes.map(elem => elem.change_data); buffers.unshift(Buffer.from(utils.getChangesFileHeader(), 'utf8')); buffer = Buffer.concat(buffers); } else { let changesJSON = indexChunk > 1 ? ',[' : '['; changesJSON += changes[0].change_data; for (let i = 1; i < changes.length; ++i) { changesJSON += ','; changesJSON += changes[i].change_data; } changesJSON += ']\r\n'; buffer = Buffer.from(changesJSON, 'utf8'); } await storage.putObject(ctx, changesPrefix + (indexChunk++).toString().padStart(3, '0'), buffer, buffer.length, tenErrorFiles); } index += tenMaxRequestChanges; } while (changes && tenMaxRequestChanges === changes.length); } function sendAuthChangesByChunks(ctx, changes, connections) { return co(function* () { //websocket payload size is limited by https://github.com/faye/faye-websocket-node#initialization-options (64 MiB) //xhr payload size is limited by nginx param client_max_body_size (current 100MB) //"1.5MB" is choosen to avoid disconnect(after 25s) while downloading/uploading oversized changes with 0.5Mbps connection const tenEditor = getEditorConfig(ctx); let startIndex = 0; let endIndex = 0; while (endIndex < changes.length) { startIndex = endIndex; let curBytes = 0; for (; endIndex < changes.length && curBytes < tenEditor['websocketMaxPayloadSize']; ++endIndex) { curBytes += JSON.stringify(changes[endIndex]).length + 24; //24 - for JSON overhead } //todo simplify 'authChanges' format to reduce message size and JSON overhead const sendObject = { type: 'authChanges', changes: changes.slice(startIndex, endIndex) }; for (let i = 0; i < connections.length; ++i) { const conn = connections[i]; if (needSendChanges(conn)) { if (conn.supportAuthChangesAck) { conn.authChangesAck = true; } sendData(ctx, conn, sendObject); } } //todo use emit callback //wait ack let time = 0; const interval = 100; const limit = 30000; for (let i = 0; i < connections.length; ++i) { const conn = connections[i]; while (constants.CONN_CLOSED !== conn.readyState && needSendChanges(conn) && conn.authChangesAck && time < limit) { yield utils.sleep(interval); time += interval; } delete conn.authChangesAck; } } }); } function* sendAuthChanges(ctx, docId, connections) { const tenMaxRequestChanges = ctx.getCfg('services.CoAuthoring.server.maxRequestChanges', cfgMaxRequestChanges); let index = 0; let changes; do { const objChangesDocument = yield getDocumentChanges(ctx, docId, index, index + tenMaxRequestChanges); changes = objChangesDocument.arrChanges; yield sendAuthChangesByChunks(ctx, changes, connections); connections = connections.filter(conn => { return constants.CONN_CLOSED !== conn.readyState; }); index += tenMaxRequestChanges; } while (connections.length > 0 && changes && tenMaxRequestChanges === changes.length); } function* sendAuthInfo(ctx, conn, bIsRestore, participantsMap, opt_hasForgotten, opt_openedAt) { const tenImageSize = ctx.getCfg('services.CoAuthoring.server.limits_image_size', cfgImageSize); const tenTypesUpload = ctx.getCfg('services.CoAuthoring.utils.limits_image_types_upload', cfgTypesUpload); const docId = conn.docId; let docLock = yield editorData.getLocks(ctx, docId); if (EditorTypes.document !== conn.editorType) { const docLockList = []; for (const lockId in docLock) { docLockList.push(docLock[lockId]); } docLock = docLockList; } let allMessages = yield editorData.getMessages(ctx, docId); allMessages = allMessages.length > 0 ? allMessages : undefined; //todo client side let sessionToken; if (!bIsRestore) { sessionToken = yield fillJwtByConnection(ctx, conn); } const tenEditor = getEditorConfig(ctx); tenEditor['limits_image_size'] = tenImageSize; tenEditor['limits_image_types_upload'] = tenTypesUpload; const sendObject = { type: 'auth', result: 1, sessionId: conn.sessionId, sessionTimeConnect: conn.sessionTimeConnect, participants: participantsMap, messages: allMessages, locks: docLock, indexUser: conn.user.indexUser, hasForgotten: opt_hasForgotten, jwt: sessionToken, g_cAscSpellCheckUrl: tenEditor['spellcheckerUrl'], buildVersion: commonDefines.buildVersion, buildNumber: commonDefines.buildNumber, licenseType: conn.licenseType, settings: tenEditor, openedAt: opt_openedAt }; sendData(ctx, conn, sendObject); //Or 0 if fails } function* onMessage(ctx, conn, data) { if (false === conn.permissions?.chat) { ctx.logger.warn('insert message permissions.chat==false'); return; } const docId = conn.docId; const userId = conn.user.id; const msg = { docid: docId, message: data.message, time: Date.now(), user: userId, useridoriginal: conn.user.idOriginal, username: conn.user.username }; yield editorData.addMessage(ctx, docId, msg); // insert ctx.logger.info('insert message: %j', msg); const messages = [msg]; sendDataMessage(ctx, conn, messages); yield publish(ctx, {type: commonDefines.c_oPublishType.message, ctx, docId, userId, messages}, docId, userId); } function* onCursor(ctx, conn, data) { const docId = conn.docId; const userId = conn.user.id; const msg = {cursor: data.cursor, time: Date.now(), user: userId, useridoriginal: conn.user.idOriginal}; ctx.logger.info('send cursor: %s', msg); const messages = [msg]; yield publish(ctx, {type: commonDefines.c_oPublishType.cursor, ctx, docId, userId, messages}, docId, userId); } // For Word block is now string "guid" // For Excel block is now object { sheetId, type, rangeOrObjectId, guid } // For presentations, this is an object { type, val } or { type, slideId, objId } async function getLock(ctx, conn, data, bIsRestore) { ctx.logger.debug('getLock'); let fCheckLock = null; switch (conn.editorType) { case EditorTypes.document: // Word fCheckLock = _checkLockWord; break; case EditorTypes.spreadsheet: // Excel fCheckLock = _checkLockExcel; break; case EditorTypes.presentation: case EditorTypes.diagram: // PP fCheckLock = _checkLockPresentation; break; default: return false; } const docId = conn.docId, userId = conn.user.id, arrayBlocks = data.block; const locks = arrayBlocks.reduce((map, block) => { //todo use one id map[block.guid || block] = {time: Date.now(), user: userId, block}; return map; }, {}); const addRes = await editorData.addLocksNX(ctx, docId, locks); const documentLocks = addRes.allLocks; const isAllAdded = Object.keys(addRes.lockConflict).length === 0; if (!isAllAdded && !fCheckLock(ctx, docId, documentLocks, locks, arrayBlocks, userId)) { //remove new locks const toRemove = {}; for (const lockId in locks) { if (!addRes.lockConflict[lockId]) { toRemove[lockId] = locks[lockId]; delete documentLocks[lockId]; } } await editorData.removeLocks(ctx, docId, toRemove); if (bIsRestore) { return false; } } sendData(ctx, conn, {type: 'getLock', locks: documentLocks}); await publish(ctx, {type: commonDefines.c_oPublishType.getLock, ctx, docId, userId, documentLocks}, docId, userId); return true; } function sendGetLock(ctx, participants, documentLocks) { _.each(participants, participant => { sendData(ctx, participant, {type: 'getLock', locks: documentLocks}); }); } // For Excel, it is necessary to recalculate locks when adding / deleting rows / columns function* saveChanges(ctx, conn, data) { const tenEditor = getEditorConfig(ctx); const tenPubSubMaxChanges = ctx.getCfg('services.CoAuthoring.pubsub.maxChanges', cfgPubSubMaxChanges); const tenExpSaveLock = ctx.getCfg('services.CoAuthoring.expire.saveLock', cfgExpSaveLock); const docId = conn.docId, userId = conn.user.id; ctx.logger.info('Start saveChanges: reSave: %s', data.reSave); const lockRes = yield editorData.lockSave(ctx, docId, userId, tenExpSaveLock); if (!lockRes) { //should not be here. cfgExpSaveLock - 60sec, sockjs disconnects after 25sec ctx.logger.warn('saveChanges lockSave error'); return; } let puckerIndex = yield* getChangesIndex(ctx, docId); if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return; } let deleteIndex = -1; if (data.startSaveChanges && null != data.deleteIndex) { deleteIndex = data.deleteIndex; if (-1 !== deleteIndex) { const deleteCount = puckerIndex - deleteIndex; if (0 < deleteCount) { puckerIndex -= deleteCount; yield sqlBase.deleteChangesPromise(ctx, docId, deleteIndex); } else if (0 > deleteCount) { ctx.logger.error('Error saveChanges: deleteIndex: %s ; startIndex: %s ; deleteCount: %s', deleteIndex, puckerIndex, deleteCount); } } } if (constants.CONN_CLOSED === conn.conn.readyState) { //closing could happen during async action return; } // Starting index change when adding const startIndex = puckerIndex; const newChanges = tenEditor['binaryChanges'] ? data.changes : JSON.parse(data.changes); const newChangesLastDate = new Date(); newChangesLastDate.setMilliseconds(0); //remove milliseconds avoid issues with MySQL datetime rounding const newChangesLastTime = newChangesLastDate.getTime(); const arrNewDocumentChanges = []; ctx.logger.info('saveChanges: deleteIndex: %s ; startIndex: %s ; length: %s', deleteIndex, startIndex, newChanges.length); if (0 < newChanges.length) { let oElement = null; for (let i = 0; i < newChanges.length; ++i) { oElement = newChanges[i]; const change = tenEditor['binaryChanges'] ? oElement : JSON.stringify(oElement); arrNewDocumentChanges.push({docid: docId, change, time: newChangesLastDate, user: userId, useridoriginal: conn.user.idOriginal}); } puckerIndex += arrNewDocumentChanges.length; yield sqlBase.insertChangesPromise(ctx, arrNewDocumentChanges, docId, startIndex, conn.user); } const changesIndex = -1 === deleteIndex && data.startSaveChanges ? startIndex : -1; if (data.endSaveChanges) { // For Excel, you need to recalculate indexes for locks if (data.isExcel && false !== data.isCoAuthoring && data.excelAdditionalInfo) { const tmpAdditionalInfo = JSON.parse(data.excelAdditionalInfo); // This is what we got recalcIndexColumns and recalcIndexRows const oRecalcIndexColumns = _addRecalcIndex(tmpAdditionalInfo['indexCols']); const oRecalcIndexRows = _addRecalcIndex(tmpAdditionalInfo['indexRows']); // Now we need to recalculate indexes for lock elements if (null !== oRecalcIndexColumns || null !== oRecalcIndexRows) { const docLock = yield editorData.getLocks(ctx, docId); const docLockMod = _recalcLockArray(userId, docLock, oRecalcIndexColumns, oRecalcIndexRows); if (Object.keys(docLockMod).length > 0) { yield editorData.addLocks(ctx, docId, docLockMod); } } } let userLocks = []; if (data.releaseLocks) { //Release locks userLocks = yield removeUserLocks(ctx, docId, userId); } // For this user, we remove Lock from the document if the unlock flag has arrived const checkEndAuthLockRes = yield* checkEndAuthLock(ctx, data.unlock, false, docId, userId); if (!checkEndAuthLockRes) { const arrLocks = _.map(userLocks, e => { return { block: e.block, user: e.user, time: Date.now(), changes: null }; }); let changesToSend = arrNewDocumentChanges; if (changesToSend.length > tenPubSubMaxChanges) { changesToSend = null; } else { changesToSend.forEach(value => { value.time = value.time.getTime(); }); } yield publish( ctx, { type: commonDefines.c_oPublishType.changes, ctx, docId, userId, changes: changesToSend, startIndex, changesIndex: puckerIndex, syncChangesIndex: puckerIndex, locks: arrLocks, excelAdditionalInfo: data.excelAdditionalInfo, endSaveChanges: data.endSaveChanges }, docId, userId ); } // Automatically remove the lock ourselves and send the index to save yield* unSaveLock(ctx, conn, changesIndex, newChangesLastTime, puckerIndex); //last save const changeInfo = getExternalChangeInfo(conn.user, newChangesLastTime, conn.lang); yield resetForceSaveAfterChanges(ctx, docId, newChangesLastTime, puckerIndex, utils.getBaseUrlByConnection(ctx, conn), changeInfo); } else { let changesToSend = arrNewDocumentChanges; if (changesToSend.length > tenPubSubMaxChanges) { changesToSend = null; } else { changesToSend.forEach(value => { value.time = value.time.getTime(); }); } const isPublished = yield publish( ctx, { type: commonDefines.c_oPublishType.changes, ctx, docId, userId, changes: changesToSend, startIndex, changesIndex: puckerIndex, syncChangesIndex: puckerIndex, locks: [], excelAdditionalInfo: undefined, endSaveChanges: data.endSaveChanges }, docId, userId ); sendData(ctx, conn, {type: 'savePartChanges', changesIndex, syncChangesIndex: puckerIndex}); if (!isPublished) { //stub for lockDocumentsTimerId yield publish(ctx, {type: commonDefines.c_oPublishType.changesNotify, ctx, docId}); } } } // Can we save? function* isSaveLock(ctx, conn, data) { const tenExpSaveLock = ctx.getCfg('services.CoAuthoring.expire.saveLock', cfgExpSaveLock); if (!conn.user) { return; } let lockRes = true; //check changesIndex for compatibility or 0 in case of first save if (data.syncChangesIndex) { const forceSave = yield editorData.getForceSave(ctx, conn.docId); if (forceSave && forceSave.index !== data.syncChangesIndex) { if (!conn.unsyncTime) { conn.unsyncTime = new Date(); } if (Date.now() - conn.unsyncTime.getTime() < tenExpSaveLock * 1000) { lockRes = false; ctx.logger.debug( 'isSaveLock editor unsynced since %j serverIndex:%s clientIndex:%s ', conn.unsyncTime, forceSave.index, data.syncChangesIndex ); sendData(ctx, conn, {type: 'saveLock', saveLock: !lockRes}); return; } else { ctx.logger.warn( 'isSaveLock editor unsynced since %j serverIndex:%s clientIndex:%s ', conn.unsyncTime, forceSave.index, data.syncChangesIndex ); } } } conn.unsyncTime = null; lockRes = yield editorData.lockSave(ctx, conn.docId, conn.user.id, tenExpSaveLock); ctx.logger.debug('isSaveLock lockRes: %s', lockRes); // We send only to the one who asked (you can not send to everyone) sendData(ctx, conn, {type: 'saveLock', saveLock: !lockRes}); } // Removing lock from save function* unSaveLock(ctx, conn, index, time, syncChangesIndex) { const unlockRes = yield editorData.unlockSave(ctx, conn.docId, conn.user.id); if (commonDefines.c_oAscUnlockRes.Locked !== unlockRes) { sendData(ctx, conn, {type: 'unSaveLock', index, time, syncChangesIndex}); } else { ctx.logger.warn('unSaveLock failure'); } } // Returning all messages for a document function* getMessages(ctx, conn) { let allMessages = yield editorData.getMessages(ctx, conn.docId); allMessages = allMessages.length > 0 ? allMessages : undefined; //todo client side sendDataMessage(ctx, conn, allMessages); } function _checkLockWord(_ctx, _docId, _documentLocks, _newLocks, _arrayBlocks, _userId) { return true; } function _checkLockExcel(ctx, docId, documentLocks, newLocks, arrayBlocks, userId) { // Data is array now let documentLock; let isLock = false; let isExistInArray = false; let i, blockRange; const lengthArray = arrayBlocks ? arrayBlocks.length : 0; for (i = 0; i < lengthArray && false === isLock; ++i) { blockRange = arrayBlocks[i]; for (const keyLockInArray in documentLocks) { if (newLocks[keyLockInArray]) { //skip just added continue; } documentLock = documentLocks[keyLockInArray]; // Checking if an object is in an array (the current user sent a lock again) if ( documentLock.user === userId && blockRange.sheetId === documentLock.block.sheetId && blockRange.type === c_oAscLockTypeElem.Object && documentLock.block.type === c_oAscLockTypeElem.Object && documentLock.block.rangeOrObjectId === blockRange.rangeOrObjectId ) { isExistInArray = true; break; } if (c_oAscLockTypeElem.Sheet === blockRange.type && c_oAscLockTypeElem.Sheet === documentLock.block.type) { // If the current user sent a lock of the current sheet, then we do not enter it into the array, and if a new one, then we enter it if (documentLock.user === userId) { if (blockRange.sheetId === documentLock.block.sheetId) { isExistInArray = true; break; } else { // new sheet continue; } } else { // If someone has locked a sheet, then no one else can lock sheets (otherwise you can delete all sheets) isLock = true; break; } } if (documentLock.user === userId || !documentLock.block || blockRange.sheetId !== documentLock.block.sheetId) { continue; } isLock = compareExcelBlock(blockRange, documentLock.block); if (true === isLock) { break; } } } if (0 === lengthArray) { isLock = true; } return !isLock && !isExistInArray; } function _checkLockPresentation(ctx, docId, documentLocks, newLocks, arrayBlocks, userId) { // Data is array now let isLock = false; let i, blockRange; const lengthArray = arrayBlocks ? arrayBlocks.length : 0; for (i = 0; i < lengthArray && false === isLock; ++i) { blockRange = arrayBlocks[i]; for (const keyLockInArray in documentLocks) { if (newLocks[keyLockInArray]) { //skip just added continue; } const documentLock = documentLocks[keyLockInArray]; if (documentLock.user === userId || !documentLock.block) { continue; } isLock = comparePresentationBlock(blockRange, documentLock.block); if (true === isLock) { break; } } } if (0 === lengthArray) { isLock = true; } return !isLock; } function _checkLicense(ctx, conn) { return co(function* () { try { ctx.logger.info('_checkLicense start'); const tenEditSingleton = ctx.getCfg('services.CoAuthoring.server.edit_singleton', cfgEditSingleton); const tenOpenProtectedFile = ctx.getCfg('services.CoAuthoring.server.openProtectedFile', cfgOpenProtectedFile); const tenIsAnonymousSupport = ctx.getCfg('services.CoAuthoring.server.isAnonymousSupport', cfgIsAnonymousSupport); let rights = constants.RIGHTS.Edit; if (tenEditSingleton) { // ToDo docId from url ? const handshake = conn.handshake; const docIdParsed = constants.DOC_ID_SOCKET_PATTERN.exec(handshake.url); if (docIdParsed && 1 < docIdParsed.length) { const participantsMap = yield getParticipantMap(ctx, docIdParsed[1]); for (let i = 0; i < participantsMap.length; ++i) { const elem = participantsMap[i]; if (!elem.view) { rights = constants.RIGHTS.View; break; } } } } const [licenseInfo] = yield tenantManager.getTenantLicense(ctx); const pluginSettings = yield aiProxyHandler.getPluginSettingsForInterface(ctx); sendData(ctx, conn, { type: 'license', license: { type: licenseInfo.type, light: false, //todo remove in sdk mode: licenseInfo.mode, rights, buildVersion: commonDefines.buildVersion, buildNumber: commonDefines.buildNumber, protectionSupport: tenOpenProtectedFile, //todo find a better place isAnonymousSupport: tenIsAnonymousSupport, //todo find a better place liveViewerSupport: utils.isLiveViewerSupport(licenseInfo), branding: licenseInfo.branding, customization: licenseInfo.customization, advancedApi: licenseInfo.advancedApi }, aiPluginSettings: pluginSettings }); ctx.logger.info('_checkLicense end'); } catch (err) { ctx.logger.error('_checkLicense error: %s', err.stack); } }); } function* _checkLicenseAuth(ctx, licenseInfo, userId, isLiveViewer) { const tenWarningLimitPercents = ctx.getCfg('license.warning_limit_percents', cfgWarningLimitPercents) / 100; const tenNotificationRuleLicenseLimitEdit = ctx.getCfg(`notification.rules.licenseLimitEdit.template`, cfgNotificationRuleLicenseLimitEdit); const tenNotificationRuleLicenseLimitLiveViewer = ctx.getCfg( `notification.rules.licenseLimitLiveViewer.template`, cfgNotificationRuleLicenseLimitLiveViewer ); const c_LR = constants.LICENSE_RESULT; let licenseType = licenseInfo.type; if (c_LR.Success === licenseType || c_LR.SuccessLimit === licenseType) { let notificationLimit, notificationLimitTitle; let notificationTemplate = tenNotificationRuleLicenseLimitEdit; let notificationType = notificationTypes.LICENSE_LIMIT_EDIT; let notificationPercent = 100; if (licenseInfo.usersCount) { const nowUTC = getLicenseNowUtc(); notificationLimitTitle = 'user'; notificationLimit = 'users'; if (isLiveViewer) { notificationTemplate = tenNotificationRuleLicenseLimitLiveViewer; notificationType = notificationTypes.LICENSE_LIMIT_LIVE_VIEWER; const arrUsers = yield editorStat.getPresenceUniqueViewUser(ctx, nowUTC); if ( arrUsers.length >= licenseInfo.usersViewCount && -1 === arrUsers.findIndex(element => { return element.userid === userId; }) ) { licenseType = licenseInfo.hasLicense ? c_LR.UsersViewCount : c_LR.UsersViewCountOS; } else if (licenseInfo.usersViewCount * tenWarningLimitPercents <= arrUsers.length) { notificationPercent = tenWarningLimitPercents * 100; } } else { const arrUsers = yield editorStat.getPresenceUniqueUser(ctx, nowUTC); if ( arrUsers.length >= licenseInfo.usersCount && -1 === arrUsers.findIndex(element => { return element.userid === userId; }) ) { licenseType = licenseInfo.hasLicense ? c_LR.UsersCount : c_LR.UsersCountOS; } else if (licenseInfo.usersCount * tenWarningLimitPercents <= arrUsers.length) { notificationPercent = tenWarningLimitPercents * 100; } } } else { notificationLimitTitle = 'connection'; notificationLimit = 'connections'; if (isLiveViewer) { notificationTemplate = tenNotificationRuleLicenseLimitLiveViewer; notificationType = notificationTypes.LICENSE_LIMIT_LIVE_VIEWER; const connectionsLiveCount = licenseInfo.connectionsView; const liveViewerConnectionsCount = yield editorStat.getLiveViewerConnectionsCount(ctx, connections); if (liveViewerConnectionsCount >= connectionsLiveCount) { licenseType = licenseInfo.hasLicense ? c_LR.ConnectionsLive : c_LR.ConnectionsLiveOS; } else if (connectionsLiveCount * tenWarningLimitPercents <= liveViewerConnectionsCount) { notificationPercent = tenWarningLimitPercents * 100; } } else { const connectionsCount = licenseInfo.connections; const editConnectionsCount = yield editorStat.getEditorConnectionsCount(ctx, connections); if (editConnectionsCount >= connectionsCount) { licenseType = licenseInfo.hasLicense ? c_LR.Connections : c_LR.ConnectionsOS; } else if (connectionsCount * tenWarningLimitPercents <= editConnectionsCount) { notificationPercent = tenWarningLimitPercents * 100; } } } if ((c_LR.Success !== licenseType && c_LR.SuccessLimit !== licenseType) || 100 !== notificationPercent) { const applicationName = (process.env.APPLICATION_NAME || '').toUpperCase(); const title = util.format(notificationTemplate.title, applicationName, notificationLimitTitle); const message = util.format(notificationTemplate.body, notificationPercent, notificationLimit); if (100 !== notificationPercent) { ctx.logger.warn(message); } else { ctx.logger.error(message); } //todo with yield service could throw error void notificationService.notify(ctx, notificationType, title, message, notificationType + notificationPercent); } } return licenseType; } //publish subscribe message brocker pubsubOnMessage = function (msg) { return co(function* () { const ctx = new operationContext.Context(); try { const data = JSON.parse(msg); ctx.initFromPubSub(data); yield ctx.initTenantCache(); ctx.logger.debug('pubsub message start:%s', msg); let participants; let participant; let objChangesDocument; let i; let lockDocumentTimer, cmd; switch (data.type) { case commonDefines.c_oPublishType.drop: dropUserFromDocument(ctx, data.docId, data.users, data.description); break; case commonDefines.c_oPublishType.closeConnection: closeUsersConnection(ctx, data.docId, data.usersMap, data.isOriginalId, data.code, data.description); break; case commonDefines.c_oPublishType.releaseLock: participants = getParticipants(data.docId, true, data.userId, true); _.each(participants, participant => { sendReleaseLock(ctx, participant, data.locks); }); break; case commonDefines.c_oPublishType.participantsState: participants = getParticipants(data.docId, true, data.userId); sendParticipantsState(ctx, participants, data); break; case commonDefines.c_oPublishType.message: participants = getParticipants(data.docId, true, data.userId); _.each(participants, participant => { sendDataMessage(ctx, participant, data.messages); }); break; case commonDefines.c_oPublishType.getLock: participants = getParticipants(data.docId, true, data.userId, true); sendGetLock(ctx, participants, data.documentLocks); break; case commonDefines.c_oPublishType.changes: lockDocumentTimer = lockDocumentsTimerId[data.docId]; if (lockDocumentTimer) { ctx.logger.debug('lockDocumentsTimerId update c_oPublishType.changes'); cleanLockDocumentTimer(data.docId, lockDocumentTimer); setLockDocumentTimer(ctx, data.docId, lockDocumentTimer.userId); } participants = getParticipants(data.docId, true, data.userId); if (participants.length > 0) { let changes = data.changes; if (null == changes) { objChangesDocument = yield* getDocumentChanges(ctx, data.docId, data.startIndex, data.changesIndex); changes = objChangesDocument.arrChanges; } _.each(participants, participant => { if (!needSendChanges(participant)) { return; } sendData(ctx, participant, { type: 'saveChanges', changes, changesIndex: data.changesIndex, syncChangesIndex: data.syncChangesIndex, endSaveChanges: data.endSaveChanges, locks: data.locks, excelAdditionalInfo: data.excelAdditionalInfo }); }); } break; case commonDefines.c_oPublishType.changesNotify: lockDocumentTimer = lockDocumentsTimerId[data.docId]; if (lockDocumentTimer) { ctx.logger.debug('lockDocumentsTimerId update c_oPublishType.changesNotify'); cleanLockDocumentTimer(data.docId, lockDocumentTimer); setLockDocumentTimer(ctx, data.docId, lockDocumentTimer.userId); } break; case commonDefines.c_oPublishType.auth: lockDocumentTimer = lockDocumentsTimerId[data.docId]; if (lockDocumentTimer) { ctx.logger.debug('lockDocumentsTimerId clear'); cleanLockDocumentTimer(data.docId, lockDocumentTimer); } participants = getParticipants(data.docId, true, data.userId, true); if (participants.length > 0) { yield* sendAuthChanges(ctx, data.docId, participants); for (i = 0; i < participants.length; ++i) { participant = participants[i]; yield* sendAuthInfo(ctx, participant, false, data.participantsMap); } } break; case commonDefines.c_oPublishType.receiveTask: { cmd = new commonDefines.InputCommand(data.cmd, true); const output = new canvasService.OutputDataWrap(); output.fromObject(data.output); const outputData = output.getData(); const docId = cmd.getDocId(); if (cmd.getUserConnectionId()) { participants = getParticipantUser(docId, cmd.getUserConnectionId()); } else { participants = getParticipants(docId); } for (i = 0; i < participants.length; ++i) { participant = participants[i]; if (data.needUrlKey) { if (0 === data.needUrlMethod) { outputData.setData(yield storage.getSignedUrls(ctx, participant.baseUrl, data.needUrlKey, data.needUrlType, data.creationDate)); } else if (1 === data.needUrlMethod) { outputData.setData( yield storage.getSignedUrl(ctx, participant.baseUrl, data.needUrlKey, data.needUrlType, undefined, data.creationDate) ); } else { let url; if (cmd.getInline()) { url = yield canvasService.getPrintFileUrl(ctx, data.needUrlKey, participant.baseUrl, cmd.getTitle()); outputData.setExtName('.pdf'); } else { url = yield storage.getSignedUrl(ctx, participant.baseUrl, data.needUrlKey, data.needUrlType, cmd.getTitle(), data.creationDate); outputData.setExtName(pathModule.extname(data.needUrlKey)); } outputData.setData(url); } if (undefined !== data.openedAt) { outputData.setOpenedAt(data.openedAt); } yield modifyConnectionForPassword(ctx, participant, data.needUrlIsCorrectPassword); } sendData(ctx, participant, output); } break; } case commonDefines.c_oPublishType.warning: participants = getParticipants(data.docId); _.each(participants, participant => { sendDataWarning(ctx, participant, undefined, data.description); }); break; case commonDefines.c_oPublishType.cursor: participants = getParticipants(data.docId, true, data.userId); _.each(participants, participant => { sendDataCursor(ctx, participant, data.messages); }); break; case commonDefines.c_oPublishType.shutdown: //flag prevent new socket connections and receive data from exist connections shutdownFlag = data.status; wopiClient.setIsShutdown(shutdownFlag); ctx.logger.warn('start shutdown:%s', shutdownFlag); if (shutdownFlag) { ctx.logger.warn('active connections: %d', connections.length); //do not stop the server, because sockets and all requests will be unavailable //bad because you may need to convert the output file and the fact that requests for the CommandService will not be processed //server.close(); //in the cycle we will remove elements so copy array const connectionsTmp = connections.slice(); //destroy all open connections for (i = 0; i < connectionsTmp.length; ++i) { sendDataDisconnectReason(ctx, connectionsTmp[i], constants.SHUTDOWN_CODE, constants.SHUTDOWN_REASON); connectionsTmp[i].disconnect(true); } } ctx.logger.warn('end shutdown'); break; case commonDefines.c_oPublishType.meta: participants = getParticipants(data.docId); _.each(participants, participant => { sendDataMeta(ctx, participant, data.meta); }); break; case commonDefines.c_oPublishType.forceSave: participants = getParticipants(data.docId, true, data.userId, true); _.each(participants, participant => { sendData(ctx, participant, {type: 'forceSave', messages: data.data}); }); break; case commonDefines.c_oPublishType.changeConnecitonInfo: { let hasChanges = false; cmd = new commonDefines.InputCommand(data.cmd, true); participants = getParticipants(data.docId); for (i = 0; i < participants.length; ++i) { participant = participants[i]; if (!participant.denyChangeName && participant.user.idOriginal === data.useridoriginal) { hasChanges = true; ctx.logger.debug('changeConnectionInfo: userId = %s', data.useridoriginal); participant.user.username = cmd.getUserName(); yield addPresence(ctx, participant, false); const sessionToken = yield fillJwtByConnection(ctx, participant); sendDataRefreshToken(ctx, participant, sessionToken); } } if (hasChanges) { const participants = yield getParticipantMap(ctx, data.docId); const participantsTimestamp = Date.now(); yield publish(ctx, { type: commonDefines.c_oPublishType.participantsState, ctx, docId: data.docId, userId: null, participantsTimestamp, participants }); } break; } case commonDefines.c_oPublishType.rpc: participants = getParticipantUser(data.docId, data.userId); _.each(participants, participant => { sendDataRpc(ctx, participant, data.responseKey, data.data); }); break; case commonDefines.c_oPublishType.updateVersion: // To finalize form or refresh file in live view participants = getParticipants(data.docId); _.each(participants, participant => { sendData(ctx, participant, {type: 'updateVersion', success: data.success}); }); break; default: ctx.logger.debug('pubsub unknown message type:%s', msg); } } catch (err) { ctx.logger.error('pubsub message error: %s', err.stack); } }); }; function* collectStats(ctx, countEdit, countLiveView, countView) { const now = Date.now(); yield editorStat.setEditorConnections(ctx, countEdit, countLiveView, countView, now, PRECISION); } function expireDoc() { return co(function* () { const ctx = new operationContext.Context(); try { const tenants = {}; let countEditByShard = 0; let countLiveViewByShard = 0; let countViewByShard = 0; ctx.logger.debug('expireDoc connections.length = %d', connections.length); const nowMs = new Date().getTime(); for (let i = 0; i < connections.length; ++i) { const conn = connections[i]; ctx.initFromConnection(conn); //todo group by tenant yield ctx.initTenantCache(); let tenExpSessionIdle = ms(ctx.getCfg('services.CoAuthoring.expire.sessionidle', cfgExpSessionIdle)) || 0; const tenExpSessionAbsolute = ms(ctx.getCfg('services.CoAuthoring.expire.sessionabsolute', cfgExpSessionAbsolute)); const tenExpSessionCloseCommand = ms(ctx.getCfg('services.CoAuthoring.expire.sessionclosecommand', cfgExpSessionCloseCommand)); if (preStopFlag && (tenExpSessionIdle > 5 * 60 * 1000 || tenExpSessionIdle <= 0)) { tenExpSessionIdle = 5 * 60 * 1000; //5 minutes } const maxMs = nowMs + Math.max(tenExpSessionCloseCommand, expDocumentsStep); let tenant = tenants[ctx.tenant]; if (!tenant) { tenant = tenants[ctx.tenant] = {countEditByShard: 0, countLiveViewByShard: 0, countViewByShard: 0}; } //wopi access_token_ttl; if (tenExpSessionAbsolute > 0 || conn.access_token_ttl) { if ( ((tenExpSessionAbsolute > 0 && maxMs - conn.sessionTimeConnect > tenExpSessionAbsolute) || (conn.access_token_ttl && maxMs > conn.access_token_ttl)) && !conn.sessionIsSendWarning ) { conn.sessionIsSendWarning = true; sendDataSession(ctx, conn, { code: constants.SESSION_ABSOLUTE_CODE, reason: constants.SESSION_ABSOLUTE_REASON }); } else if (nowMs - conn.sessionTimeConnect > tenExpSessionAbsolute) { ctx.logger.debug('expireDoc close absolute session'); sendDataDisconnectReason(ctx, conn, constants.SESSION_ABSOLUTE_CODE, constants.SESSION_ABSOLUTE_REASON); conn.disconnect(true); continue; } } if (tenExpSessionIdle > 0 && !(conn.user?.view || conn.isCloseCoAuthoring)) { if (maxMs - conn.sessionTimeLastAction > tenExpSessionIdle && !conn.sessionIsSendWarning) { conn.sessionIsSendWarning = true; sendDataSession(ctx, conn, { code: constants.SESSION_IDLE_CODE, reason: constants.SESSION_IDLE_REASON, interval: tenExpSessionIdle }); } else if (nowMs - conn.sessionTimeLastAction > tenExpSessionIdle) { ctx.logger.debug('expireDoc close idle session'); sendDataDisconnectReason(ctx, conn, constants.SESSION_IDLE_CODE, constants.SESSION_IDLE_REASON); conn.disconnect(true); continue; } } if (constants.CONN_CLOSED === conn.conn.readyState) { ctx.logger.error('expireDoc connection closed'); } yield updatePresence(ctx, conn); if (utils.isLiveViewer(conn)) { countLiveViewByShard++; tenant.countLiveViewByShard++; } else if (conn.isCloseCoAuthoring || (conn.user && conn.user.view)) { countViewByShard++; tenant.countViewByShard++; } else { countEditByShard++; tenant.countEditByShard++; } } for (const tenantId in tenants) { if (Object.hasOwn(tenants, tenantId)) { ctx.setTenant(tenantId); const tenant = tenants[tenantId]; yield* collectStats(ctx, tenant.countEditByShard, tenant.countLiveViewByShard, tenant.countViewByShard); yield editorStat.setEditorConnectionsCountByShard(ctx, SHARD_ID, tenant.countEditByShard); yield editorStat.setLiveViewerConnectionsCountByShard(ctx, SHARD_ID, tenant.countLiveViewByShard); yield editorStat.setViewerConnectionsCountByShard(ctx, SHARD_ID, tenant.countViewByShard); if (clientStatsD) { //todo with multitenant const countEdit = yield editorStat.getEditorConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.edit', countEdit); const countLiveView = yield editorStat.getLiveViewerConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.liveview', countLiveView); const countView = yield editorStat.getViewerConnectionsCount(ctx, connections); clientStatsD.gauge('expireDoc.connections.view', countView); } } } if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) { //aggregated tenant stats const aggregationCtx = new operationContext.Context(); aggregationCtx.init(tenantManager.getDefautTenant(), ctx.docId, ctx.userId); //yield ctx.initTenantCache();//no need yield* collectStats(aggregationCtx, countEditByShard, countLiveViewByShard, countViewByShard); yield editorStat.setEditorConnectionsCountByShard(aggregationCtx, SHARD_ID, countEditByShard); yield editorStat.setLiveViewerConnectionsCountByShard(aggregationCtx, SHARD_ID, countLiveViewByShard); yield editorStat.setViewerConnectionsCountByShard(aggregationCtx, SHARD_ID, countViewByShard); } ctx.initDefault(); } catch (err) { ctx.logger.error('expireDoc error: %s', err.stack); } finally { setTimeout(expireDoc, expDocumentsStep); } }); } setTimeout(expireDoc, expDocumentsStep); function refreshWopiLock() { return co(function* () { const ctx = new operationContext.Context(); try { ctx.logger.info('refreshWopiLock start'); const docIds = new Map(); for (let i = 0; i < connections.length; ++i) { const conn = connections[i]; ctx.initFromConnection(conn); //todo group by tenant yield ctx.initTenantCache(); const docId = conn.docId; if ((conn.user && conn.user.view) || docIds.has(docId)) { continue; } docIds.set(docId, 1); if (undefined === conn.access_token_ttl) { continue; } const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0 && selectRes[0] && selectRes[0].callback) { const callback = selectRes[0].callback; const callbackUrl = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, callback); const wopiParams = wopiClient.parseWopiCallback(ctx, callbackUrl, callback); if (wopiParams && wopiParams.commonInfo) { yield wopiClient.lock(ctx, 'REFRESH_LOCK', wopiParams.commonInfo.lockId, wopiParams.commonInfo.fileInfo, wopiParams.userAuth); } } } ctx.initDefault(); ctx.logger.info('refreshWopiLock end'); } catch (err) { ctx.logger.error('refreshWopiLock error:%s', err.stack); } finally { setTimeout(refreshWopiLock, cfgRefreshLockInterval); } }); } setTimeout(refreshWopiLock, cfgRefreshLockInterval); pubsub = new pubsubService(); pubsub.on('message', pubsubOnMessage); pubsub.init(err => { if (null != err) { operationContext.global.logger.error('createPubSub error: %s', err.stack); } queue = new queueService(); queue.on('dead', handleDeadLetter); queue.on('response', canvasService.receiveTask); queue.init(true, true, false, true, true, true, err => { if (null != err) { operationContext.global.logger.error('createTaskQueue error: %s', err.stack); } gc.startGC(); //check data base compatibility const tables = [ [cfgTableResult, constants.TABLE_RESULT_SCHEMA], [cfgTableChanges, constants.TABLE_CHANGES_SCHEMA] ]; const requestPromises = tables.map(table => isSchemaCompatible(table)); Promise.all(requestPromises).then( checkResult => { if (checkResult.includes(false)) { return; } editorData .connect() .then(() => editorStat.connect()) .then(() => callbackFunction()) .catch(err => { operationContext.global.logger.error('editorData error: %s', err.stack); }); }, error => operationContext.global.logger.error('getTableColumns error: %s', error.stack) ); }); }); }; exports.setLicenseInfo = async function (globalCtx, data, original) { tenantManager.setDefLicense(data, original); await utilsDocService.notifyLicenseExpiration(globalCtx, data.endDate); const tenantsList = await tenantManager.getAllTenants(globalCtx); for (const tenant of tenantsList) { const ctx = new operationContext.Context(); ctx.setTenant(tenant); await ctx.initTenantCache(); const [licenseInfo] = await tenantManager.getTenantLicense(ctx); await utilsDocService.notifyLicenseExpiration(ctx, licenseInfo.endDate); } }; exports.healthCheck = function (req, res) { return co(function* () { let output = false; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); ctx.logger.info('healthCheck start'); //database yield sqlBase.healthCheck(ctx); ctx.logger.debug('healthCheck database'); //check redis connection const healthData = yield editorData.healthCheck(); if (healthData) { ctx.logger.debug('healthCheck editorData'); } else { throw new Error('editorData'); } const healthStat = yield editorStat.healthCheck(); if (healthStat) { ctx.logger.debug('healthCheck editorStat'); } else { throw new Error('editorStat'); } const healthPubsub = yield pubsub.healthCheck(); if (healthPubsub) { ctx.logger.debug('healthCheck pubsub'); } else { throw new Error('pubsub'); } const healthQueue = yield queue.healthCheck(); if (healthQueue) { ctx.logger.debug('healthCheck queue'); } else { throw new Error('queue'); } //storage yield storage.healthCheck(ctx); ctx.logger.debug('healthCheck storage'); if (storage.isDifferentPersistentStorage()) { yield storage.healthCheck(ctx, cfgForgottenFiles); ctx.logger.debug('healthCheck storage persistent'); } output = true; ctx.logger.info('healthCheck end'); } catch (err) { ctx.logger.error('healthCheck error %s', err.stack); } finally { res.setHeader('Content-Type', 'text/plain'); res.send(output.toString()); } }); }; function validateInputParams(ctx, authRes, command) { const commandsWithoutKey = ['version', 'license', 'getForgottenList']; const isValidWithoutKey = commandsWithoutKey.includes(command.c); const isDocIdString = typeof command.key === 'string'; ctx.setDocId(command.key); if (authRes.code === constants.VKEY_KEY_EXPIRE) { return commonDefines.c_oAscServerCommandErrors.TokenExpire; } else if (authRes.code !== constants.NO_ERROR) { return commonDefines.c_oAscServerCommandErrors.Token; } if (isValidWithoutKey || isDocIdString) { return commonDefines.c_oAscServerCommandErrors.NoError; } else { return commonDefines.c_oAscServerCommandErrors.DocumentIdError; } } function* getFilesKeys(ctx, opt_specialDir) { const directoryList = yield storage.listObjects(ctx, '', opt_specialDir); const keys = directoryList.map(directory => directory.split('/')[0]); const filteredKeys = []; let previousKey = null; // Key is a folder name. This folder could consist of several files, which leads to N same strings in "keys" array in a row. for (const key of keys) { if (previousKey !== key) { previousKey = key; filteredKeys.push(key); } } return filteredKeys; } function* findForgottenFile(ctx, docId) { const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles); const tenForgottenFilesName = ctx.getCfg('services.CoAuthoring.server.forgottenfilesname', cfgForgottenFilesName); const forgottenList = yield storage.listObjects(ctx, docId, tenForgottenFiles); return forgottenList.find(forgotten => tenForgottenFilesName === pathModule.basename(forgotten, pathModule.extname(forgotten))); } function* commandLicense(ctx) { const nowUTC = getLicenseNowUtc(); const users = yield editorStat.getPresenceUniqueUser(ctx, nowUTC); const users_view = yield editorStat.getPresenceUniqueViewUser(ctx, nowUTC); const [licenseInfo, licenseOriginal] = yield tenantManager.getTenantLicense(ctx); return { license: licenseOriginal || utils.convertLicenseInfoToFileParams(licenseInfo), server: utils.convertLicenseInfoToServerParams(licenseInfo), quota: {users, users_view} }; } async function proxyCommand(ctx, req, params) { const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); const tenTokenEnableRequestInbox = ctx.getCfg('services.CoAuthoring.token.enable.request.inbox', cfgTokenEnableRequestInbox); //todo gen shardkey as in sdkjs const shardkey = params.key; const baseUrl = utils.getBaseUrlByRequest(ctx, req); let url = `${baseUrl}/command?&${constants.SHARD_KEY_API_NAME}=${encodeURIComponent(shardkey)}`; for (const name in req.query) { url += `&${name}=${encodeURIComponent(req.query[name])}`; } ctx.logger.info('commandFromServer proxy request with "key" to correctly process commands in sharded cluster to url:%s', url); //isInJwtToken is true because 'command' is always internal return await utils.postRequestPromise( ctx, url, req.body, null, req.body.length, tenCallbackRequestTimeout, undefined, tenTokenEnableRequestInbox, req.headers ); } /** * Server commands handler. * @param ctx Local context. * @param params Request parameters. * @param req Request object. * @param output{{ key: string, error: number, version: undefined | string, users: [string]}}} Mutable. Response body. * @returns undefined. */ function* commandHandle(ctx, params, req, output) { const tenForgottenFiles = ctx.getCfg('services.CoAuthoring.server.forgottenfiles', cfgForgottenFiles); const docId = params.key; const forgottenData = {}; switch (params.c) { case 'info': { //If no files in the database means they have not been edited. const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0) { const sendData = yield* bindEvents(ctx, docId, params.callback, utils.getBaseUrlByRequest(ctx, req), undefined, params.userdata); if (sendData) { output.users = sendData.users || []; } else { output.error = commonDefines.c_oAscServerCommandErrors.ParseError; } } else { output.error = commonDefines.c_oAscServerCommandErrors.DocumentIdError; } break; } case 'drop': { if (params.users) { const users = typeof params.users === 'string' ? JSON.parse(params.users) : params.users; yield dropUsersFromDocument(ctx, docId, users); } else { yield dropUsersFromDocument(ctx, docId); } break; } case 'saved': { // Result from document manager about file save processing status after assembly if ('1' !== params.status) { //"saved" request is done synchronously so populate a variable to check it after sendServerRequest yield editorData.setSaved(ctx, docId, params.status); ctx.logger.warn('saved corrupted id = %s status = %s conv = %s', docId, params.status, params.conv); } else { ctx.logger.info('saved id = %s status = %s conv = %s', docId, params.status, params.conv); } break; } case 'forcesave': { const forceSaveRes = yield startForceSave( ctx, docId, commonDefines.c_oAscForceSaveTypes.Command, params.userdata, undefined, undefined, undefined, undefined, undefined, undefined, utils.getBaseUrlByRequest(ctx, req) ); output.error = forceSaveRes.code; break; } case 'meta': { if (params.meta) { yield publish(ctx, {type: commonDefines.c_oPublishType.meta, ctx, docId, meta: params.meta}); } else { output.error = commonDefines.c_oAscServerCommandErrors.UnknownCommand; } break; } case 'getForgotten': { // Checking for files existence. const forgottenFileFullPath = yield* findForgottenFile(ctx, docId); if (!forgottenFileFullPath) { output.error = commonDefines.c_oAscServerCommandErrors.DocumentIdError; break; } const forgottenFile = pathModule.basename(forgottenFileFullPath); // Creating URLs from files. const baseUrl = utils.getBaseUrlByRequest(ctx, req); forgottenData.url = yield storage.getSignedUrl( ctx, baseUrl, forgottenFileFullPath, commonDefines.c_oAscUrlTypes.Temporary, forgottenFile, undefined, tenForgottenFiles ); break; } case 'deleteForgotten': { const forgottenFile = yield* findForgottenFile(ctx, docId); if (!forgottenFile) { output.error = commonDefines.c_oAscServerCommandErrors.DocumentIdError; break; } yield storage.deletePath(ctx, docId, tenForgottenFiles); break; } case 'getForgottenList': { forgottenData.keys = yield* getFilesKeys(ctx, tenForgottenFiles); break; } case 'version': { output.version = `${commonDefines.buildVersion}.${commonDefines.buildNumber}`; break; } case 'license': { const outputLicense = yield* commandLicense(ctx); Object.assign(output, outputLicense); break; } default: { output.error = commonDefines.c_oAscServerCommandErrors.UnknownCommand; break; } } Object.assign(output, forgottenData); } // Command from the server (specifically teamlab) exports.commandFromServer = function (req, res) { return co(function* () { const output = {key: 'commandFromServer', error: commonDefines.c_oAscServerCommandErrors.NoError, version: undefined, users: undefined}; const ctx = new operationContext.Context(); let postRes = null; try { ctx.initFromRequest(req); yield ctx.initTenantCache(); ctx.logger.info('commandFromServer start'); const authRes = yield getRequestParams(ctx, req); const params = authRes.params; // Key is document id output.key = params.key; output.error = validateInputParams(ctx, authRes, params); if (output.error === commonDefines.c_oAscServerCommandErrors.NoError) { if (params.key && !req.query[constants.SHARD_KEY_API_NAME] && !req.query[constants.SHARD_KEY_WOPI_NAME] && process.env.DEFAULT_SHARD_KEY) { postRes = yield proxyCommand(ctx, req, params); } else { ctx.logger.debug('commandFromServer: c = %s', params.c); yield* commandHandle(ctx, params, req, output); } } } catch (err) { output.error = commonDefines.c_oAscServerCommandErrors.UnknownError; ctx.logger.error('Error commandFromServer: %s', err.stack); } finally { let outputBuffer; if (postRes) { outputBuffer = postRes.body; } else { outputBuffer = Buffer.from(JSON.stringify(output), 'utf8'); } res.setHeader('Content-Type', 'application/json'); res.setHeader('Content-Length', outputBuffer.length); res.send(outputBuffer); ctx.logger.info('commandFromServer end : %s', outputBuffer); } }); }; exports.shutdown = function (req, res) { return co(function* () { let output = false; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); ctx.logger.info('shutdown start'); output = yield shutdown.shutdown(ctx, editorStat, req.method === 'PUT'); } catch (err) { ctx.logger.error('shutdown error %s', err.stack); } finally { res.setHeader('Content-Type', 'text/plain'); res.send(output.toString()); ctx.logger.info('shutdown end'); } }); }; exports.preStop = async function (req, res) { let output = false; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); await ctx.initTenantCache(); preStopFlag = req.method === 'PUT'; ctx.logger.info('preStop set flag', preStopFlag); if (preStopFlag) { await gc.checkFileExpire(0); } output = true; } catch (err) { ctx.logger.error('preStop error %s', err.stack); } finally { res.setHeader('Content-Type', 'text/plain'); res.send(output.toString()); ctx.logger.info('preStop end'); } }; /** * Get active connections array * @returns {Array} Active connections */ function getConnections() { return connections; } exports.getConnections = getConnections; exports.getEditorConnectionsCount = function (req, res) { const ctx = new operationContext.Context(); let count = 0; try { ctx.initFromRequest(req); for (let i = 0; i < connections.length; ++i) { const conn = connections[i]; if (!(conn.isCloseCoAuthoring || (conn.user && conn.user.view))) { count++; } } ctx.logger.info('getConnectionsCount count=%d', count); } catch (err) { ctx.logger.error('getConnectionsCount error %s', err.stack); } finally { res.setHeader('Content-Type', 'text/plain'); res.send(count.toString()); } };