diff --git a/.prettierignore b/.prettierignore index 50a52184..a385062b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,7 +5,8 @@ coverage .next out AdminPanel/client/src/pages/AiIntegration/ai/** -AdminPanel/client/src/pages/AiIntegration/js/** +AdminPanel/client/src/pages/AiIntegration/js/plugins.js +AdminPanel/client/src/pages/AiIntegration/js/plugins-ui.js *.min.js *.min.css package-lock.json diff --git a/Common/config/schemas/config.schema.json b/Common/config/schemas/config.schema.json index fd1295db..a132231c 100644 --- a/Common/config/schemas/config.schema.json +++ b/Common/config/schemas/config.schema.json @@ -257,14 +257,6 @@ } } }, - "connectionConfiguration": { - "type": "object", - "additionalProperties": false, - "properties": { - "disableFileAccess": {"type": "boolean"}, - "disableUrlAccess": {"type": "boolean"} - } - }, "contactDefaults": { "type": "object", "additionalProperties": false, diff --git a/Common/sources/moduleReloader.js b/Common/sources/moduleReloader.js index 4426cc35..584f7382 100644 --- a/Common/sources/moduleReloader.js +++ b/Common/sources/moduleReloader.js @@ -53,6 +53,15 @@ function reloadNpmModule(moduleName) { // Backup original NODE_CONFIG to avoid growing environment const prevNodeConfig = process.env.NODE_CONFIG; let nodeConfigOverridden = false; +let baseConfigSnapshot = null; + +/** + * Returns the base configuration as plain object before runtime configuration is applied + * @returns {Object} Base configuration object + */ +function getBaseConfig() { + return baseConfigSnapshot; +} /** * Requires config module with runtime configuration support. @@ -64,6 +73,9 @@ function requireConfigWithRuntime(opt_additionalConfig) { let config = require('config'); try { + // Save base config before reloading with runtime modifications + baseConfigSnapshot = config.util.toObject(); + const configFilePath = config.get('runtimeConfig.filePath'); if (configFilePath) { const configData = fs.readFileSync(configFilePath, 'utf8'); @@ -105,6 +117,7 @@ function finalizeConfigWithRuntime() { module.exports = { reloadNpmModule, + getBaseConfig, requireConfigWithRuntime, finalizeConfigWithRuntime }; diff --git a/Common/sources/notificationService.js b/Common/sources/notificationService.js index 63e30321..67541e9d 100644 --- a/Common/sources/notificationService.js +++ b/Common/sources/notificationService.js @@ -36,8 +36,6 @@ const ms = require('ms'); const mailService = require('./mailService'); -const cfgMailServer = config.util.cloneDeep(config.get('email.smtpServerConfiguration')); -const cfgMailMessageDefaults = config.util.cloneDeep(config.get('email.contactDefaults')); const cfgEditorDataStorage = config.get('services.CoAuthoring.server.editorDataStorage'); const cfgEditorStatStorage = config.get('services.CoAuthoring.server.editorStatStorage'); const editorStatStorage = require('./../../DocService/sources/' + (cfgEditorStatStorage || cfgEditorDataStorage)); @@ -56,13 +54,15 @@ class TransportInterface { } class MailTransport extends TransportInterface { - host = cfgMailServer.host; - port = cfgMailServer.port; - auth = cfgMailServer.auth; - constructor(ctx) { super(); + const mailServerConfig = ctx.getCfg('email.smtpServerConfiguration'); + this.host = mailServerConfig.host; + this.port = mailServerConfig.port; + this.auth = mailServerConfig.auth; + const cfgMailMessageDefaults = ctx.getCfg('email.contactDefaults'); + mailService.createTransporter(ctx, this.host, this.port, this.auth, cfgMailMessageDefaults); } diff --git a/Common/sources/operationContext.js b/Common/sources/operationContext.js index a0d31192..6acb840b 100644 --- a/Common/sources/operationContext.js +++ b/Common/sources/operationContext.js @@ -32,12 +32,14 @@ 'use strict'; -const config = require('config'); const utils = require('./utils'); const logger = require('./logger'); const constants = require('./constants'); const tenantManager = require('./tenantManager'); const runtimeConfigManager = require('./runtimeConfigManager'); +const moduleReloader = require('./moduleReloader'); + +let configCache = null; function Context() { this.logger = logger.getLogger('nodeJS'); @@ -99,12 +101,27 @@ Context.prototype.initFromPubSub = function (data) { this.init(ctx.tenant, ctx.docId, ctx.userId, ctx.shardKey, ctx.wopiSrc, ctx.userSessionId); }; Context.prototype.initTenantCache = async function () { - const runtimeConfig = await runtimeConfigManager.getConfig(this); - const tenantConfig = await tenantManager.getTenantConfig(this); - this.config = utils.deepMergeObjects(config.util.toObject(), runtimeConfig, tenantConfig); + if (!configCache) { + configCache = Object.create(null); + } + this.config = configCache[this.tenant]; + if (!this.config) { + const runtimeConfig = await runtimeConfigManager.getConfig(this); + const tenantConfig = await tenantManager.getTenantConfig(this); + this.config = utils.deepMergeObjects({}, moduleReloader.getBaseConfig(), runtimeConfig, tenantConfig); + configCache[this.tenant] = this.config; + } //todo license and secret }; +Context.prototype.cleanRuntimeConfigCache = function () { + configCache = null; +}; +Context.prototype.cleanTenantConfigCache = function (tenant) { + if (configCache) { + configCache[tenant] = null; + } +}; Context.prototype.setTenant = function (tenant) { this.tenant = tenant; @@ -151,7 +168,7 @@ Context.prototype.getCfg = function (property, defaultValue) { * @returns {object} The merged configuration object */ Context.prototype.getFullCfg = function () { - return utils.deepMergeObjects(config.util.toObject(), this.config); + return utils.deepMergeObjects({}, moduleReloader.getBaseConfig(), this.config); }; exports.Context = Context; diff --git a/Common/sources/runtimeConfigManager.js b/Common/sources/runtimeConfigManager.js index b33f6ba7..9dc917f6 100644 --- a/Common/sources/runtimeConfigManager.js +++ b/Common/sources/runtimeConfigManager.js @@ -146,6 +146,8 @@ function handleConfigFileChange(eventTypeOrCurrent, filenameOrPrevious) { reloadTimer = null; nodeCache.del(configFileName); operationContext.global.logger.info(`handleConfigFileChange reloading config: ${configFileName}`); + + operationContext.global.cleanRuntimeConfigCache(); getConfig(operationContext.global) .then(config => { logger.configureLogger(config?.log?.options); diff --git a/Common/sources/tenantManager.js b/Common/sources/tenantManager.js index a527d2e3..7886aac8 100644 --- a/Common/sources/tenantManager.js +++ b/Common/sources/tenantManager.js @@ -116,6 +116,7 @@ async function getTenantConfig(ctx) { } catch (e) { ctx.logger.debug('getTenantConfig error: %s', e.stack); } finally { + ctx.cleanTenantConfigCache(ctx.tenant); nodeCache.set(configPath, res); } } @@ -135,6 +136,8 @@ async function setTenantConfig(ctx, config) { const tenantPath = utils.removeIllegalCharacters(ctx.tenant); const configPath = path.join(cfgTenantsBaseDir, tenantPath, cfgTenantsFilenameConfig); await writeFile(configPath, JSON.stringify(newConfig, null, 2), 'utf8'); + + ctx.cleanTenantConfigCache(ctx.tenant); nodeCache.set(configPath, newConfig); } return newConfig; @@ -151,6 +154,8 @@ async function replaceTenantConfig(ctx, config) { const tenantPath = utils.removeIllegalCharacters(ctx.tenant); const configPath = path.join(cfgTenantsBaseDir, tenantPath, cfgTenantsFilenameConfig); await writeFile(configPath, JSON.stringify(config, null, 2), 'utf8'); + + ctx.cleanTenantConfigCache(ctx.tenant); nodeCache.set(configPath, config); return config; } diff --git a/Common/sources/utils.js b/Common/sources/utils.js index 3c249e56..1634ab3b 100644 --- a/Common/sources/utils.js +++ b/Common/sources/utils.js @@ -1100,6 +1100,22 @@ function getSecretByElem(secretElem) { return secret; } exports.getSecretByElem = getSecretByElem; +const jwtKeyCache = Object.create(null); +/** + * Gets or creates a cached symmetric key for JWT verification (HS256/HS384/HS512). + * Caches crypto.KeyObject to avoid expensive key creation on every request. + * @param {string} secret - JWT symmetric secret + * @returns {crypto.KeyObject} Cached secret key object + */ +function getJwtHsKey(secret) { + let res = jwtKeyCache[secret]; + if (!res) { + res = jwtKeyCache[secret] = crypto.createSecretKey(Buffer.from(secret, 'utf8')); + } + return res; +} +exports.getJwtHsKey = getJwtHsKey; + function fillJwtForRequest(ctx, payload, secret, opt_inBody) { const tenTokenOutboxAlgorithm = ctx.getCfg('services.CoAuthoring.token.outbox.algorithm', cfgTokenOutboxAlgorithm); const tenTokenOutboxExpires = ctx.getCfg('services.CoAuthoring.token.outbox.expires', cfgTokenOutboxExpires); @@ -1114,7 +1130,7 @@ function fillJwtForRequest(ctx, payload, secret, opt_inBody) { } const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; - return jwt.sign(data, secret, options); + return jwt.sign(data, getJwtHsKey(secret), options); } exports.fillJwtForRequest = fillJwtForRequest; exports.forwarded = forwarded; diff --git a/DocService/sources/DocsCoServer.js b/DocService/sources/DocsCoServer.js index 20987f6c..369f5958 100644 --- a/DocService/sources/DocsCoServer.js +++ b/DocService/sources/DocsCoServer.js @@ -512,7 +512,7 @@ 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, secret, options); + return jwt.sign(payload, utils.getJwtHsKey(secret), options); }); } function needSendChanges(conn) { @@ -1556,6 +1556,7 @@ function createSaveTimer(ctx, docId, opt_userId, opt_userIndex, opt_userLcid, op 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()); @@ -1582,9 +1583,15 @@ function createSaveTimer(ctx, docId, opt_userId, opt_userIndex, opt_userLcid, op yield utils.sleep(c_oAscLockTimeOutDelay); } } else { - //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'); + 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); + } } }); } @@ -1599,7 +1606,7 @@ function checkJwt(ctx, token, type) { ctx.logger.warn('empty secret: token = %s', token); } try { - res.decoded = jwt.verify(token, secret, tenTokenVerifyOptions); + 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); diff --git a/DocService/sources/canvasservice.js b/DocService/sources/canvasservice.js index 5a1815fe..b7573013 100644 --- a/DocService/sources/canvasservice.js +++ b/DocService/sources/canvasservice.js @@ -1891,7 +1891,7 @@ exports.saveFromChanges = function (ctx, docId, statusInfo, optFormat, opt_userI //we do a select, because during the timeout the information could change const selectRes = yield taskResult.select(ctx, docId); const row = selectRes.length > 0 ? selectRes[0] : null; - if (row && row.status == commonDefines.FileStatus.SaveVersion && row.status_info == statusInfo && row.callback) { + if (row && row.status == commonDefines.FileStatus.SaveVersion && row.status_info == statusInfo) { if (null == optFormat) { optFormat = changeFormatByOrigin(ctx, row, constants.AVS_OFFICESTUDIO_FILE_OTHER_OOXML); } @@ -1921,10 +1921,6 @@ exports.saveFromChanges = function (ctx, docId, statusInfo, optFormat, opt_userI yield docsCoServer.editorStat.addShutdown(redisKeyShutdown, docId); } ctx.logger.debug('AddTask saveFromChanges'); - } else if (row && !row.callback) { - ctx.logger.debug('saveFromChanges empty callback: %s', docId); - yield docsCoServer.cleanDocumentOnExitNoChangesPromise(ctx, docId, opt_userId, opt_userIndex, false, true); - //todo restore status } else { if (row) { ctx.logger.debug('saveFromChanges status mismatch: row: %d; %d; expected: %d', row.status, row.status_info, statusInfo); diff --git a/DocService/sources/taskresult.js b/DocService/sources/taskresult.js index 22c75a35..74697ded 100644 --- a/DocService/sources/taskresult.js +++ b/DocService/sources/taskresult.js @@ -134,6 +134,19 @@ function select(ctx, docId) { ); }); } +/** + * Convert task object to SQL update/condition array + * @param {TaskResultData} task - Task data object + * @param {boolean} updateTime - Whether to update last_open_date + * @param {boolean} isMask - Whether this is for WHERE clause (mask mode) + * @param {Array} values - SQL parameter values array + * @param {boolean} setPassword - Whether to set password directly + * @returns {Array} Array of SQL conditions/assignments + * + * Special mask values: + * - Use 'NOT_EMPTY' as field value in mask mode to check for non-empty callback + * - Example: {callback: 'NOT_EMPTY'} generates "callback IS NOT NULL AND callback != ''" + */ function toUpdateArray(task, updateTime, isMask, values, setPassword) { const res = []; if (null != task.status) { @@ -162,6 +175,10 @@ function toUpdateArray(task, updateTime, isMask, values, setPassword) { const sqlParam = addSqlParam(userCallback.toSQLInsert(), values); res.push(`callback=${concatParams('callback', sqlParam)}`); } + // Add callback non-empty check for mask + if (isMask && task.callback === 'NOT_EMPTY') { + res.push(`callback IS NOT NULL AND callback != ''`); + } if (null != task.baseurl) { const sqlParam = addSqlParam(task.baseurl, values); res.push(`baseurl=${sqlParam}`); diff --git a/DocService/sources/wopiClient.js b/DocService/sources/wopiClient.js index d98fa85d..8f663333 100644 --- a/DocService/sources/wopiClient.js +++ b/DocService/sources/wopiClient.js @@ -677,7 +677,7 @@ function getEditorHtml(req, res) { const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); - params.token = jwt.sign(params, secret, options); + params.token = jwt.sign(params, utils.getJwtHsKey(secret), options); } catch (err) { ctx.logger.error('wopiEditor error: %s', err.stack); params.fileInfo = {}; @@ -743,7 +743,7 @@ function getConverterHtml(req, res) { const tokenData = {docId}; const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = yield tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); - const token = jwt.sign(tokenData, secret, options); + const token = jwt.sign(tokenData, utils.getJwtHsKey(secret), options); params.statusHandler += `&token=${encodeURIComponent(token)}`; } @@ -942,7 +942,7 @@ async function refreshFile(ctx, wopiParams, baseUrl) { } const options = {algorithm: tenTokenOutboxAlgorithm, expiresIn: tenTokenOutboxExpires}; const secret = await tenantManager.getTenantSecret(ctx, commonDefines.c_oAscSecretType.Browser); - res.token = jwt.sign(res, secret, options); + res.token = jwt.sign(res, utils.getJwtHsKey(secret), options); } catch (err) { res = undefined; ctx.logger.error('wopi error RefreshFile:%s', err.stack); diff --git a/eslint.config.js b/eslint.config.js index 67024b92..574b3bf4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,7 +19,8 @@ module.exports = [ '.next/', 'out/', 'AdminPanel/client/src/pages/AiIntegration/ai/**', - 'AdminPanel/client/src/pages/AiIntegration/js/**', + 'AdminPanel/client/src/pages/AiIntegration/js/plugins.js', + 'AdminPanel/client/src/pages/AiIntegration/js/plugins-ui.js', '*.min.js', 'package-lock.json', 'npm-shrinkwrap.json',