/* * (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 * */ 'use strict'; const crypto = require('crypto'); const path = require('path'); const {pipeline} = require('node:stream/promises'); const {URL} = require('url'); const co = require('co'); const jwt = require('jsonwebtoken'); const config = require('config'); const {createReadStream} = require('fs'); const {stat, lstat, readdir} = require('fs/promises'); const utf7 = require('utf7'); const mimeDB = require('mime-db'); const xmlbuilder2 = require('xmlbuilder2'); const utils = require('./../../Common/sources/utils'); const constants = require('./../../Common/sources/constants'); const commonDefines = require('./../../Common/sources/commondefines'); const wopiUtils = require('./wopiUtils'); const operationContext = require('./../../Common/sources/operationContext'); const tenantManager = require('./../../Common/sources/tenantManager'); const sqlBase = require('./databaseConnectors/baseConnector'); const taskResult = require('./taskresult'); const canvasService = require('./canvasservice'); const converterService = require('./converterservice'); const mime = require('mime'); const license = require('./../../Common/sources/license'); const cfgTokenOutboxAlgorithm = config.get('services.CoAuthoring.token.outbox.algorithm'); const cfgTokenOutboxExpires = config.get('services.CoAuthoring.token.outbox.expires'); const cfgCallbackRequestTimeout = config.get('services.CoAuthoring.server.callbackRequestTimeout'); const cfgNewFileTemplate = config.get('services.CoAuthoring.server.newFileTemplate'); const cfgDownloadTimeout = config.get('FileConverter.converter.downloadTimeout'); const cfgWopiFileInfoBlockList = config.get('wopi.fileInfoBlockList'); const cfgWopiWopiZone = config.get('wopi.wopiZone'); const cfgWopiPdfView = config.get('wopi.pdfView'); const cfgWopiPdfEdit = config.get('wopi.pdfEdit'); const cfgWopiWordView = config.get('wopi.wordView'); const cfgWopiWordEdit = config.get('wopi.wordEdit'); const cfgWopiCellView = config.get('wopi.cellView'); const cfgWopiCellEdit = config.get('wopi.cellEdit'); const cfgWopiSlideView = config.get('wopi.slideView'); const cfgWopiSlideEdit = config.get('wopi.slideEdit'); const cfgWopiDiagramView = config.get('wopi.diagramView'); const cfgWopiDiagramEdit = config.get('wopi.diagramEdit'); const cfgWopiForms = config.get('wopi.forms'); const cfgWopiFavIconUrlWord = config.get('wopi.favIconUrlWord'); const cfgWopiFavIconUrlCell = config.get('wopi.favIconUrlCell'); const cfgWopiFavIconUrlSlide = config.get('wopi.favIconUrlSlide'); const cfgWopiFavIconUrlPdf = config.get('wopi.favIconUrlPdf'); const cfgWopiFavIconUrlDiagram = config.get('wopi.favIconUrlDiagram'); const cfgWopiPublicKey = config.get('wopi.publicKey'); const cfgWopiModulus = config.get('wopi.modulus'); const cfgWopiExponent = config.get('wopi.exponent'); const cfgWopiPublicKeyOld = config.get('wopi.publicKeyOld'); const cfgWopiModulusOld = config.get('wopi.modulusOld'); const cfgWopiExponentOld = config.get('wopi.exponentOld'); const cfgWopiHost = config.get('wopi.host'); const cfgWopiDummySampleFilePath = config.get('wopi.dummy.sampleFilePath'); let templatesFolderLocalesCache = null; let templatesFolderExtsCache = null; const templateFilesSizeCache = {}; let shutdownFlag = false; //patch mimeDB if (!mimeDB['application/vnd.visio2013']) { mimeDB['application/vnd.visio2013'] = {extensions: ['vsdx', 'vstx', 'vssx', 'vsdm', 'vstm', 'vssm']}; } const mimeTypesByExt = (function () { const mimeTypesByExt = {}; for (const mimeType in mimeDB) { if (Object.hasOwn(mimeDB, mimeType)) { const val = mimeDB[mimeType]; if (val.extensions) { val.extensions.forEach(value => { if (!mimeTypesByExt[value]) { mimeTypesByExt[value] = []; } mimeTypesByExt[value].push(mimeType); }); } } } return mimeTypesByExt; })(); async function getTemplatesFolderExts(ctx) { //find available template files if (templatesFolderExtsCache === null) { const tenNewFileTemplate = ctx.getCfg('services.CoAuthoring.server.newFileTemplate', cfgNewFileTemplate); const dirContent = await readdir(`${tenNewFileTemplate}/${constants.TEMPLATES_DEFAULT_LOCALE}/`, {withFileTypes: true}); templatesFolderExtsCache = dirContent .filter(dirObject => dirObject.isFile()) .reduce((result, item) => { const ext = path.extname(item.name).substring(1); result[ext] = ext; return result; }, {}); } return templatesFolderExtsCache; } function discovery(req, res) { return co(function* () { const xml = xmlbuilder2.create({version: '1.0', encoding: 'utf-8'}); const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); ctx.logger.info('wopiDiscovery start'); const tenWopiWopiZone = ctx.getCfg('wopi.wopiZone', cfgWopiWopiZone); const tenWopiPdfView = ctx.getCfg('wopi.pdfView', cfgWopiPdfView); const tenWopiPdfEdit = ctx.getCfg('wopi.pdfEdit', cfgWopiPdfEdit); const tenWopiWordView = ctx.getCfg('wopi.wordView', cfgWopiWordView); const tenWopiWordEdit = ctx.getCfg('wopi.wordEdit', cfgWopiWordEdit); const tenWopiCellView = ctx.getCfg('wopi.cellView', cfgWopiCellView); const tenWopiCellEdit = ctx.getCfg('wopi.cellEdit', cfgWopiCellEdit); const tenWopiSlideView = ctx.getCfg('wopi.slideView', cfgWopiSlideView); const tenWopiSlideEdit = ctx.getCfg('wopi.slideEdit', cfgWopiSlideEdit); const tenWopiDiagramView = ctx.getCfg('wopi.diagramView', cfgWopiDiagramView); const tenWopiDiagramEdit = ctx.getCfg('wopi.diagramEdit', cfgWopiDiagramEdit); const tenWopiForms = ctx.getCfg('wopi.forms', cfgWopiForms); const tenWopiFavIconUrlWord = ctx.getCfg('wopi.favIconUrlWord', cfgWopiFavIconUrlWord); const tenWopiFavIconUrlCell = ctx.getCfg('wopi.favIconUrlCell', cfgWopiFavIconUrlCell); const tenWopiFavIconUrlSlide = ctx.getCfg('wopi.favIconUrlSlide', cfgWopiFavIconUrlSlide); const tenWopiFavIconUrlPdf = ctx.getCfg('wopi.favIconUrlPdf', cfgWopiFavIconUrlPdf); const tenWopiFavIconUrlDiagram = ctx.getCfg('wopi.favIconUrlDiagram', cfgWopiFavIconUrlDiagram); const tenWopiPublicKey = ctx.getCfg('wopi.publicKey', cfgWopiPublicKey); const tenWopiModulus = ctx.getCfg('wopi.modulus', cfgWopiModulus); const tenWopiExponent = ctx.getCfg('wopi.exponent', cfgWopiExponent); const tenWopiPublicKeyOld = ctx.getCfg('wopi.publicKeyOld', cfgWopiPublicKeyOld); const tenWopiModulusOld = ctx.getCfg('wopi.modulusOld', cfgWopiModulusOld); const tenWopiExponentOld = ctx.getCfg('wopi.exponentOld', cfgWopiExponentOld); const tenWopiHost = ctx.getCfg('wopi.host', cfgWopiHost); const baseUrl = tenWopiHost || utils.getBaseUrlByRequest(ctx, req); const names = ['Word', 'Excel', 'PowerPoint', 'Pdf']; const favIconUrls = [tenWopiFavIconUrlWord, tenWopiFavIconUrlCell, tenWopiFavIconUrlSlide, tenWopiFavIconUrlPdf]; const exts = [ {targetext: 'docx', view: tenWopiWordView, edit: tenWopiWordEdit}, {targetext: 'xlsx', view: tenWopiCellView, edit: tenWopiCellEdit}, {targetext: 'pptx', view: tenWopiSlideView, edit: tenWopiSlideEdit}, {targetext: null, view: tenWopiPdfView, edit: tenWopiPdfEdit} ]; const documentTypes = [`word`, `cell`, `slide`, `pdf`]; //todo check sdkjs-ooxml addon const addVisio = (tenWopiDiagramView.length > 0 || tenWopiDiagramEdit.length > 0) && (constants.PACKAGE_TYPE_OS !== license.packageType || process.env?.NODE_ENV?.startsWith('development-')); if (addVisio) { names.push('Visio'); favIconUrls.push(tenWopiFavIconUrlDiagram); exts.push({targetext: null, view: tenWopiDiagramView, edit: tenWopiDiagramEdit}); documentTypes.push(`diagram`); } const templatesFolderExtsCache = yield getTemplatesFolderExts(ctx); const formsExts = tenWopiForms.reduce((result, item) => { result[item] = item; return result; }, {}); const templateStart = `${baseUrl}/hosting/wopi`; let templateEnd = `<rs=DC_LLCC&><dchat=DISABLE_CHAT&><embed=EMBEDDED&>`; templateEnd += `<fs=FULLSCREEN&><hid=HOST_SESSION_ID&><rec=RECORDING&>`; templateEnd += `<sc=SESSION_CONTEXT&><thm=THEME_ID&><ui=UI_LLCC&>`; templateEnd += `<wopisrc=WOPI_SOURCE&>&`; const xmlZone = xml.ele('wopi-discovery').ele('net-zone', {name: tenWopiWopiZone}); //start section for MS WOPI connectors for (let i = 0; i < names.length; ++i) { const name = names[i]; let favIconUrl = favIconUrls[i]; if (!(favIconUrl.startsWith('http://') || favIconUrl.startsWith('https://'))) { favIconUrl = baseUrl + favIconUrl; } const ext = exts[i]; const urlTemplateView = `${templateStart}/${documentTypes[i]}/view?${templateEnd}`; const urlTemplateEmbedView = `${templateStart}/${documentTypes[i]}/view?embed=1&${templateEnd}`; const urlTemplateMobileView = `${templateStart}/${documentTypes[i]}/view?mobile=1&${templateEnd}`; const urlTemplateEdit = `${templateStart}/${documentTypes[i]}/edit?${templateEnd}`; const urlTemplateMobileEdit = `${templateStart}/${documentTypes[i]}/edit?mobile=1&${templateEnd}`; const urlTemplateFormSubmit = `${templateStart}/${documentTypes[i]}/edit?formsubmit=1&${templateEnd}`; const xmlApp = xmlZone.ele('app', {name, favIconUrl}); for (let j = 0; j < ext.view.length; ++j) { xmlApp.ele('action', {name: 'view', ext: ext.view[j], default: 'true', urlsrc: urlTemplateView}).up(); xmlApp.ele('action', {name: 'embedview', ext: ext.view[j], urlsrc: urlTemplateEmbedView}).up(); xmlApp.ele('action', {name: 'mobileView', ext: ext.view[j], urlsrc: urlTemplateMobileView}).up(); if (ext.targetext) { const urlConvert = `${templateStart}/convert-and-edit/${ext.view[j]}/${ext.targetext}?${templateEnd}`; xmlApp.ele('action', {name: 'convert', ext: ext.view[j], targetext: ext.targetext, requires: 'update', urlsrc: urlConvert}).up(); } } for (let j = 0; j < ext.edit.length; ++j) { xmlApp.ele('action', {name: 'view', ext: ext.edit[j], urlsrc: urlTemplateView}).up(); xmlApp.ele('action', {name: 'embedview', ext: ext.edit[j], urlsrc: urlTemplateEmbedView}).up(); xmlApp.ele('action', {name: 'mobileView', ext: ext.edit[j], urlsrc: urlTemplateMobileView}).up(); if (formsExts[ext.edit[j]]) { xmlApp.ele('action', {name: 'edit', ext: ext.edit[j], default: 'true', requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); xmlApp.ele('action', {name: 'formsubmit', ext: ext.edit[j], requires: 'locks,update', urlsrc: urlTemplateFormSubmit}).up(); } else { xmlApp.ele('action', {name: 'edit', ext: ext.edit[j], default: 'true', requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); } xmlApp.ele('action', {name: 'mobileEdit', ext: ext.edit[j], requires: 'locks,update', urlsrc: urlTemplateMobileEdit}).up(); if (templatesFolderExtsCache[ext.edit[j]]) { xmlApp.ele('action', {name: 'editnew', ext: ext.edit[j], requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); } } xmlApp.up(); } //end section for MS WOPI connectors //start section for collabora nexcloud connectors for (let i = 0; i < exts.length; ++i) { const ext = exts[i]; const urlTemplateView = `${templateStart}/${documentTypes[i]}/view?${templateEnd}`; const urlTemplateEmbedView = `${templateStart}/${documentTypes[i]}/view?embed=1&${templateEnd}`; const urlTemplateMobileView = `${templateStart}/${documentTypes[i]}/view?mobile=1&${templateEnd}`; const urlTemplateEdit = `${templateStart}/${documentTypes[i]}/edit?${templateEnd}`; const urlTemplateMobileEdit = `${templateStart}/${documentTypes[i]}/edit?mobile=1&${templateEnd}`; const urlTemplateFormSubmit = `${templateStart}/${documentTypes[i]}/edit?formsubmit=1&${templateEnd}`; const mimeTypesDuplicate = new Set(); //to remove duplicates for each editor(allow html for word and excel) for (let j = 0; j < ext.view.length; ++j) { const mimeTypes = mimeTypesByExt[ext.view[j]]; if (mimeTypes) { mimeTypes.forEach(value => { if (mimeTypesDuplicate.has(value)) { return; } else { mimeTypesDuplicate.add(value); } const xmlApp = xmlZone.ele('app', {name: value}); xmlApp.ele('action', {name: 'view', ext: '', default: 'true', urlsrc: urlTemplateView}).up(); xmlApp.ele('action', {name: 'embedview', ext: '', urlsrc: urlTemplateEmbedView}).up(); xmlApp.ele('action', {name: 'mobileView', ext: '', urlsrc: urlTemplateMobileView}).up(); if (ext.targetext) { const urlConvert = `${templateStart}/convert-and-edit/${ext.view[j]}/${ext.targetext}?${templateEnd}`; xmlApp.ele('action', {name: 'convert', ext: '', targetext: ext.targetext, requires: 'update', urlsrc: urlConvert}).up(); } xmlApp.up(); }); } } mimeTypesDuplicate.clear(); for (let j = 0; j < ext.edit.length; ++j) { const mimeTypes = mimeTypesByExt[ext.edit[j]]; if (mimeTypes) { mimeTypes.forEach(value => { if (mimeTypesDuplicate.has(value)) { return; } else { mimeTypesDuplicate.add(value); } const xmlApp = xmlZone.ele('app', {name: value}); if (formsExts[ext.edit[j]]) { xmlApp.ele('action', {name: 'edit', ext: '', default: 'true', requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); xmlApp.ele('action', {name: 'formsubmit', ext: '', requires: 'locks,update', urlsrc: urlTemplateFormSubmit}).up(); } else { xmlApp.ele('action', {name: 'edit', ext: '', default: 'true', requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); } xmlApp.ele('action', {name: 'mobileEdit', ext: '', requires: 'locks,update', urlsrc: urlTemplateMobileEdit}).up(); if (templatesFolderExtsCache[ext.edit[j]]) { xmlApp.ele('action', {name: 'editnew', ext: '', requires: 'locks,update', urlsrc: urlTemplateEdit}).up(); } xmlApp.up(); }); } } } const xmlApp = xmlZone.ele('app', {name: 'Capabilities'}); xmlApp.ele('action', {ext: '', name: 'getinfo', requires: 'locks,update', urlsrc: `${baseUrl}/hosting/capabilities`}).up(); xmlApp.up(); //end section for collabora nexcloud connectors const xmlDiscovery = xmlZone.up(); if (tenWopiPublicKeyOld && tenWopiPublicKey) { const exponent = numberToBase64(tenWopiExponent); const exponentOld = numberToBase64(tenWopiExponentOld); xmlDiscovery .ele('proof-key', { oldvalue: tenWopiPublicKeyOld, oldmodulus: tenWopiModulusOld, oldexponent: exponentOld, value: tenWopiPublicKey, modulus: tenWopiModulus, exponent }) .up(); } xmlDiscovery.up(); } catch (err) { ctx.logger.error('wopiDiscovery error:%s', err.stack); } finally { res.setHeader('Content-Type', 'text/xml'); res.send(xml.end()); ctx.logger.info('wopiDiscovery end'); } }); } function collaboraCapabilities(req, res) { return co(function* () { const output = { 'convert-to': {available: true, endpoint: '/lool/convert-to'}, hasMobileSupport: true, hasProxyPrefix: false, hasTemplateSaveAs: false, hasTemplateSource: true, productVersion: commonDefines.buildVersion }; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); ctx.logger.info('collaboraCapabilities start'); } catch (err) { ctx.logger.error('collaboraCapabilities error:%s', err.stack); } finally { utils.fillResponseSimple(res, JSON.stringify(output), 'application/json'); ctx.logger.info('collaboraCapabilities end'); } }); } function isWopiCallback(url) { return url && url.startsWith('{'); } function isWopiUnlockMarker(url) { return isWopiCallback(url) && !!JSON.parse(url).unlockId; } function isWopiModifiedMarker(url) { if (isWopiCallback(url)) { const obj = JSON.parse(url); return obj.fileInfo && obj.fileInfo.LastModifiedTime; } } function getWopiUnlockMarker(wopiParams) { if (!wopiParams.userAuth || !wopiParams.commonInfo) { return; } return JSON.stringify(Object.assign({unlockId: wopiParams.commonInfo.lockId}, wopiParams.userAuth)); } function getWopiModifiedMarker(wopiParams, lastModifiedTime) { return JSON.stringify(Object.assign({fileInfo: {LastModifiedTime: lastModifiedTime}}, wopiParams.userAuth)); } function getFileTypeByInfo(fileInfo) { let fileType = fileInfo.BaseFileName ? fileInfo.BaseFileName.substr(fileInfo.BaseFileName.lastIndexOf('.') + 1) : ''; fileType = fileInfo.FileExtension ? fileInfo.FileExtension.substr(1) : fileType; return fileType.toLowerCase(); } function isWopiJwtToken(decoded) { return !!decoded.fileInfo; } function setIsShutdown(val) { shutdownFlag = val; } function getLastModifiedTimeFromCallbacks(callbacks) { for (let i = callbacks.length; i >= 0; --i) { const callback = callbacks[i]; const lastModifiedTime = isWopiModifiedMarker(callback); if (lastModifiedTime) { return lastModifiedTime; } } } function isCorrectUserAuth(userAuth) { return undefined !== userAuth.wopiSrc; } function parseWopiCallback(ctx, userAuthStr, opt_url) { let wopiParams = null; if (isWopiCallback(userAuthStr)) { let userAuth = JSON.parse(userAuthStr); if (!isCorrectUserAuth(userAuth)) { userAuth = null; } let commonInfo = null; let lastModifiedTime = null; if (opt_url) { const commonInfoStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, opt_url, 1); if (isWopiCallback(commonInfoStr)) { commonInfo = JSON.parse(commonInfoStr); if (commonInfo.fileInfo) { lastModifiedTime = commonInfo.fileInfo.LastModifiedTime; if (lastModifiedTime) { const callbacks = sqlBase.UserCallback.prototype.getCallbacks(ctx, opt_url); lastModifiedTime = getLastModifiedTimeFromCallbacks(callbacks); } } else { commonInfo = null; } } } wopiParams = {commonInfo, userAuth, LastModifiedTime: lastModifiedTime}; ctx.logger.debug('parseWopiCallback wopiParams:%j', wopiParams); } return wopiParams; } function checkAndInvalidateCache(ctx, docId, fileInfo) { return co(function* () { const res = {success: true, lockId: undefined}; const selectRes = yield taskResult.select(ctx, docId); if (selectRes.length > 0) { const row = selectRes[0]; if (row.callback) { const commonInfoStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback, 1); if (isWopiCallback(commonInfoStr)) { const commonInfo = JSON.parse(commonInfoStr); res.lockId = commonInfo.lockId; ctx.logger.debug('wopiEditor lockId from DB lockId=%s', res.lockId); const unlockMarkStr = sqlBase.UserCallback.prototype.getCallbackByUserIndex(ctx, row.callback); ctx.logger.debug('wopiEditor commonInfoStr=%s', commonInfoStr); ctx.logger.debug('wopiEditor unlockMarkStr=%s', unlockMarkStr); const hasUnlockMarker = isWopiUnlockMarker(unlockMarkStr); const isUpdateVersion = commonDefines.FileStatus.UpdateVersion === row.status; ctx.logger.debug('wopiEditor hasUnlockMarker=%s isUpdateVersion=%s', hasUnlockMarker, isUpdateVersion); if (hasUnlockMarker || isUpdateVersion) { const fileInfoVersion = fileInfo.Version; const cacheVersion = commonInfo.fileInfo.Version; const fileInfoModified = fileInfo.LastModifiedTime; const cacheModified = commonInfo.fileInfo.LastModifiedTime; ctx.logger.debug('wopiEditor version fileInfo=%s; cache=%s', fileInfoVersion, cacheVersion); ctx.logger.debug('wopiEditor LastModifiedTime fileInfo=%s; cache=%s', fileInfoModified, cacheModified); if (fileInfoVersion !== cacheVersion || fileInfoModified !== cacheModified) { const mask = new taskResult.TaskResultData(); mask.tenant = ctx.tenant; mask.key = docId; mask.last_open_date = row.last_open_date; //cleanupRes can be false in case of simultaneous opening. it is OK const cleanupRes = yield canvasService.cleanupCacheIf(ctx, mask); ctx.logger.debug('wopiEditor cleanupRes=%s', cleanupRes); res.lockId = undefined; } } } else { res.success = false; ctx.logger.warn('wopiEditor attempt to open not wopi record'); } } } return res; }); } function parsePutFileResponse(ctx, postRes) { let body = null; if (postRes.body) { try { //collabora nexcloud connector body = JSON.parse(postRes.body); } catch (e) { ctx.logger.debug('wopi PutFile body parse error: %s', e.stack); } } return body; } async function checkAndReplaceEmptyFile(ctx, fileInfo, wopiSrc, access_token, access_token_ttl, lang, ui, fileType) { // TODO: throw error if format not supported? if (fileInfo.Size === 0 && fileType.length !== 0) { const tenNewFileTemplate = ctx.getCfg('services.CoAuthoring.server.newFileTemplate', cfgNewFileTemplate); //Create new files using Office for the web const wopiParams = getWopiParams(undefined, fileInfo, wopiSrc, access_token, access_token_ttl); if (templatesFolderLocalesCache === null) { const dirContent = await readdir(`${tenNewFileTemplate}/`, {withFileTypes: true}); templatesFolderLocalesCache = dirContent.filter(dirObject => dirObject.isDirectory()).map(dirObject => dirObject.name); } const localePrefix = lang || ui || 'en'; let locale = constants.TEMPLATES_FOLDER_LOCALE_COLLISON_MAP[localePrefix] ?? templatesFolderLocalesCache.find(locale => locale.startsWith(localePrefix)); if (locale === undefined) { locale = constants.TEMPLATES_DEFAULT_LOCALE; } const filePath = `${tenNewFileTemplate}/${locale}/new.${fileType}`; if (!templateFilesSizeCache[filePath]) { templateFilesSizeCache[filePath] = await lstat(filePath); } const templateFileInfo = templateFilesSizeCache[filePath]; const templateFileStream = createReadStream(filePath); const postRes = await putFile(ctx, wopiParams, undefined, templateFileStream, templateFileInfo.size, fileInfo.UserId, false, false, false); if (postRes) { //update Size fileInfo.Size = templateFileInfo.size; const body = parsePutFileResponse(ctx, postRes); //collabora nexcloud connector if (body?.LastModifiedTime) { //update LastModifiedTime fileInfo.LastModifiedTime = body.LastModifiedTime; } } } } function createDocId(ctx, wopiSrc, mode, fileInfo) { const fileId = wopiSrc.substring(wopiSrc.lastIndexOf('/') + 1); let docId = undefined; if ('view' !== mode) { docId = `${fileId}`; } else { //todo rename operation requires lock fileInfo.SupportsRename = false; //todo change docId to avoid empty cache after editors are gone if (fileInfo.LastModifiedTime) { docId = `view.${fileId}.${fileInfo.LastModifiedTime}`; } else { docId = `view.${fileId}.${fileInfo.Version}`; } } docId = docId.replace(constants.DOC_ID_REPLACE_REGEX, '_').substring(0, constants.DOC_ID_MAX_LENGTH); return docId; } async function preOpen(ctx, lockId, docId, fileInfo, userAuth, baseUrl, fileType) { //todo move to lock and common info saving to websocket connection //save common info if (undefined === lockId) { //Use deterministic(not random) lockId to fix issues with forgotten openings due to integrator failures lockId = docId; const commonInfo = JSON.stringify({lockId, fileInfo}); await canvasService.commandOpenStartPromise(ctx, docId, baseUrl, commonInfo, fileType); } //Lock if ('view' !== userAuth.mode) { const lockRes = await lock(ctx, 'LOCK', lockId, fileInfo, userAuth); return !!lockRes; } return true; } /** * Prepares document for editing by creating document ID and validating cache * @param {operationContext.Context} ctx - The operation context * @param {string} wopiSrc - The WOPI source URL * @param {Object} fileInfo - File information from WOPI * @param {Object} userAuth - User authentication object * @param {string} fileType - File type * @param {string} baseUrl - Base URL for internal file endpoints * @param {Object} params - Parameters object to update * @returns {Promise} Promise resolving to success result */ async function prepareDocumentForEditing(ctx, wopiSrc, fileInfo, userAuth, fileType, baseUrl, params) { // Create document ID const docId = createDocId(ctx, wopiSrc, userAuth.mode, fileInfo); params.key = docId; // Check and invalidate cache const checkRes = await checkAndInvalidateCache(ctx, docId, fileInfo); if (!checkRes.success) { params.fileInfo = {}; return false; } if (!shutdownFlag) { const preOpenRes = await preOpen(ctx, checkRes.lockId, docId, fileInfo, userAuth, baseUrl, fileType); if (!preOpenRes && userAuth.mode !== 'view') { ctx.logger.warn('prepareDocumentForEditing error: lock failed, fallback to view mode'); userAuth.mode = 'view'; userAuth.forcedViewMode = true; return await prepareDocumentForEditing(ctx, wopiSrc, fileInfo, userAuth, fileType, baseUrl, params); } } return true; } function getEditorHtml(req, res) { return co(function* () { const params = { key: undefined, apiQuery: '', fileInfo: {}, userAuth: {}, queryParams: req.query, token: undefined, documentType: undefined, docs_api_config: {} }; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); const tenTokenOutboxAlgorithm = ctx.getCfg('services.CoAuthoring.token.outbox.algorithm', cfgTokenOutboxAlgorithm); const tenTokenOutboxExpires = ctx.getCfg('services.CoAuthoring.token.outbox.expires', cfgTokenOutboxExpires); const tenWopiFileInfoBlockList = ctx.getCfg('wopi.fileInfoBlockList', cfgWopiFileInfoBlockList); const wopiSrc = req.query['wopisrc']; const fileId = wopiSrc.substring(wopiSrc.lastIndexOf('/') + 1); ctx.setDocId(fileId); const usid = req.query['usid'] || crypto.randomUUID(); ctx.setUserSessionId(usid); ctx.logger.info('wopiEditor start'); ctx.logger.debug(`wopiEditor req.url:%s`, req.url); ctx.logger.debug(`wopiEditor req.query:%j`, req.query); ctx.logger.debug(`wopiEditor req.body:%j`, req.body); params.apiQuery = `?${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(wopiSrc)}`; params.documentType = req.params.documentType; let mode = req.params.mode; const sc = req.query['sc']; const lang = req.query['lang']; const ui = req.query['ui']; const access_token = req.body['access_token'] || ''; const access_token_ttl = parseInt(req.body['access_token_ttl']) || 0; const docs_api_config = req.body['docs_api_config']; if (docs_api_config) { params.docs_api_config = JSON.parse(docs_api_config); } // Create user authentication object const userAuth = (params.userAuth = { wopiSrc, access_token, access_token_ttl, userSessionId: usid, mode, forcedViewMode: false }); const fileInfo = (params.fileInfo = yield checkFileInfo(ctx, wopiSrc, access_token, sc)); if (!fileInfo) { params.fileInfo = {}; return; } const fileType = getFileTypeByInfo(fileInfo); if (!shutdownFlag) { yield checkAndReplaceEmptyFile(ctx, fileInfo, wopiSrc, access_token, access_token_ttl, lang, ui, fileType); } const canEdit = fileInfo.UserCanOnlyComment || fileInfo.UserCanWrite || fileInfo.UserCanReview; if (!canEdit) { ctx.logger.warn('wopiEditor: edit mode is not allowed, fallback to view mode'); userAuth.mode = 'view'; userAuth.forcedViewMode = true; } // Prepare document for editing (docId, cache validation) const prepareResult = yield prepareDocumentForEditing(ctx, wopiSrc, fileInfo, userAuth, fileType, utils.getBaseUrlByRequest(ctx, req), params); if (!prepareResult) { params.fileInfo = {}; return; } mode = userAuth.mode; ctx.setDocId(params.key); tenWopiFileInfoBlockList.forEach(item => { delete params.fileInfo[item]; }); const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); params.token = jwt.sign(params, utils.getJwtHsKey(secret), options); } catch (err) { ctx.logger.error('wopiEditor error: %s', err.stack); params.fileInfo = {}; } finally { ctx.logger.debug('wopiEditor render params=%j', params); try { res.render('editor-wopi', params); } catch (err) { ctx.logger.error('wopiEditor error:%s', err.stack); res.sendStatus(400); } ctx.logger.info('wopiEditor end'); } }); } function getConverterHtml(req, res) { return co(function* () { const params = {statusHandler: undefined}; const ctx = new operationContext.Context(); try { ctx.initFromRequest(req); yield ctx.initTenantCache(); const tenTokenOutboxAlgorithm = ctx.getCfg('services.CoAuthoring.token.outbox.algorithm', cfgTokenOutboxAlgorithm); const tenTokenOutboxExpires = ctx.getCfg('services.CoAuthoring.token.outbox.expires', cfgTokenOutboxExpires); const tenWopiHost = ctx.getCfg('wopi.host', cfgWopiHost); const wopiSrc = req.query['wopisrc']; const fileId = wopiSrc.substring(wopiSrc.lastIndexOf('/') + 1); ctx.setDocId(fileId); ctx.logger.info('convert-and-edit start'); const access_token = req.body['access_token'] || ''; const access_token_ttl = parseInt(req.body['access_token_ttl']) || 0; const ext = req.params.ext; const targetext = req.params.targetext; if (!(wopiSrc && access_token && access_token_ttl && ext && targetext)) { ctx.logger.debug( 'convert-and-edit invalid params: WOPISrc=%s; access_token=%s; access_token_ttl=%s; ext=%s; targetext=%s', wopiSrc, access_token, access_token_ttl, ext, targetext ); return; } const fileInfo = yield checkFileInfo(ctx, wopiSrc, access_token); if (!fileInfo) { ctx.logger.info('convert-and-edit checkFileInfo error'); return; } const wopiParams = getWopiParams(undefined, fileInfo, wopiSrc, access_token, access_token_ttl); const docId = yield converterService.convertAndEdit(ctx, wopiParams, ext, targetext); if (docId) { const baseUrl = tenWopiHost || utils.getBaseUrlByRequest(ctx, req); params.statusHandler = `${baseUrl}/hosting/wopi/convert-and-edit-handler`; params.statusHandler += `?${constants.SHARD_KEY_WOPI_NAME}=${encodeURIComponent(wopiSrc)}&access_token=${encodeURIComponent(access_token)}`; params.statusHandler += `&targetext=${encodeURIComponent(targetext)}&docId=${encodeURIComponent(docId)}`; const tokenData = {docId}; const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); const token = jwt.sign(tokenData, utils.getJwtHsKey(secret), options); params.statusHandler += `&token=${encodeURIComponent(token)}`; } } catch (err) { ctx.logger.error('convert-and-edit error:%s', err.stack); } finally { ctx.logger.debug('convert-and-edit render params=%j', params); try { res.render('convert-and-edit-wopi', params); } catch (err) { ctx.logger.error('convert-and-edit error:%s', err.stack); res.sendStatus(400); } ctx.logger.info('convert-and-edit end'); } }); } function putFile(ctx, wopiParams, data, dataStream, dataSize, userLastChangeId, isModifiedByUser, isAutosave, isExitSave) { return co(function* () { let postRes = null; try { ctx.logger.info('wopi PutFile start'); const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); if (!wopiParams.userAuth || !wopiParams.commonInfo) { return postRes; } const fileInfo = wopiParams.commonInfo.fileInfo; const userAuth = wopiParams.userAuth; const uri = `${userAuth.wopiSrc}/contents?access_token=${encodeURIComponent(userAuth.access_token)}`; const filterStatus = yield checkIpFilter(ctx, uri); if (0 !== filterStatus) { return postRes; } //collabora nexcloud connector sets only UserCanWrite=true const canEdit = fileInfo.UserCanOnlyComment || fileInfo.UserCanWrite || fileInfo.UserCanReview; if (fileInfo && (fileInfo.SupportsUpdate || canEdit)) { const commonInfo = wopiParams.commonInfo; //todo add all the users who contributed changes to the document in this PutFile request to X-WOPI-Editors const headers = {'X-WOPI-Override': 'PUT', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-Editors': userLastChangeId}; yield wopiUtils.fillStandardHeaders(ctx, headers, uri, userAuth.access_token); headers['X-LOOL-WOPI-IsModifiedByUser'] = isModifiedByUser; headers['X-LOOL-WOPI-IsAutosave'] = isAutosave; headers['X-LOOL-WOPI-IsExitSave'] = isExitSave; if (wopiParams.LastModifiedTime) { //collabora nexcloud connector headers['X-LOOL-WOPI-Timestamp'] = wopiParams.LastModifiedTime; } headers['Content-Type'] = mime.getType(getFileTypeByInfo(fileInfo)); ctx.logger.debug('wopi PutFile request uri=%s headers=%j', uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; postRes = yield utils.postRequestPromise(ctx, uri, data, dataStream, dataSize, tenCallbackRequestTimeout, undefined, isInJwtToken, headers); ctx.logger.debug('wopi PutFile response headers=%j', postRes.response.headers); ctx.logger.debug('wopi PutFile response body:%s', postRes.body); } else { ctx.logger.warn('wopi SupportsUpdate = %s or canEdit = %s', fileInfo?.SupportsUpdate, canEdit); } } catch (err) { ctx.logger.error('wopi error PutFile:%s', err.stack); } finally { ctx.logger.info('wopi PutFile end'); } return postRes; }); } function putRelativeFile(ctx, wopiSrc, access_token, data, dataStream, dataSize, suggestedExt, suggestedTarget, isFileConversion) { return co(function* () { let res = undefined; try { ctx.logger.info('wopi putRelativeFile start'); const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); const uri = `${wopiSrc}?access_token=${encodeURIComponent(access_token)}`; const filterStatus = yield checkIpFilter(ctx, uri); if (0 !== filterStatus) { return res; } const headers = {'X-WOPI-Override': 'PUT_RELATIVE', 'X-WOPI-SuggestedTarget': utf7.encode(suggestedTarget || suggestedExt)}; if (isFileConversion) { headers['X-WOPI-FileConversion'] = isFileConversion; } yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token); headers['Content-Type'] = mime.getType(suggestedExt); ctx.logger.debug('wopi putRelativeFile request uri=%s headers=%j', uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; const postRes = yield utils.postRequestPromise( ctx, uri, data, dataStream, dataSize, tenCallbackRequestTimeout, undefined, isInJwtToken, headers ); ctx.logger.debug('wopi putRelativeFile response headers=%j', postRes.response.headers); ctx.logger.debug('wopi putRelativeFile response body:%s', postRes.body); res = JSON.parse(postRes.body); } catch (err) { ctx.logger.error('wopi error putRelativeFile:%s', err.stack); } finally { ctx.logger.info('wopi putRelativeFile end'); } return res; }); } /** * Renames a file using the WOPI protocol * @param {operationContext.Context} ctx - The operation context. * @param {object} wopiParams - The WOPI parameters. * @param {string} name - The new name for the file. * @returns {Promise<{Name: string}|undefined>} */ async function renameFile(ctx, wopiParams, name) { let res = undefined; try { ctx.logger.info('wopi RenameFile start'); const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); if (!wopiParams.userAuth || !wopiParams.commonInfo) { return res; } const fileInfo = wopiParams.commonInfo.fileInfo; const userAuth = wopiParams.userAuth; const uri = `${userAuth.wopiSrc}?access_token=${encodeURIComponent(userAuth.access_token)}`; const filterStatus = await checkIpFilter(ctx, uri); if (0 !== filterStatus) { return res; } if (fileInfo && fileInfo.SupportsRename) { const fileNameMaxLength = fileInfo.FileNameMaxLength || 255; name = name.substring(0, fileNameMaxLength); const commonInfo = wopiParams.commonInfo; const headers = {'X-WOPI-Override': 'RENAME_FILE', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-RequestedName': utf7.encode(name)}; await wopiUtils.fillStandardHeaders(ctx, headers, uri, userAuth.access_token); ctx.logger.debug('wopi RenameFile request uri=%s headers=%j', uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; const postRes = await utils.postRequestPromise( ctx, uri, undefined, undefined, undefined, tenCallbackRequestTimeout, undefined, isInJwtToken, headers ); ctx.logger.debug('wopi RenameFile response headers=%j body=%s', postRes.response.headers, postRes.body); if (postRes.body) { res = JSON.parse(postRes.body); } else { //sharepoint send empty body(2016 allways, 2019 with same name) res = {Name: name}; } } else { ctx.logger.info('wopi SupportsRename = false'); } } catch (err) { ctx.logger.error('wopi error RenameFile:%s', err.stack); } finally { ctx.logger.info('wopi RenameFile end'); } return res; } async function refreshFile(ctx, wopiParams, baseUrl) { let res; try { ctx.logger.info('wopi RefreshFile start'); const userAuth = wopiParams.userAuth; if (!userAuth) { return; } const tenTokenOutboxAlgorithm = ctx.getCfg('services.CoAuthoring.token.outbox.algorithm', cfgTokenOutboxAlgorithm); const tenTokenOutboxExpires = ctx.getCfg('services.CoAuthoring.token.outbox.expires', cfgTokenOutboxExpires); const fileInfo = await checkFileInfo(ctx, userAuth.wopiSrc, userAuth.access_token); const fileType = getFileTypeByInfo(fileInfo); res = {userAuth, fileInfo, queryParams: undefined}; const prepareResult = await prepareDocumentForEditing(ctx, userAuth.wopiSrc, fileInfo, userAuth, fileType, baseUrl, res); if (!prepareResult) { return; } const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = await tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); res.token = jwt.sign(res, utils.getJwtHsKey(secret), options); } catch (err) { res = undefined; ctx.logger.error('wopi error RefreshFile:%s', err.stack); } finally { ctx.logger.info('wopi RefreshFile end'); } return res; } function checkFileInfo(ctx, wopiSrc, access_token, opt_sc) { return co(function* () { let fileInfo = undefined; try { ctx.logger.info('wopi checkFileInfo start'); const tenDownloadTimeout = ctx.getCfg('FileConverter.converter.downloadTimeout', cfgDownloadTimeout); const uri = `${wopiSrc}?access_token=${encodeURIComponent(access_token)}`; const filterStatus = yield checkIpFilter(ctx, uri); if (0 !== filterStatus) { return fileInfo; } const headers = {}; if (opt_sc) { headers['X-WOPI-SessionContext'] = opt_sc; } yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token); ctx.logger.debug('wopi checkFileInfo request uri=%s headers=%j', uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; const getRes = yield utils.downloadUrlPromise(ctx, uri, tenDownloadTimeout, undefined, undefined, isInJwtToken, headers); ctx.logger.debug(`wopi checkFileInfo headers=%j body=%s`, getRes.response.headers, getRes.body); fileInfo = JSON.parse(getRes.body); } catch (err) { ctx.logger.error('wopi error checkFileInfo:%s', err.stack); } finally { ctx.logger.info('wopi checkFileInfo end'); } return fileInfo; }); } function lock(ctx, command, lockId, fileInfo, userAuth) { return co(function* () { let res = true; try { ctx.logger.info('wopi %s start', command); const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); if (fileInfo && fileInfo.SupportsLocks) { if (!userAuth) { return false; } const wopiSrc = userAuth.wopiSrc; const access_token = userAuth.access_token; const uri = `${wopiSrc}?access_token=${encodeURIComponent(access_token)}`; const filterStatus = yield checkIpFilter(ctx, uri); if (0 !== filterStatus) { return false; } const headers = {'X-WOPI-Override': command, 'X-WOPI-Lock': lockId}; yield wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token); ctx.logger.debug('wopi %s request uri=%s headers=%j', command, uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; const postRes = yield utils.postRequestPromise( ctx, uri, undefined, undefined, undefined, tenCallbackRequestTimeout, undefined, isInJwtToken, headers ); ctx.logger.debug('wopi %s response headers=%j', command, postRes.response.headers); } else { ctx.logger.info('wopi %s SupportsLocks = false', command); } } catch (err) { res = false; ctx.logger.error('wopi error %s:%s', command, err.stack); } finally { ctx.logger.info('wopi %s end', command); } return res; }); } async function unlock(ctx, wopiParams) { let res = false; try { ctx.logger.info('wopi Unlock start'); const tenCallbackRequestTimeout = ctx.getCfg('services.CoAuthoring.server.callbackRequestTimeout', cfgCallbackRequestTimeout); if (!wopiParams.userAuth || !wopiParams.commonInfo) { return; } const fileInfo = wopiParams.commonInfo.fileInfo; if (fileInfo && fileInfo.SupportsLocks) { const wopiSrc = wopiParams.userAuth.wopiSrc; const lockId = wopiParams.commonInfo.lockId; const access_token = wopiParams.userAuth.access_token; const uri = `${wopiSrc}?access_token=${encodeURIComponent(access_token)}`; const filterStatus = await checkIpFilter(ctx, uri); if (0 !== filterStatus) { return; } const headers = {'X-WOPI-Override': 'UNLOCK', 'X-WOPI-Lock': lockId}; await wopiUtils.fillStandardHeaders(ctx, headers, uri, access_token); ctx.logger.debug('wopi Unlock request uri=%s headers=%j', uri, headers); //isInJwtToken is true because it passed checkIpFilter for wopi const isInJwtToken = true; const postRes = await utils.postRequestPromise( ctx, uri, undefined, undefined, undefined, tenCallbackRequestTimeout, undefined, isInJwtToken, headers ); ctx.logger.debug('wopi Unlock response headers=%j', postRes.response.headers); } else { ctx.logger.info('wopi SupportsLocks = false'); } res = true; } catch (err) { ctx.logger.error('wopi error Unlock:%s', err.stack); } finally { ctx.logger.info('wopi Unlock end'); } return res; } function numberToBase64(val) { // Convert to hexadecimal let hexString = val.toString(16); //Ensure the hexadecimal string has an even length if (hexString.length % 2 !== 0) { hexString = '0' + hexString; } //Convert the hexadecimal string to a buffer const buffer = Buffer.from(hexString, 'hex'); return buffer.toString('base64'); } function checkIpFilter(ctx, uri) { return co(function* () { const urlParsed = new URL(uri); const filterStatus = yield* utils.checkHostFilter(ctx, urlParsed.hostname); if (0 !== filterStatus) { ctx.logger.warn('wopi checkIpFilter error: url = %s', uri); } return filterStatus; }); } function getWopiParams(lockId, fileInfo, wopiSrc, access_token, access_token_ttl) { const commonInfo = {lockId, fileInfo}; const userAuth = { wopiSrc, access_token, access_token_ttl, userSessionId: null, mode: null }; return {commonInfo, userAuth, LastModifiedTime: null}; } async function dummyCheckFileInfo(req, res) { //static output for performance reason res.json({ BaseFileName: 'sample.docx', OwnerId: 'userId', Size: 100, //no need to set actual size for test UserId: 'userId', //test ignores UserFriendlyName: 'user', Version: 0, UserCanWrite: true, SupportsGetLock: true, SupportsLocks: true, SupportsUpdate: true }); } async function dummyGetFile(req, res) { const ctx = new operationContext.Context(); ctx.initFromRequest(req); try { await ctx.initTenantCache(); const tenWopiDummySampleFilePath = ctx.getCfg('wopi.dummy.sampleFilePath', cfgWopiDummySampleFilePath); const sampleFileStat = await stat(tenWopiDummySampleFilePath); res.setHeader('Content-Length', sampleFileStat.size); res.setHeader('Content-Type', mime.getType(tenWopiDummySampleFilePath)); await pipeline(createReadStream(tenWopiDummySampleFilePath), res); } catch (err) { if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { //xhr.abort case ctx.logger.debug('dummyGetFile error: %s', err.stack); } else { ctx.logger.error('dummyGetFile error:%s', err.stack); } } finally { if (!res.headersSent) { res.sendStatus(400); } } } function dummyOk(req, res) { res.sendStatus(200); } exports.checkIpFilter = checkIpFilter; exports.discovery = discovery; exports.collaboraCapabilities = collaboraCapabilities; exports.parseWopiCallback = parseWopiCallback; exports.getEditorHtml = getEditorHtml; exports.getConverterHtml = getConverterHtml; exports.putFile = putFile; exports.parsePutFileResponse = parsePutFileResponse; exports.putRelativeFile = putRelativeFile; exports.renameFile = renameFile; exports.refreshFile = refreshFile; exports.lock = lock; exports.unlock = unlock; exports.getWopiUnlockMarker = getWopiUnlockMarker; exports.getWopiModifiedMarker = getWopiModifiedMarker; exports.getFileTypeByInfo = getFileTypeByInfo; exports.isWopiJwtToken = isWopiJwtToken; exports.setIsShutdown = setIsShutdown; exports.dummyCheckFileInfo = dummyCheckFileInfo; exports.dummyGetFile = dummyGetFile; exports.dummyOk = dummyOk;