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

Reviewed-on: https://git.onlyoffice.com/ONLYOFFICE/server/pulls/76
This commit is contained in:
Oleg Korshul
2025-10-10 19:45:35 +00:00
13 changed files with 256 additions and 27 deletions

View File

@ -0,0 +1,87 @@
import styles from './Note.module.scss';
/**
* Note component for displaying different types of messages
* @param {Object} props - Component properties
* @param {('note'|'warning'|'tip'|'important')} props.type - Type of note to display
* @param {React.ReactNode} props.children - Content to display in the note
* @returns {JSX.Element} Note component
*/
function Note({type = 'note', children}) {
const typeConfig = {
note: {
title: 'Note',
className: styles.note,
icon: (
<svg className={styles.icon} width='24' height='24' viewBox='0 0 24 24' fill='none'>
<circle cx='12' cy='12' r='10' stroke='#FF6F3D' strokeWidth='2' />
<path d='M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z' fill='#FF6F3D' />
<path
d='M12 10C12.5523 10 13 10.4477 13 11V17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17V11C11 10.4477 11.4477 10 12 10Z'
fill='#FF6F3D'
/>
</svg>
)
},
warning: {
title: 'Warning',
className: styles.warning,
icon: (
<svg className={styles.icon} width='24' height='24' viewBox='0 0 24 24' fill='none'>
<path
d='M10.5 4C11.1667 3 12.8333 3 13.5 4L21 17C21.6667 18 21 19.5 19.6667 19.5H5.33333C4 19.5 3.33333 18 4 17L10.5 4Z'
stroke='#CB0000'
strokeWidth='2'
/>
<path
d='M12 8C12.5523 8 13 8.44772 13 9V13C13 13.5523 12.5523 14 12 14C11.4477 14 11 13.5523 11 13V9C11 8.44772 11.4477 8 12 8Z'
fill='#CB0000'
/>
<circle cx='12' cy='16.5' r='1' fill='#CB0000' />
</svg>
)
},
tip: {
title: 'Tip',
className: styles.tip,
icon: (
<svg className={styles.icon} width='24' height='24' viewBox='0 0 24 24' fill='none'>
<circle cx='12' cy='12' r='10' stroke='#007B14' strokeWidth='2' />
<path d='M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z' fill='#007B14' />
<path
d='M12 10C12.5523 10 13 10.4477 13 11V17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17V11C11 10.4477 11.4477 10 12 10Z'
fill='#007B14'
/>
</svg>
)
},
important: {
title: 'Important',
className: styles.important,
icon: (
<svg className={styles.icon} width='24' height='24' viewBox='0 0 24 24' fill='none'>
<rect x='2' y='2' width='20' height='20' rx='2' stroke='#262BA5' strokeWidth='2' />
<path d='M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z' fill='#262BA5' />
<path
d='M12 10C12.5523 10 13 10.4477 13 11V17C13 17.5523 12.5523 18 12 18C11.4477 18 11 17.5523 11 17V11C11 10.4477 11.4477 10 12 10Z'
fill='#262BA5'
/>
</svg>
)
}
};
const config = typeConfig[type] || typeConfig.note;
return (
<div className={`${styles.noteContainer} ${config.className}`}>
<div className={styles.header}>
{config.icon}
<span className={styles.title}>{config.title}</span>
</div>
<div className={styles.content}>{children}</div>
</div>
);
}
export default Note;

View File

@ -0,0 +1,101 @@
/* Base note container */
.noteContainer {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px;
gap: 8px;
background: #f9f9f9;
border-radius: 2px;
flex: none;
align-self: stretch;
flex-grow: 0;
}
/* Header with icon and title */
.header {
display: flex;
flex-direction: row;
align-items: center;
padding: 0;
gap: 8px;
flex: none;
order: 0;
flex-grow: 0;
}
/* Icon styling */
.icon {
width: 24px;
height: 24px;
flex: none;
order: 0;
flex-grow: 0;
}
/* Title text */
.title {
font-family: 'Open Sans', sans-serif;
font-style: normal;
font-weight: 700;
font-size: 16px;
line-height: 130%;
display: flex;
align-items: center;
letter-spacing: -0.02em;
flex: none;
order: 1;
flex-grow: 0;
}
/* Content text */
.content {
font-family: 'Open Sans', sans-serif;
font-style: normal;
font-weight: 400;
font-size: 14px;
line-height: 150%;
color: #333333;
flex: none;
order: 1;
align-self: stretch;
flex-grow: 0;
}
/* Note variant - Orange */
.note {
border-left: 2px solid #ff6f3d;
.title {
color: #ff6f3d;
}
}
/* Warning variant - Red */
.warning {
border-left: 2px solid #cb0000;
border-radius: 2px 0 0 2px;
.title {
color: #cb0000;
}
}
/* Tip variant - Green */
.tip {
border-left: 2px solid #007b14;
.title {
color: #007b14;
}
}
/* Important variant - Blue */
.important {
border-left: 2px solid #262ba5;
.title {
color: #262ba5;
}
}

View File

@ -1,5 +1,4 @@
.forgottenPage {
max-width: 1200px;
margin: 0 auto;
.pageHeader {

View File

@ -11,6 +11,7 @@ import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch';
import Input from '../../components/Input/Input';
import Checkbox from '../../components/Checkbox/Checkbox';
import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton';
import Note from '../../components/Note/Note';
import styles from './WOPISettings.module.scss';
function WOPISettings() {
@ -148,6 +149,9 @@ function WOPISettings() {
<div className={styles.sectionDescription}>
Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated.
</div>
<div className={styles.noteWrapper}>
<Note type='warning'>Do not rotate keys more than once per 24 hours; storage may not refresh in time and authentication can fail.</Note>
</div>
<div className={styles.formRow}>
<Input
label='Current Public Key'

View File

@ -37,6 +37,10 @@
line-height: 1.5;
}
.noteWrapper {
margin-bottom: 20px;
}
.formRow {
margin-bottom: 16px;
display: flex;

View File

@ -7,6 +7,7 @@ const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfi
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
const {validateJWT} = require('../../middleware/auth');
const cookieParser = require('cookie-parser');
const utils = require('../../../../../Common/sources/utils');
const router = express.Router();
router.use(cookieParser());
@ -66,10 +67,10 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => {
} else {
await runtimeConfigManager.saveConfig(ctx, validationResult.value);
}
const filteredConfig = getScopedConfig(ctx);
const newConfig = await runtimeConfigManager.getConfig(ctx);
res.status(200).json(newConfig);
res.status(200).json(utils.deepMergeObjects(filteredConfig, newConfig));
} catch (error) {
ctx.logger.error('Configuration save error: %s', error.stack);
res.status(500).json({error: 'Internal server error', details: error.message});

View File

@ -32,6 +32,7 @@
'use strict';
const config = require('config');
const express = require('express');
const crypto = require('crypto');
const utils = require('../../../../../Common/sources/utils');
@ -41,6 +42,11 @@ const {validateJWT} = require('../../middleware/auth');
const {getConfig} = require('../../../../../Common/sources/runtimeConfigManager');
const cookieParser = require('cookie-parser');
const cfgWopiPublicKey = config.get('wopi.publicKey');
const cfgWopiModulus = config.get('wopi.modulus');
const cfgWopiPrivateKey = config.get('wopi.privateKey');
const cfgWopiExponent = config.get('wopi.exponent');
const router = express.Router();
router.use(cookieParser());
@ -154,21 +160,26 @@ function generateWopiKeys() {
router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => {
const ctx = req.ctx;
try {
ctx.initTenantCache();
ctx.logger.info('WOPI key rotation start');
const currentConfig = await getConfig(ctx);
const wopiConfig = currentConfig.wopi || {};
const newWopiConfig = generateWopiKeys();
const hasEmptyKeys = !wopiConfig.publicKey && !wopiConfig.modulus && !wopiConfig.privateKey;
const publicKey = ctx.getCfg('wopi.publicKey', cfgWopiPublicKey);
const modulus = ctx.getCfg('wopi.modulus', cfgWopiModulus);
const privateKey = ctx.getCfg('wopi.privateKey', cfgWopiPrivateKey);
const exponent = ctx.getCfg('wopi.exponent', cfgWopiExponent);
const hasEmptyKeys = !(publicKey && modulus && privateKey && exponent);
const configUpdate = {
wopi: {
publicKeyOld: hasEmptyKeys ? newWopiConfig.publicKey : wopiConfig.publicKey,
modulusOld: hasEmptyKeys ? newWopiConfig.modulus : wopiConfig.modulus,
exponentOld: hasEmptyKeys ? newWopiConfig.exponent : wopiConfig.exponent,
privateKeyOld: hasEmptyKeys ? newWopiConfig.privateKey : wopiConfig.privateKey,
publicKeyOld: hasEmptyKeys ? newWopiConfig.publicKey : publicKey,
modulusOld: hasEmptyKeys ? newWopiConfig.modulus : modulus,
exponentOld: hasEmptyKeys ? newWopiConfig.exponent : exponent,
privateKeyOld: hasEmptyKeys ? newWopiConfig.privateKey : privateKey,
publicKey: newWopiConfig.publicKey,
modulus: newWopiConfig.modulus,
exponent: newWopiConfig.exponent,

View File

@ -126,8 +126,9 @@ function disableCache(req, res, next) {
app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, disableCache, configRouter);
app.use('/api/v1/admin/wopi', corsWithCredentials, utils.checkClientIp, disableCache, wopiRouter);
app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, disableCache, adminpanelRouter);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, disableCache, infoRouter.licenseInfo);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, disableCache, async (req, res) => {
await infoRouter.licenseInfo(req, res);
});
// Serve AdminPanel client build as static assets
const clientBuildPath = path.resolve('client/build');
app.use('/', express.static(clientBuildPath));

View File

@ -38,6 +38,8 @@ const mailService = require('./mailService');
const cfgEditorDataStorage = config.get('services.CoAuthoring.server.editorDataStorage');
const cfgEditorStatStorage = config.get('services.CoAuthoring.server.editorStatStorage');
const cfgSmtpServerConfiguration = config.get('email.smtpServerConfiguration');
const cfgContactDefaults = config.get('email.contactDefaults');
const editorStatStorage = require('./../../DocService/sources/' + (cfgEditorStatStorage || cfgEditorDataStorage));
const editorStat = editorStatStorage.EditorStat ? new editorStatStorage.EditorStat() : new editorStatStorage();
@ -57,11 +59,11 @@ class MailTransport extends TransportInterface {
constructor(ctx) {
super();
const mailServerConfig = ctx.getCfg('email.smtpServerConfiguration');
const mailServerConfig = ctx.getCfg('email.smtpServerConfiguration', cfgSmtpServerConfiguration);
this.host = mailServerConfig.host;
this.port = mailServerConfig.port;
this.auth = mailServerConfig.auth;
const cfgMailMessageDefaults = ctx.getCfg('email.contactDefaults');
const cfgMailMessageDefaults = ctx.getCfg('email.contactDefaults', cfgContactDefaults);
mailService.createTransporter(ctx, this.host, this.port, this.auth, cfgMailMessageDefaults);
}

View File

@ -4695,6 +4695,15 @@ exports.shutdown = function (req, res) {
}
});
};
/**
* Get active connections array
* @returns {Array} Active connections
*/
function getConnections() {
return connections;
}
exports.getConnections = getConnections;
exports.getEditorConnectionsCount = function (req, res) {
const ctx = new operationContext.Context();
let count = 0;

View File

@ -353,10 +353,13 @@ async function getPluginSettingsForInterface(ctx) {
pluginSettings = undefined;
}
}
//remove keys from providers
if (pluginSettings && pluginSettings.providers) {
//remove keys from providers - create deep copy to avoid modifying cached config
if (pluginSettings?.providers) {
for (const key in pluginSettings.providers) {
pluginSettings.providers[key].key = '';
if (pluginSettings.providers[key]?.key) {
pluginSettings.providers[key] = JSON.parse(JSON.stringify(pluginSettings.providers[key]));
pluginSettings.providers[key].key = '';
}
}
}
return pluginSettings;

View File

@ -54,8 +54,9 @@ function getLicenseNowUtc() {
* License info endpoint handler
* @param {import('express').Request} req Express request
* @param {import('express').Response} res Express response
* @param {Function} getConnections Function to get active connections
*/
async function licenseInfo(req, res) {
async function licenseInfo(req, res, getConnections = null) {
let isError = false;
const serverDate = new Date();
// Security risk of high-precision time
@ -174,7 +175,8 @@ async function licenseInfo(req, res) {
const nowUTC = getLicenseNowUtc();
let execRes;
execRes = await editorStat.getPresenceUniqueUser(ctx, nowUTC);
output.quota.edit.connectionsCount = await editorStat.getEditorConnectionsCount(ctx, {});
const connections = getConnections ? getConnections() : null;
output.quota.edit.connectionsCount = await editorStat.getEditorConnectionsCount(ctx, connections);
output.quota.edit.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
@ -183,7 +185,7 @@ async function licenseInfo(req, res) {
});
execRes = await editorStat.getPresenceUniqueViewUser(ctx, nowUTC);
output.quota.view.connectionsCount = await editorStat.getLiveViewerConnectionsCount(ctx, {});
output.quota.view.connectionsCount = await editorStat.getLiveViewerConnectionsCount(ctx, connections);
output.quota.view.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
@ -218,24 +220,29 @@ async function licenseInfo(req, res) {
isError = true;
ctx.logger.error('licenseInfo error %s', err.stack);
} finally {
if (!isError) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(output));
} else {
res.sendStatus(400);
if (!res.headersSent) {
if (!isError) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(output));
} else {
res.sendStatus(400);
}
}
}
}
/**
* Create shared Info router
* @param {Function} getConnections Optional function to get active connections
* @returns {import('express').Router} Router instance
*/
function createInfoRouter() {
function createInfoRouter(getConnections = null) {
const router = express.Router();
// License info endpoint with CORS and client IP check
router.get('/info.json', cors(), utils.checkClientIp, licenseInfo);
router.get('/info.json', cors(), utils.checkClientIp, async (req, res) => {
await licenseInfo(req, res, getConnections);
});
return router;
}

View File

@ -295,7 +295,7 @@ docsCoServer.install(server, app, () => {
converterService.builder(req, res);
});
// Shared Info router (provides /info.json)
app.use('/info', infoRouter());
app.use('/info', infoRouter(docsCoServer.getConnections));
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);