From 734ba9a55a3badacb897899c438cd29e31e94c30 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Tue, 3 Jun 2025 10:19:38 +0300 Subject: [PATCH] [feature] Move getWopiFileUrl to wopiUtils.js to reduce dependencies in converter.js --- DocService/sources/canvasservice.js | 3 +- DocService/sources/wopiClient.js | 79 ++------------- DocService/sources/wopiUtils.js | 144 ++++++++++++++++++++++++++++ FileConverter/sources/converter.js | 4 +- 4 files changed, 156 insertions(+), 74 deletions(-) create mode 100644 DocService/sources/wopiUtils.js diff --git a/DocService/sources/canvasservice.js b/DocService/sources/canvasservice.js index 36c6b2e4..c5272641 100644 --- a/DocService/sources/canvasservice.js +++ b/DocService/sources/canvasservice.js @@ -44,6 +44,7 @@ var sqlBase = require('./databaseConnectors/baseConnector'); const utilsDocService = require('./utilsDocService'); var docsCoServer = require('./DocsCoServer'); var taskResult = require('./taskresult'); +var wopiUtils = require('./wopiUtils'); var wopiClient = require('./wopiClient'); var logger = require('./../../Common/sources/logger'); var utils = require('./../../Common/sources/utils'); @@ -1697,7 +1698,7 @@ exports.downloadFile = function(req, res) { //editnew case fromTemplate = pathModule.extname(decoded.fileInfo.BaseFileName).substring(1); } else { - ({url, headers} = yield wopiClient.getWopiFileUrl(ctx, decoded.fileInfo, decoded.userAuth)); + ({url, headers} = yield wopiUtils.getWopiFileUrl(ctx, decoded.fileInfo, decoded.userAuth)); let filterStatus = yield wopiClient.checkIpFilter(ctx, url); if (0 === filterStatus) { //todo false? (true because it passed checkIpFilter for wopi) diff --git a/DocService/sources/wopiClient.js b/DocService/sources/wopiClient.js index 2851b62f..48ff487c 100644 --- a/DocService/sources/wopiClient.js +++ b/DocService/sources/wopiClient.js @@ -34,8 +34,6 @@ const path = require('path'); const { pipeline } = require('node:stream/promises'); -const crypto = require('crypto'); -let util = require('util'); const {URL} = require('url'); const co = require('co'); const jwt = require('jsonwebtoken'); @@ -49,7 +47,7 @@ const logger = require('./../../Common/sources/logger'); const utils = require('./../../Common/sources/utils'); const constants = require('./../../Common/sources/constants'); const commonDefines = require('./../../Common/sources/commondefines'); -const formatChecker = require('./../../Common/sources/formatchecker'); +const wopiUtils = require('./wopiUtils'); const operationContext = require('./../../Common/sources/operationContext'); const tenantManager = require('./../../Common/sources/tenantManager'); const sqlBase = require('./databaseConnectors/baseConnector'); @@ -95,8 +93,6 @@ const cfgWopiPrivateKeyOld = config.get('wopi.privateKeyOld'); const cfgWopiHost = config.get('wopi.host'); const cfgWopiDummySampleFilePath = config.get('wopi.dummy.sampleFilePath'); -let cryptoSign = util.promisify(crypto.sign); - let templatesFolderLocalesCache = null; let templatesFolderExtsCache = null; const templateFilesSizeCache = {}; @@ -373,22 +369,7 @@ function getFileTypeByInfo(fileInfo) { fileType = fileInfo.FileExtension ? fileInfo.FileExtension.substr(1) : fileType; return fileType.toLowerCase(); } -async function getWopiFileUrl(ctx, fileInfo, userAuth) { - const tenMaxDownloadBytes = ctx.getCfg('FileConverter.converter.maxDownloadBytes', cfgMaxDownloadBytes); - let url; - let headers = {'X-WOPI-MaxExpectedSize': tenMaxDownloadBytes}; - if (fileInfo?.FileUrl) { - //Requests to the FileUrl can not be signed using proof keys. The FileUrl is used exactly as provided by the host, so it does not necessarily include the access token, which is required to construct the expected proof. - url = fileInfo.FileUrl; - } else if (fileInfo?.TemplateSource) { - url = fileInfo.TemplateSource; - } else if (userAuth) { - url = `${userAuth.wopiSrc}/contents?access_token=${encodeURIComponent(userAuth.access_token)}`; - await fillStandardHeaders(ctx, headers, url, userAuth.access_token); - } - ctx.logger.debug('getWopiFileUrl url=%s; headers=%j', url, headers); - return {url, headers}; -} + function isWopiJwtToken(decoded) { return !!decoded.fileInfo; } @@ -751,7 +732,7 @@ function putFile(ctx, wopiParams, data, dataStream, dataSize, userLastChangeId, let commonInfo = wopiParams.commonInfo; //todo add all the users who contributed changes to the document in this PutFile request to X-WOPI-Editors let headers = {'X-WOPI-Override': 'PUT', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-Editors': userLastChangeId}; - yield fillStandardHeaders(ctx, headers, uri, userAuth.access_token); + 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; @@ -795,7 +776,7 @@ function putRelativeFile(ctx, wopiSrc, access_token, data, dataStream, dataSize, if (isFileConversion) { headers['X-WOPI-FileConversion'] = isFileConversion; } - yield fillStandardHeaders(ctx, headers, uri, access_token); + 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); @@ -837,7 +818,7 @@ function renameFile(ctx, wopiParams, name) { let commonInfo = wopiParams.commonInfo; let headers = {'X-WOPI-Override': 'RENAME_FILE', 'X-WOPI-Lock': commonInfo.lockId, 'X-WOPI-RequestedName': utf7.encode(name)}; - yield fillStandardHeaders(ctx, headers, uri, userAuth.access_token); + yield 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 @@ -918,7 +899,7 @@ function checkFileInfo(ctx, wopiSrc, access_token, opt_sc) { if (opt_sc) { headers['X-WOPI-SessionContext'] = opt_sc; } - yield fillStandardHeaders(ctx, headers, uri, access_token); + 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 let isInJwtToken = true; @@ -953,7 +934,7 @@ function lock(ctx, command, lockId, fileInfo, userAuth) { } let headers = {"X-WOPI-Override": command, "X-WOPI-Lock": lockId}; - yield fillStandardHeaders(ctx, headers, uri, access_token); + 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 let isInJwtToken = true; @@ -992,7 +973,7 @@ async function unlock(ctx, wopiParams) { } let headers = {"X-WOPI-Override": "UNLOCK", "X-WOPI-Lock": lockId}; - await fillStandardHeaders(ctx, headers, uri, access_token); + 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 let isInJwtToken = true; @@ -1009,31 +990,6 @@ async function unlock(ctx, wopiParams) { } return res; } -function generateProofBuffer(url, accessToken, timeStamp) { - const accessTokenBytes = Buffer.from(accessToken, 'utf8'); - const urlBytes = Buffer.from(url.toUpperCase(), 'utf8'); - - let offset = 0; - let buffer = Buffer.alloc(4 + accessTokenBytes.length + 4 + urlBytes.length + 4 + 8); - buffer.writeUInt32BE(accessTokenBytes.length, offset); - offset += 4; - accessTokenBytes.copy(buffer, offset, 0, accessTokenBytes.length); - offset += accessTokenBytes.length; - buffer.writeUInt32BE(urlBytes.length, offset); - offset += 4; - urlBytes.copy(buffer, offset, 0, urlBytes.length); - offset += urlBytes.length; - buffer.writeUInt32BE(8, offset); - offset += 4; - buffer.writeBigUInt64BE(timeStamp, offset); - return buffer; -} - -async function generateProofSign(url, accessToken, timeStamp, privateKey) { - let data = generateProofBuffer(url, accessToken, timeStamp); - let sign = await cryptoSign('RSA-SHA256', data, privateKey); - return sign.toString('base64'); -} function numberToBase64(val) { // Convert to hexadecimal @@ -1047,23 +1003,6 @@ function numberToBase64(val) { return buffer.toString('base64'); } -async function fillStandardHeaders(ctx, headers, url, access_token) { - let timeStamp = utils.getDateTimeTicks(new Date()); - const tenWopiPrivateKey = ctx.getCfg('wopi.privateKey', cfgWopiPrivateKey); - const tenWopiPrivateKeyOld = ctx.getCfg('wopi.privateKeyOld', cfgWopiPrivateKeyOld); - if (tenWopiPrivateKey && tenWopiPrivateKeyOld) { - headers['X-WOPI-Proof'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKey); - headers['X-WOPI-ProofOld'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKeyOld); - } - headers['X-WOPI-TimeStamp'] = timeStamp; - headers['X-WOPI-ClientVersion'] = commonDefines.buildVersion + '.' + commonDefines.buildNumber; - // todo - // headers['X-WOPI-CorrelationId '] = ""; - // headers['X-WOPI-SessionId'] = ""; - //remove redundant header https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers#request-headers - // headers['Authorization'] = `Bearer ${access_token}`; -} - function checkIpFilter(ctx, uri){ return co(function* () { let urlParsed = new URL(uri); @@ -1169,11 +1108,9 @@ exports.renameFile = renameFile; exports.refreshFile = refreshFile; exports.lock = lock; exports.unlock = unlock; -exports.fillStandardHeaders = fillStandardHeaders; exports.getWopiUnlockMarker = getWopiUnlockMarker; exports.getWopiModifiedMarker = getWopiModifiedMarker; exports.getFileTypeByInfo = getFileTypeByInfo; -exports.getWopiFileUrl = getWopiFileUrl; exports.isWopiJwtToken = isWopiJwtToken; exports.setIsShutdown = setIsShutdown; exports.dummyCheckFileInfo = dummyCheckFileInfo; diff --git a/DocService/sources/wopiUtils.js b/DocService/sources/wopiUtils.js new file mode 100644 index 00000000..9a3e57b6 --- /dev/null +++ b/DocService/sources/wopiUtils.js @@ -0,0 +1,144 @@ +/* + * (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 util = require('util'); +const config = require('config'); +const utils = require('./../../Common/sources/utils'); +const commonDefines = require('./../../Common/sources/commondefines'); + +// Configuration constants +const cfgMaxDownloadBytes = config.get('FileConverter.converter.maxDownloadBytes'); +const cfgWopiPrivateKey = config.get('wopi.privateKey'); +const cfgWopiPrivateKeyOld = config.get('wopi.privateKeyOld'); + +const cryptoSign = util.promisify(crypto.sign); + +/** + * Generates a proof buffer for WOPI requests + * + * @param {string} url - The URL to generate proof for + * @param {string} accessToken - The access token + * @param {bigint} timeStamp - The timestamp in ticks + * @returns {Buffer} - The proof buffer + */ +function generateProofBuffer(url, accessToken, timeStamp) { + const accessTokenBytes = Buffer.from(accessToken, 'utf8'); + const urlBytes = Buffer.from(url.toUpperCase(), 'utf8'); + + let offset = 0; + let buffer = Buffer.alloc(4 + accessTokenBytes.length + 4 + urlBytes.length + 4 + 8); + buffer.writeUInt32BE(accessTokenBytes.length, offset); + offset += 4; + accessTokenBytes.copy(buffer, offset, 0, accessTokenBytes.length); + offset += accessTokenBytes.length; + buffer.writeUInt32BE(urlBytes.length, offset); + offset += 4; + urlBytes.copy(buffer, offset, 0, urlBytes.length); + offset += urlBytes.length; + buffer.writeUInt32BE(8, offset); + offset += 4; + buffer.writeBigUInt64BE(timeStamp, offset); + return buffer; +} + +/** + * Generates a proof signature for WOPI requests + * + * @param {string} url - The URL to generate proof for + * @param {string} accessToken - The access token + * @param {bigint} timeStamp - The timestamp in ticks + * @param {string} privateKey - The private key for signing + * @returns {string} - The base64-encoded signature + */ +async function generateProofSign(url, accessToken, timeStamp, privateKey) { + let data = generateProofBuffer(url, accessToken, timeStamp); + let sign = await cryptoSign('RSA-SHA256', data, privateKey); + return sign.toString('base64'); +} + +/** + * Fills standard WOPI headers for requests + * + * @param {Object} ctx - The operation context + * @param {Object} headers - The headers object to fill + * @param {string} url - The URL for the request + * @param {string} access_token - The access token + */ +async function fillStandardHeaders(ctx, headers, url, access_token) { + let timeStamp = utils.getDateTimeTicks(new Date()); + const tenWopiPrivateKey = ctx.getCfg('wopi.privateKey', cfgWopiPrivateKey); + const tenWopiPrivateKeyOld = ctx.getCfg('wopi.privateKeyOld', cfgWopiPrivateKeyOld); + if (tenWopiPrivateKey && tenWopiPrivateKeyOld) { + headers['X-WOPI-Proof'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKey); + headers['X-WOPI-ProofOld'] = await generateProofSign(url, access_token, timeStamp, tenWopiPrivateKeyOld); + } + headers['X-WOPI-TimeStamp'] = timeStamp; + headers['X-WOPI-ClientVersion'] = commonDefines.buildVersion + '.' + commonDefines.buildNumber; + // todo + // headers['X-WOPI-CorrelationId '] = ""; + // headers['X-WOPI-SessionId'] = ""; + //remove redundant header https://learn.microsoft.com/en-us/microsoft-365/cloud-storage-partner-program/rest/common-headers#request-headers + // headers['Authorization'] = `Bearer ${access_token}`; +} + +/** + * Gets a WOPI file URL with appropriate headers + * + * @param {Object} ctx - The operation context + * @param {Object} fileInfo - Information about the file + * @param {Object} userAuth - User authentication details + * @returns {Object} - Object containing URL and headers + */ +async function getWopiFileUrl(ctx, fileInfo, userAuth) { + const tenMaxDownloadBytes = ctx.getCfg('FileConverter.converter.maxDownloadBytes', cfgMaxDownloadBytes); + let url; + let headers = {'X-WOPI-MaxExpectedSize': tenMaxDownloadBytes}; + if (fileInfo?.FileUrl) { + //Requests to the FileUrl can not be signed using proof keys. The FileUrl is used exactly as provided by the host, so it does not necessarily include the access token, which is required to construct the expected proof. + url = fileInfo.FileUrl; + } else if (fileInfo?.TemplateSource) { + url = fileInfo.TemplateSource; + } else if (userAuth) { + url = `${userAuth.wopiSrc}/contents?access_token=${encodeURIComponent(userAuth.access_token)}`; + await fillStandardHeaders(ctx, headers, url, userAuth.access_token); + } + ctx.logger.debug('getWopiFileUrl url=%s; headers=%j', url, headers); + return {url, headers}; +} + +module.exports = { + getWopiFileUrl, + fillStandardHeaders +}; diff --git a/FileConverter/sources/converter.js b/FileConverter/sources/converter.js index 0857d363..642a1447 100644 --- a/FileConverter/sources/converter.js +++ b/FileConverter/sources/converter.js @@ -47,7 +47,7 @@ var storage = require('./../../Common/sources/storage/storage-base'); var utils = require('./../../Common/sources/utils'); var constants = require('./../../Common/sources/constants'); var baseConnector = require('../../DocService/sources/databaseConnectors/baseConnector'); -const wopiClient = require('./../../DocService/sources/wopiClient'); +const wopiUtils = require('./../../DocService/sources/wopiUtils'); const taskResult = require('./../../DocService/sources/taskresult'); var statsDClient = require('./../../Common/sources/statsdclient'); var queueService = require('./../../Common/sources/taskqueueRabbitMQ'); @@ -1074,7 +1074,7 @@ function* ExecuteTask(ctx, task) { isInJwtToken = true; let fileInfo = wopiParams.commonInfo?.fileInfo; fileSize = fileInfo?.Size; - ({url, headers} = yield wopiClient.getWopiFileUrl(ctx, fileInfo, wopiParams.userAuth)); + ({url, headers} = yield wopiUtils.getWopiFileUrl(ctx, fileInfo, wopiParams.userAuth)); } if (undefined === fileSize || fileSize > 0) { error = yield* downloadFile(ctx, url, dataConvert.fileFrom, withAuthorization, isInJwtToken, headers);