Merge pull request 'fix/admin-panel-bugs-3' (#73) from fix/admin-panel-bugs-3 into release/v9.1.0

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/server/pulls/73
This commit is contained in:
Oleg Korshul
2025-10-09 01:21:41 +00:00
13 changed files with 102 additions and 35 deletions

View File

@ -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

View File

@ -257,14 +257,6 @@
}
}
},
"connectionConfiguration": {
"type": "object",
"additionalProperties": false,
"properties": {
"disableFileAccess": {"type": "boolean"},
"disableUrlAccess": {"type": "boolean"}
}
},
"contactDefaults": {
"type": "object",
"additionalProperties": false,

View File

@ -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
};

View File

@ -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);
}

View File

@ -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;

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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<string>} 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}`);

View File

@ -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);

View File

@ -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',