From 17840e696096612cbb45651b9a3656ebc4217992 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Mon, 15 Sep 2025 00:36:32 +0300 Subject: [PATCH 01/13] [admin] Add generate-keys handler --- .../server/sources/routes/wopi/router.js | 177 ++++++++++++++++++ AdminPanel/server/sources/server.js | 2 + 2 files changed, 179 insertions(+) create mode 100644 AdminPanel/server/sources/routes/wopi/router.js diff --git a/AdminPanel/server/sources/routes/wopi/router.js b/AdminPanel/server/sources/routes/wopi/router.js new file mode 100644 index 00000000..f6efe8cf --- /dev/null +++ b/AdminPanel/server/sources/routes/wopi/router.js @@ -0,0 +1,177 @@ +/* + * (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 express = require('express'); +const crypto = require('crypto'); +const operationContext = require('../../../../../Common/sources/operationContext'); + +const router = express.Router(); + +/** + * Decode a base64url string into a Buffer (RFC 7515) + * @param {string} b64url base64url-encoded string (no padding) + * @returns {Buffer} decoded bytes + */ +function base64UrlToBuffer(b64url) { + const b64 = b64url + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(b64url.length / 4) * 4, '='); + return Buffer.from(b64, 'base64'); +} + +/** + * Convert a big-endian Buffer into a safe JavaScript Number. + * Note: Only for small integers (like RSA public exponent). For large values use BigInt. + * @param {Buffer} buf big-endian buffer (<= 6 bytes recommended) + * @returns {number} numeric value + */ +function bufferBEToNumber(buf) { + let n = 0; + for (const byte of buf.values()) { + n = (n << 8) | byte; + } + return n >>> 0; +} + +/** + * Build a Microsoft PUBLICKEYBLOB from modulus and exponent. + * Layout: + * BLOBHEADER (8 bytes): + * bType=0x06 (PUBLICKEYBLOB), bVersion=0x02, reserved=0x0000, aiKeyAlg=0x0000A400 (CALG_RSA_KEYX) + * RSAPUBKEY (12 bytes): + * magic='RSA1' (0x31415352 LE), bitlen=modBits (LE), pubexp (LE) + * modulus bytes (little-endian) + * @param {Buffer} modulusBE Modulus big-endian, length = keySizeBytes + * @param {number} exponent Public exponent (decimal) + * @returns {Buffer} PUBLICKEYBLOB bytes + */ +function makeMsPublicKeyBlob(modulusBE, exponent) { + const keySizeBytes = modulusBE.length; + const header = Buffer.alloc(8); + // BLOBHEADER + header.writeUInt8(0x06, 0); // PUBLICKEYBLOB + header.writeUInt8(0x02, 1); // version + header.writeUInt16LE(0, 2); // reserved + header.writeUInt32LE(0x0000a400, 4); // CALG_RSA_KEYX + + const rsapub = Buffer.alloc(12); + // 'RSA1' magic LE + rsapub.writeUInt32LE(0x31415352, 0); + rsapub.writeUInt32LE(keySizeBytes * 8, 4); // bit length + rsapub.writeUInt32LE(exponent >>> 0, 8); // exponent (fits in 32-bit) + + // modulus little-endian + const modulusLE = Buffer.from(modulusBE); + modulusLE.reverse(); + + return Buffer.concat([header, rsapub, modulusLE]); +} + +/** + * Generates WOPI private/public key pair and extracts modulus/exponent using Microsoft PUBLICKEYBLOB format. + * Uses JWK export for robust modulus/exponent retrieval across Node versions. + * @returns {Object} WOPI configuration object + */ +function generateWopiKeys() { + // Generate RSA private key (2048 bits) + const {privateKey, publicKey} = crypto.generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem' + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem' + } + }); + + // Extract modulus (n) and exponent (e) via JWK for compatibility + const publicKeyObj = crypto.createPublicKey(publicKey); + /** @type {{kty:string,n:string,e:string}} */ + const jwk = publicKeyObj.export({format: 'jwk'}); + const modulusBE = base64UrlToBuffer(jwk.n); // big-endian bytes + const exponent = bufferBEToNumber(base64UrlToBuffer(jwk.e)); + + // Create MS PUBLICKEYBLOB format (matches bash script behavior) + const publicKeyBlob = makeMsPublicKeyBlob(modulusBE, exponent); + + // Convert modulus to base64 (same as bash script: xxd -r -p | openssl base64 -A) + const modulus = modulusBE.toString('base64'); + + // Convert keys to base64 for storage + const publicKeyBase64 = publicKeyBlob.toString('base64'); + + return { + publicKey: publicKeyBase64, + modulus, + exponent, + privateKey + }; +} + +/** + * Generates WOPI RSA keys and returns key parameters. + */ +router.get('/generate-keys', express.json(), async (req, res) => { + const ctx = new operationContext.Context(); + try { + ctx.initFromRequest(req); + ctx.logger.info('WOPI key generation start'); + + // Generate WOPI keys + const wopiConfig = generateWopiKeys(); + + res.json({ + publicKey: wopiConfig.publicKey, + modulus: wopiConfig.modulus, + exponent: wopiConfig.exponent, + privateKey: wopiConfig.privateKey + }); + } catch (error) { + ctx.logger.error('WOPI key generation error: %s', error.stack); + res.status(500).json({ + success: false, + error: 'Failed to generate WOPI keys', + details: error.message + }); + } finally { + ctx.logger.info('WOPI key generation end'); + } +}); + +// Export router and helper for reuse in tests or other modules +module.exports = router; +module.exports.generateWopiKeys = generateWopiKeys; diff --git a/AdminPanel/server/sources/server.js b/AdminPanel/server/sources/server.js index 4ad8ef60..008d40df 100644 --- a/AdminPanel/server/sources/server.js +++ b/AdminPanel/server/sources/server.js @@ -47,6 +47,7 @@ const infoRouter = require('../../../DocService/sources/routes/info'); const configRouter = require('./routes/config/router'); const adminpanelRouter = require('./routes/adminpanel/router'); +const wopiRouter = require('./routes/wopi/router'); const port = config.get('adminPanel.port'); @@ -81,6 +82,7 @@ const corsWithCredentials = cors({ operationContext.global.logger.warn('AdminPanel server starting...'); app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, configRouter); +app.use('/api/v1/admin/wopi', corsWithCredentials, utils.checkClientIp, wopiRouter); app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, adminpanelRouter); app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, infoRouter.licenseInfo); From bf31469731e1077ce0d69d030af0fcb81b028f04 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Wed, 17 Sep 2025 12:02:51 +0300 Subject: [PATCH 02/13] [feature] Make Save button fixed --- .../FixedSaveButton/FixedSaveButton.js | 16 ++++++++++++ .../FixedSaveButton.module.scss | 26 +++++++++++++++++++ .../src/pages/EmailConfig/EmailConfig.js | 12 ++++----- .../pages/EmailConfig/EmailConfig.module.scss | 4 +++ .../client/src/pages/Expiration/Expiration.js | 12 ++++----- .../pages/Expiration/Expiration.module.scss | 4 +++ .../client/src/pages/FileLimits/FileLimits.js | 12 ++++----- .../pages/FileLimits/FileLimits.module.scss | 4 +++ .../RequestFiltering/RequestFiltering.js | 12 ++++----- .../RequestFiltering.module.scss | 4 +++ .../SecuritySettings/SecuritySettings.js | 12 ++++----- .../SecuritySettings.module.scss | 4 +++ .../src/pages/WOPISettings/WOPISettings.js | 12 ++++----- .../WOPISettings/WOPISettings.module.scss | 4 +++ 14 files changed, 96 insertions(+), 42 deletions(-) create mode 100644 AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js create mode 100644 AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss diff --git a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js new file mode 100644 index 00000000..50228e6a --- /dev/null +++ b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.js @@ -0,0 +1,16 @@ +import SaveButton from '../SaveButton/SaveButton'; +import styles from './FixedSaveButton.module.scss'; + +function FixedSaveButton({onClick, disabled, children = 'Save Changes'}) { + return ( +
+
+ + {children} + +
+
+ ); +} + +export default FixedSaveButton; diff --git a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss new file mode 100644 index 00000000..b79fa550 --- /dev/null +++ b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss @@ -0,0 +1,26 @@ +.fixedSaveContainer { + position: fixed; + bottom: 0; + left: 256px; + right: 0; + background: #ffffff; + border-top: 1px solid #e2e2e2; +// box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); + z-index: 1000; + padding: 16px 0; + width: calc(100% - 256px); +// background-color: #fafafa; +} + +.saveButtonWrapper { +// width: calc(100% - 256px); + margin: 0 auto; + padding: 0 24px; + display: flex; + justify-content: flex-start; +} + +// Add bottom padding to pages to prevent content from being hidden behind fixed button +.pageWithFixedSave { + padding-bottom: 40px; // Adjust based on button height + padding +} diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js index 6c27ae09..28f7495f 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js @@ -9,7 +9,7 @@ import PageDescription from '../../components/PageDescription/PageDescription'; import Tabs from '../../components/Tabs/Tabs'; import Input from '../../components/Input/Input'; import Checkbox from '../../components/Checkbox/Checkbox'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './EmailConfig.module.scss'; const emailConfigTabs = [ @@ -244,7 +244,7 @@ function EmailConfig() { } return ( -
+
Email Configuration Configure SMTP server settings, security options, and default email addresses @@ -252,11 +252,9 @@ function EmailConfig() { {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss index d182c815..34aa205f 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} \ No newline at end of file diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.js b/AdminPanel/client/src/pages/Expiration/Expiration.js index 8202b197..5dab0fdd 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.js +++ b/AdminPanel/client/src/pages/Expiration/Expiration.js @@ -8,7 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import Tabs from '../../components/Tabs/Tabs'; import Input from '../../components/Input/Input'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './Expiration.module.scss'; const expirationTabs = [ @@ -206,7 +206,7 @@ function Expiration() { } return ( -
+
Expiration Settings Configure file cleanup schedules, session timeouts, and garbage collection settings @@ -214,11 +214,9 @@ function Expiration() { {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss index 693c8d1f..91e1fbf3 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss +++ b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} \ No newline at end of file diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.js b/AdminPanel/client/src/pages/FileLimits/FileLimits.js index 3bd2b23e..4fea9da9 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.js +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.js @@ -7,7 +7,7 @@ import {useFieldValidation} from '../../hooks/useFieldValidation'; import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import Input from '../../components/Input/Input'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './FileLimits.module.scss'; function FileLimits() { @@ -171,7 +171,7 @@ function FileLimits() { } return ( -
+
File Size Limits Configure maximum file sizes and download limits for document processing @@ -237,11 +237,9 @@ function FileLimits() {
-
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss index 35a97fb0..de6f9704 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss @@ -43,3 +43,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} \ No newline at end of file diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js index ff1e3f79..c3b77fcd 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js @@ -5,7 +5,7 @@ import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; import Checkbox from '../../components/Checkbox/Checkbox'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import styles from './RequestFiltering.module.scss'; @@ -87,7 +87,7 @@ function RequestFiltering() { } return ( -
+
Request Filtering Configure request filtering settings to control which IP addresses are allowed to make requests to the server. @@ -118,11 +118,9 @@ function RequestFiltering() {
-
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss index 3dad8d43..94c6cf3f 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss @@ -67,3 +67,7 @@ display: flex; justify-content: flex-start; } + +.pageWithFixedSave { + padding-bottom: 40px; +} \ No newline at end of file diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js index 2ebd7eac..521933b1 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js @@ -8,7 +8,7 @@ import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import Tabs from '../../components/Tabs/Tabs'; import AccessRules from '../../components/AccessRules/AccessRules'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './SecuritySettings.module.scss'; const securityTabs = [{key: 'ip-filtering', label: 'IP Filtering'}]; @@ -97,7 +97,7 @@ function SecuritySettings() { } return ( -
+
Security Settings Configure IP filtering, authentication, and security policies @@ -105,11 +105,9 @@ function SecuritySettings() { {renderTabContent()} -
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss index 83f55c5c..a1dffe09 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.module.scss @@ -2,6 +2,10 @@ padding: 0; } +.pageWithFixedSave { + padding-bottom: 40px; +} + .loading { display: flex; justify-content: center; diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index 57a6a2f3..f591fdcf 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -7,7 +7,7 @@ import {useFieldValidation} from '../../hooks/useFieldValidation'; import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch'; -import SaveButton from '../../components/SaveButton/SaveButton'; +import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './WOPISettings.module.scss'; function WOPISettings() { @@ -66,7 +66,7 @@ function WOPISettings() { } return ( -
+
WOPI Settings Configure WOPI (Web Application Open Platform Interface) support for document editing @@ -74,11 +74,9 @@ function WOPISettings() {
-
- - Save Changes - -
+ + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss index 50a5ea70..fd1b531e 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss @@ -2,6 +2,10 @@ padding: 0; } +.pageWithFixedSave { + padding-bottom: 80px; +} + .loading { display: flex; justify-content: center; From 7f7100d9b34ac125084444059da851d3020b45bf Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Wed, 17 Sep 2025 12:04:31 +0300 Subject: [PATCH 03/13] [fix] Config management in Admin Panel --- AdminPanel/client/src/App.css | 10 ++ AdminPanel/client/src/App.js | 21 +++-- AdminPanel/client/src/api/index.js | 9 +- .../components/ConfigLoader/ConfigLoader.js | 91 +++++++++++++++++++ AdminPanel/client/src/components/Menu/Menu.js | 6 +- .../client/src/hooks/useFieldValidation.js | 26 ++---- .../src/pages/EmailConfig/EmailConfig.js | 40 +++++--- .../client/src/pages/Expiration/Expiration.js | 39 +++++--- .../client/src/pages/FileLimits/FileLimits.js | 14 +-- .../RequestFiltering/RequestFiltering.js | 25 +++-- .../SecuritySettings/SecuritySettings.js | 40 ++++---- .../src/pages/WOPISettings/WOPISettings.js | 13 +-- .../client/src/store/slices/configSlice.js | 51 ++++++++--- .../server/sources/routes/config/router.js | 5 +- 14 files changed, 263 insertions(+), 127 deletions(-) create mode 100644 AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js diff --git a/AdminPanel/client/src/App.css b/AdminPanel/client/src/App.css index 574210ba..8b4c671f 100644 --- a/AdminPanel/client/src/App.css +++ b/AdminPanel/client/src/App.css @@ -74,3 +74,13 @@ body::-webkit-scrollbar { body::-webkit-scrollbar-thumb { background: #efefef; } + +/* Spinner animation */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 808cd1be..cc16be57 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -3,6 +3,7 @@ import {Routes, Route, Navigate} from 'react-router-dom'; import './App.css'; import {store} from './store'; import AuthWrapper from './components/AuthWrapper/AuthWrapper'; +import ConfigLoader from './components/ConfigLoader/ConfigLoader'; import Menu from './components/Menu/Menu'; import {menuItems} from './config/menuItems'; @@ -11,15 +12,17 @@ function App() {
-
- -
- - } /> - {menuItems.map(item => ( - } /> - ))} - +
+ +
+ + + } /> + {menuItems.map(item => ( + } /> + ))} + +
diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 8bc4ec27..6264f6d1 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -49,13 +49,8 @@ export const updateConfiguration = async configData => { } } - // Try to parse as JSON, fallback to text if it's not JSON - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return response.json(); - } else { - return response.text(); - } + // Return the new config from the server + return response.json(); }; export const fetchCurrentUser = async () => { diff --git a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js new file mode 100644 index 00000000..cb7b4ff2 --- /dev/null +++ b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js @@ -0,0 +1,91 @@ +import { useEffect } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + selectConfig, + selectConfigLoading, + selectConfigError, + selectSchema, + selectSchemaLoading, + selectSchemaError, + fetchConfig, + fetchSchema +} from '../../store/slices/configSlice'; + +const ConfigLoader = ({ children }) => { + const dispatch = useDispatch(); + const config = useSelector(selectConfig); + const configLoading = useSelector(selectConfigLoading); + const configError = useSelector(selectConfigError); + const schema = useSelector(selectSchema); + const schemaLoading = useSelector(selectSchemaLoading); + const schemaError = useSelector(selectSchemaError); + + const loading = configLoading || schemaLoading; + const error = configError || schemaError; + + useEffect(() => { + // Fetch config if not loaded + if (!config && !configLoading && !configError) { + dispatch(fetchConfig()); + } + + // Fetch schema if not loaded (only once per session) + if (!schema && !schemaLoading && !schemaError) { + dispatch(fetchSchema()); + } + }, [config, configLoading, configError, schema, schemaLoading, schemaError, dispatch]); + + if (loading) { + return ( +
+ + + +

Loading configuration...

+
+ ); + } + + if (error) { + return ( +
+

Error loading configuration: {error}

+ +
+ ); + } + + if (!config || !schema) { + return null; + } + + return children; +}; + +export default ConfigLoader; diff --git a/AdminPanel/client/src/components/Menu/Menu.js b/AdminPanel/client/src/components/Menu/Menu.js index ba5cb97d..630244e2 100644 --- a/AdminPanel/client/src/components/Menu/Menu.js +++ b/AdminPanel/client/src/components/Menu/Menu.js @@ -1,6 +1,7 @@ -import {useSelector} from 'react-redux'; +import {useSelector, useDispatch} from 'react-redux'; import {useLocation, useNavigate} from 'react-router-dom'; import {selectIsAuthenticated} from '../../store/slices/userSlice'; +import {clearConfig} from '../../store/slices/configSlice'; import {logout} from '../../api'; import MenuItem from './MenuItem/MenuItem'; import AppMenuLogo from '../../assets/AppMenuLogo.svg'; @@ -10,6 +11,7 @@ import styles from './Menu.module.scss'; function Menu() { const location = useLocation(); const navigate = useNavigate(); + const dispatch = useDispatch(); const isAuthenticated = useSelector(selectIsAuthenticated); const handleLogout = async () => { @@ -25,6 +27,8 @@ function Menu() { }; const handleMenuItemClick = item => { + // Clear config to force reload when switching pages + dispatch(clearConfig()); navigate(item.path); }; diff --git a/AdminPanel/client/src/hooks/useFieldValidation.js b/AdminPanel/client/src/hooks/useFieldValidation.js index 7df8e78a..43be1765 100644 --- a/AdminPanel/client/src/hooks/useFieldValidation.js +++ b/AdminPanel/client/src/hooks/useFieldValidation.js @@ -1,7 +1,8 @@ import {useState, useEffect, useCallback} from 'react'; +import {useSelector} from 'react-redux'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; -import {fetchConfigurationSchema} from '../api'; +import {selectSchema, selectSchemaLoading, selectSchemaError} from '../store/slices/configSlice'; // Cron expression with 6 space-separated fields (server-compatible) const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/; @@ -12,19 +13,15 @@ const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/; */ export const useFieldValidation = () => { const [validator, setValidator] = useState(null); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); const [fieldErrors, setFieldErrors] = useState({}); - // Initialize validator with schema from backend + const schema = useSelector(selectSchema); + const isLoading = useSelector(selectSchemaLoading); + const error = useSelector(selectSchemaError); + useEffect(() => { - const initializeValidator = async () => { + if (schema && !validator) { try { - setIsLoading(true); - setError(null); - - const schema = await fetchConfigurationSchema(); - // Build AJV validator with custom and standard formats const ajv = new Ajv({allErrors: true, strict: false}); addFormats(ajv); // Add standard formats including email @@ -34,14 +31,9 @@ export const useFieldValidation = () => { setValidator(() => validateFn); } catch (err) { console.error('Failed to initialize field validator:', err); - setError(err.message); - } finally { - setIsLoading(false); } - }; - - initializeValidator(); - }, []); + } + }, [schema, validator]); /** * Validates a single field value against the schema diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js index 28f7495f..bf73beb1 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js @@ -1,6 +1,6 @@ -import {useState, useEffect} from 'react'; +import {useState, useRef} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -21,8 +21,7 @@ const emailConfigTabs = [ function EmailConfig() { const dispatch = useDispatch(); const config = useSelector(selectConfig); - const loading = useSelector(selectConfigLoading); - const {validateField, getFieldError, hasValidationErrors} = useFieldValidation(); + const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation(); const [activeTab, setActiveTab] = useState('smtp-server'); @@ -38,6 +37,8 @@ function EmailConfig() { defaultToEmail: '' }); const [hasChanges, setHasChanges] = useState(false); + const hasInitialized = useRef(false); + // Configuration paths const CONFIG_PATHS = { smtpHost: 'email.smtpServerConfiguration.host', @@ -50,11 +51,9 @@ function EmailConfig() { defaultToEmail: 'email.contactDefaults.to' }; - // Load config data when component mounts - useEffect(() => { - if (!config) { - dispatch(fetchConfig()); - } else { + // Reset state and errors to global config + const resetToGlobalConfig = () => { + if (config) { const settings = {}; Object.keys(CONFIG_PATHS).forEach(key => { const value = getNestedValue(config, CONFIG_PATHS[key], ''); @@ -62,8 +61,24 @@ function EmailConfig() { }); setLocalSettings(settings); setHasChanges(false); + // Clear validation errors for all fields + Object.values(CONFIG_PATHS).forEach(path => { + clearFieldError(path); + }); } - }, [dispatch, config]); + }; + + // Initialize settings from config when component loads (only once) + if (config && !hasInitialized.current) { + resetToGlobalConfig(); + hasInitialized.current = true; + } + + // Handle tab change and reset state + const handleTabChange = (newTab) => { + setActiveTab(newTab); + resetToGlobalConfig(); + }; // Handle field changes const handleFieldChange = (field, value) => { @@ -239,16 +254,13 @@ function EmailConfig() { } }; - if (loading) { - return
Loading email configuration...
; - } return (
Email Configuration Configure SMTP server settings, security options, and default email addresses - + {renderTabContent()} diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.js b/AdminPanel/client/src/pages/Expiration/Expiration.js index 5dab0fdd..cdbfc2f5 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.js +++ b/AdminPanel/client/src/pages/Expiration/Expiration.js @@ -1,6 +1,6 @@ -import {useState, useEffect} from 'react'; +import {useState, useRef} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -19,8 +19,7 @@ const expirationTabs = [ function Expiration() { const dispatch = useDispatch(); const config = useSelector(selectConfig); - const loading = useSelector(selectConfigLoading); - const {validateField, getFieldError, hasValidationErrors} = useFieldValidation(); + const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation(); const [activeTab, setActiveTab] = useState('garbage-collection'); @@ -34,6 +33,7 @@ function Expiration() { sessionabsolute: '' }); const [hasChanges, setHasChanges] = useState(false); + const hasInitialized = useRef(false); // Configuration paths const CONFIG_PATHS = { @@ -45,11 +45,9 @@ function Expiration() { sessionabsolute: 'services.CoAuthoring.expire.sessionabsolute' }; - // Load config data when component mounts - useEffect(() => { - if (!config) { - dispatch(fetchConfig()); - } else { + // Reset state and errors to global config + const resetToGlobalConfig = () => { + if (config) { const settings = {}; Object.keys(CONFIG_PATHS).forEach(key => { const value = getNestedValue(config, CONFIG_PATHS[key], ''); @@ -57,8 +55,24 @@ function Expiration() { }); setLocalSettings(settings); setHasChanges(false); + // Clear validation errors for all fields + Object.values(CONFIG_PATHS).forEach(path => { + clearFieldError(path); + }); } - }, [dispatch, config]); + }; + + // Handle tab change and reset state + const handleTabChange = (newTab) => { + setActiveTab(newTab); + resetToGlobalConfig(); + }; + + // Initialize settings from config when component loads (only once) + if (config && !hasInitialized.current) { + resetToGlobalConfig(); + hasInitialized.current = true; + } // Handle field changes const handleFieldChange = (field, value) => { @@ -201,16 +215,13 @@ function Expiration() { } }; - if (loading) { - return
Loading expiration settings...
; - } return (
Expiration Settings Configure file cleanup schedules, session timeouts, and garbage collection settings - + {renderTabContent()} diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.js b/AdminPanel/client/src/pages/FileLimits/FileLimits.js index 4fea9da9..589d73c6 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.js +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.js @@ -1,6 +1,6 @@ import {useState, useEffect} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -13,7 +13,6 @@ import styles from './FileLimits.module.scss'; function FileLimits() { const dispatch = useDispatch(); const config = useSelector(selectConfig); - const loading = useSelector(selectConfigLoading); const {validateField, getFieldError, hasValidationErrors} = useFieldValidation(); // Local state for form fields @@ -37,13 +36,11 @@ function FileLimits() { // Load config data when component mounts useEffect(() => { - if (!config) { - dispatch(fetchConfig()); - } else { + if (config) { const settings = {}; // Get max download bytes - settings.maxDownloadBytes = getNestedValue(config, CONFIG_PATHS.maxDownloadBytes, ''); + settings.maxDownloadBytes = getNestedValue(config, 'FileConverter.converter.maxDownloadBytes', ''); // Get input limits - need to handle array structure const inputLimits = getNestedValue(config, 'FileConverter.converter.inputLimits', []); @@ -85,7 +82,7 @@ function FileLimits() { let originalValue; if (key === 'maxDownloadBytes') { - originalValue = getNestedValue(config, CONFIG_PATHS.maxDownloadBytes, ''); + originalValue = getNestedValue(config, 'FileConverter.converter.maxDownloadBytes', ''); } else { // Handle input limits array structure for comparison const inputLimits = getNestedValue(config, 'FileConverter.converter.inputLimits', []); @@ -166,9 +163,6 @@ function FileLimits() { setHasChanges(false); }; - if (loading) { - return
Loading file limits settings...
; - } return (
diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js index c3b77fcd..325c1f2f 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js @@ -1,6 +1,6 @@ -import {useState, useEffect} from 'react'; +import {useState, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; -import {fetchConfig, saveConfig} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -12,7 +12,7 @@ import styles from './RequestFiltering.module.scss'; function RequestFiltering() { const dispatch = useDispatch(); - const {config, loading} = useSelector(state => state.config); + const config = useSelector(selectConfig); const {validateField, getFieldError, hasValidationErrors} = useFieldValidation(); const [localSettings, setLocalSettings] = useState({ @@ -27,8 +27,8 @@ function RequestFiltering() { allowMetaIPAddress: 'request-filtering-agent.allowMetaIPAddress' }; - // Load initial values from config - useEffect(() => { + const hasInitialized = useRef(false); + const resetToGlobalConfig = () => { if (config) { const newSettings = {}; Object.keys(CONFIG_PATHS).forEach(key => { @@ -37,12 +37,12 @@ function RequestFiltering() { }); setLocalSettings(newSettings); } - }, [config]); - - // Load config on component mount - useEffect(() => { - dispatch(fetchConfig()); - }, [dispatch]); + }; + // Load initial values from config + if (config && !hasInitialized.current) { + resetToGlobalConfig(); + hasInitialized.current = true; + } // Handle field changes const handleFieldChange = (field, value) => { @@ -82,9 +82,6 @@ function RequestFiltering() { setHasChanges(false); }; - if (loading) { - return
Loading request filtering settings...
; - } return (
diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js index 521933b1..ceb03f87 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js @@ -1,6 +1,6 @@ -import {useState, useEffect} from 'react'; +import {useState, useRef} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {fetchConfig, saveConfig, selectConfig, selectConfigLoading} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -16,30 +16,39 @@ const securityTabs = [{key: 'ip-filtering', label: 'IP Filtering'}]; function SecuritySettings() { const dispatch = useDispatch(); const config = useSelector(selectConfig); - const loading = useSelector(selectConfigLoading); - const {validateField, getFieldError, hasValidationErrors} = useFieldValidation(); + const {validateField, getFieldError, hasValidationErrors, clearFieldError} = useFieldValidation(); const [activeTab, setActiveTab] = useState('ip-filtering'); const [localRules, setLocalRules] = useState([]); const [hasChanges, setHasChanges] = useState(false); - useEffect(() => { - if (!config) { - dispatch(fetchConfig()); - } else { - // Get IP filtering rules from actual config + // Reset state and errors to global config + const resetToGlobalConfig = () => { + if (config) { const ipFilterRules = getNestedValue(config, 'services.CoAuthoring.ipfilter.rules', []); - - // Convert from backend format to UI format const uiRules = ipFilterRules.map(rule => ({ type: rule.allowed ? 'Allow' : 'Deny', value: rule.address })); - setLocalRules(uiRules); setHasChanges(false); + // Clear validation errors + clearFieldError('services.CoAuthoring.ipfilter.rules'); } - }, [dispatch, config]); + }; + + // Handle tab change and reset state + const handleTabChange = (newTab) => { + setActiveTab(newTab); + resetToGlobalConfig(); + }; + + const hasInitialized = useRef(false); + + if (config && !hasInitialized.current) { + resetToGlobalConfig(); + hasInitialized.current = true; + } // Handle rules changes const handleRulesChange = newRules => { @@ -92,16 +101,13 @@ function SecuritySettings() { } }; - if (loading) { - return
Loading security settings...
; - } return (
Security Settings Configure IP filtering, authentication, and security policies - + {renderTabContent()} diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index f591fdcf..4df01274 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -1,6 +1,6 @@ import {useEffect, useState} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {fetchConfig, saveConfig, selectConfig, selectConfigLoading, selectSchema} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; @@ -13,8 +13,6 @@ import styles from './WOPISettings.module.scss'; function WOPISettings() { const dispatch = useDispatch(); const config = useSelector(selectConfig); - const schema = useSelector(selectSchema); - const loading = useSelector(selectConfigLoading); const {validateField, hasValidationErrors} = useFieldValidation(); // Local state for WOPI enable setting @@ -32,12 +30,6 @@ function WOPISettings() { } }, [config, configWopiEnabled]); - useEffect(() => { - if (!config || !schema) { - dispatch(fetchConfig()); - } - }, [dispatch, config, schema]); - const handleWopiEnabledChange = enabled => { setLocalWopiEnabled(enabled); setHasChanges(enabled !== configWopiEnabled); @@ -61,9 +53,6 @@ function WOPISettings() { } }; - if (loading) { - return
Loading WOPI settings...
; - } return (
diff --git a/AdminPanel/client/src/store/slices/configSlice.js b/AdminPanel/client/src/store/slices/configSlice.js index 2b463db1..ef4befd6 100644 --- a/AdminPanel/client/src/store/slices/configSlice.js +++ b/AdminPanel/client/src/store/slices/configSlice.js @@ -3,8 +3,17 @@ import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration} from export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rejectWithValue}) => { try { - const [config, schema] = await Promise.all([fetchConfiguration(), fetchConfigurationSchema()]); - return {config, schema}; + const config = await fetchConfiguration(); + return {config}; + } catch (error) { + return rejectWithValue(error.message); + } +}); + +export const fetchSchema = createAsyncThunk('config/fetchSchema', async (_, {rejectWithValue}) => { + try { + const schema = await fetchConfigurationSchema(); + return {schema}; } catch (error) { return rejectWithValue(error.message); } @@ -12,8 +21,8 @@ export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rej export const saveConfig = createAsyncThunk('config/saveConfig', async (configData, {rejectWithValue}) => { try { - await updateConfiguration(configData); - return configData; + const newConfig = await updateConfiguration(configData); + return newConfig; } catch (error) { return rejectWithValue(error); } @@ -23,8 +32,10 @@ const initialState = { config: null, schema: null, loading: false, + schemaLoading: false, saving: false, - error: null + error: null, + schemaError: null }; const configSlice = createSlice({ @@ -37,6 +48,11 @@ const configSlice = createSlice({ state.config = {...state.config, ...action.payload}; } }, + clearConfig: state => { + state.config = null; + state.loading = false; + state.error = null; + }, clearError: state => { state.error = null; } @@ -51,13 +67,26 @@ const configSlice = createSlice({ .addCase(fetchConfig.fulfilled, (state, action) => { state.loading = false; state.config = action.payload.config; - state.schema = action.payload.schema; state.error = null; }) .addCase(fetchConfig.rejected, (state, action) => { state.loading = false; state.error = action.payload; }) + // Fetch schema cases + .addCase(fetchSchema.pending, state => { + state.schemaLoading = true; + state.schemaError = null; + }) + .addCase(fetchSchema.fulfilled, (state, action) => { + state.schemaLoading = false; + state.schema = action.payload.schema; + state.schemaError = null; + }) + .addCase(fetchSchema.rejected, (state, action) => { + state.schemaLoading = false; + state.schemaError = action.payload; + }) // Save config cases .addCase(saveConfig.pending, state => { state.saving = true; @@ -65,10 +94,8 @@ const configSlice = createSlice({ }) .addCase(saveConfig.fulfilled, (state, action) => { state.saving = false; - // Update the global config with the saved changes - if (state.config) { - state.config = {...state.config, ...action.payload}; - } + // Update the global config with the complete new config from server + state.config = action.payload; state.error = null; }) .addCase(saveConfig.rejected, (state, action) => { @@ -78,13 +105,15 @@ const configSlice = createSlice({ } }); -export const {updateLocalConfig, clearError} = configSlice.actions; +export const {updateLocalConfig, clearConfig, clearError} = configSlice.actions; // Selectors export const selectConfig = state => state.config.config; export const selectSchema = state => state.config.schema; export const selectConfigLoading = state => state.config.loading; +export const selectSchemaLoading = state => state.config.schemaLoading; export const selectConfigSaving = state => state.config.saving; export const selectConfigError = state => state.config.error; +export const selectSchemaError = state => state.config.schemaError; export default configSlice.reducer; diff --git a/AdminPanel/server/sources/routes/config/router.js b/AdminPanel/server/sources/routes/config/router.js index 9de7be70..568a9d64 100644 --- a/AdminPanel/server/sources/routes/config/router.js +++ b/AdminPanel/server/sources/routes/config/router.js @@ -89,7 +89,10 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => { } else { await runtimeConfigManager.saveConfig(ctx, newConfig); } - res.sendStatus(200); + + await ctx.initTenantCache(); + const filteredConfig = getScopedConfig(ctx); + res.status(200).json(filteredConfig); } catch (error) { ctx.logger.error('Configuration save error: %s', error.stack); res.status(500).json({error: 'Internal server error', details: error.message}); From ea5546b9623302911d72800879748c74e9ccedfc Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Wed, 17 Sep 2025 16:49:33 +0300 Subject: [PATCH 04/13] [feature] Rotate wopi keys --- AdminPanel/client/src/api/index.js | 17 ++++ .../src/pages/WOPISettings/WOPISettings.js | 91 ++++++++++++++++--- .../WOPISettings/WOPISettings.module.scss | 40 ++++++++ .../client/src/store/slices/configSlice.js | 24 ++++- AdminPanel/client/src/utils/maskKey.js | 20 ++++ AdminPanel/server/sources/middleware/auth.js | 36 ++++++++ .../server/sources/routes/config/router.js | 21 +---- .../server/sources/routes/wopi/router.js | 59 ++++++++---- Common/config/schemas/config.schema.json | 3 +- 9 files changed, 259 insertions(+), 52 deletions(-) create mode 100644 AdminPanel/client/src/utils/maskKey.js create mode 100644 AdminPanel/server/sources/middleware/auth.js diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 6264f6d1..11e1a3a4 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -104,3 +104,20 @@ export const logout = async () => { return response.json(); }; + +export const rotateWopiKeys = async () => { + const response = await fetch(`${BACKEND_URL}/api/v1/admin/wopi/rotate-keys`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include' + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to rotate WOPI keys'); + } + + return response.json(); +}; \ No newline at end of file diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index 4df01274..c43d9c40 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -1,12 +1,16 @@ -import {useEffect, useState} from 'react'; +import {useState, useRef} from 'react'; import {useSelector, useDispatch} from 'react-redux'; -import {saveConfig, selectConfig} from '../../store/slices/configSlice'; +import {saveConfig, selectConfig, rotateWopiKeysAction} from '../../store/slices/configSlice'; import {getNestedValue} from '../../utils/getNestedValue'; import {mergeNestedObjects} from '../../utils/mergeNestedObjects'; import {useFieldValidation} from '../../hooks/useFieldValidation'; +import {maskKey} from '../../utils/maskKey'; import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch'; +import Input from '../../components/Input/Input'; +import SaveButton from '../../components/SaveButton/SaveButton'; +import Tabs from '../../components/Tabs/Tabs'; import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './WOPISettings.module.scss'; @@ -18,17 +22,37 @@ function WOPISettings() { // Local state for WOPI enable setting const [localWopiEnabled, setLocalWopiEnabled] = useState(false); const [hasChanges, setHasChanges] = useState(false); + const [activeTab, setActiveTab] = useState('settings'); + const hasInitialized = useRef(false); - // Get the actual config value + // Get the actual config values const configWopiEnabled = getNestedValue(config, 'wopi.enable', false); + const wopiPublicKey = getNestedValue(config, 'wopi.publicKey', ''); - // Initialize local state when config loads - useEffect(() => { + // Tabs configuration + const tabs = [ + { key: 'settings', label: 'Settings' }, + { key: 'keys', label: 'Key Management' } + ]; + + const resetToGlobalConfig = () => { if (config) { setLocalWopiEnabled(configWopiEnabled); setHasChanges(false); + validateField('wopi.enable', configWopiEnabled); } - }, [config, configWopiEnabled]); + }; + + // Initialize settings from config when component loads (only once) + if (config && !hasInitialized.current) { + resetToGlobalConfig(); + hasInitialized.current = true; + } + + const handleTabChange = (newTab) => { + setActiveTab(newTab); + resetToGlobalConfig(); + }; const handleWopiEnabledChange = enabled => { setLocalWopiEnabled(enabled); @@ -53,19 +77,60 @@ function WOPISettings() { } }; + const handleRotateKeys = async () => { + await dispatch(rotateWopiKeysAction()).unwrap(); + }; + + + const renderSettingsTab = () => ( +
+ +
+ ); + + const renderKeysTab = () => ( +
+
Key Management
+
+ Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. +
+
+ + + Rotate Keys + +
+
+ ); return ( -
+
WOPI Settings Configure WOPI (Web Application Open Platform Interface) support for document editing -
- -
+ + {activeTab === 'settings' && renderSettingsTab()} + {activeTab === 'keys' && renderKeysTab()} + - - Save Changes - + {activeTab === 'settings' && ( + + Save Changes + + )}
); } diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss index fd1b531e..6e5f7207 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss @@ -2,6 +2,11 @@ padding: 0; } +// Ensure proper spacing with tabs +:global(.tabsContainer) { + margin-bottom: 0; +} + .pageWithFixedSave { padding-bottom: 80px; } @@ -23,6 +28,41 @@ margin-bottom: 32px; } +.sectionTitle { + font-size: 18px; + font-weight: 600; + color: #333333; + margin-bottom: 8px; +} + +.sectionDescription { + font-size: 14px; + color: #666666; + margin-bottom: 24px; + line-height: 1.5; +} + +.formRow { + margin-bottom: 16px; + display: flex; + align-items: flex-end; + gap: 16px; + flex-wrap: wrap; +} + +// Ensure SaveButton aligns properly in form row +:global(.saveButton) { + margin-top: 0; +} + +// Override Input component styles for disabled key display +:global(.input[disabled]) { + background-color: #f8f9fa !important; + color: #666666 !important; + cursor: not-allowed !important; + opacity: 0.8; +} + .actions { display: flex; justify-content: flex-start; diff --git a/AdminPanel/client/src/store/slices/configSlice.js b/AdminPanel/client/src/store/slices/configSlice.js index ef4befd6..a59935fa 100644 --- a/AdminPanel/client/src/store/slices/configSlice.js +++ b/AdminPanel/client/src/store/slices/configSlice.js @@ -1,5 +1,5 @@ import {createSlice, createAsyncThunk} from '@reduxjs/toolkit'; -import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration} from '../../api'; +import {fetchConfiguration, fetchConfigurationSchema, updateConfiguration, rotateWopiKeys} from '../../api'; export const fetchConfig = createAsyncThunk('config/fetchConfig', async (_, {rejectWithValue}) => { try { @@ -28,6 +28,15 @@ export const saveConfig = createAsyncThunk('config/saveConfig', async (configDat } }); +export const rotateWopiKeysAction = createAsyncThunk('config/rotateWopiKeys', async (_, {rejectWithValue}) => { + try { + const newConfig = await rotateWopiKeys(); + return newConfig; + } catch (error) { + return rejectWithValue(error); + } +}); + const initialState = { config: null, schema: null, @@ -101,6 +110,19 @@ const configSlice = createSlice({ .addCase(saveConfig.rejected, (state, action) => { state.saving = false; state.error = action.payload; + }) + .addCase(rotateWopiKeysAction.pending, state => { + state.saving = true; + state.error = null; + }) + .addCase(rotateWopiKeysAction.fulfilled, (state, action) => { + state.saving = false; + state.config = action.payload; + state.error = null; + }) + .addCase(rotateWopiKeysAction.rejected, (state, action) => { + state.saving = false; + state.error = action.payload; }); } }); diff --git a/AdminPanel/client/src/utils/maskKey.js b/AdminPanel/client/src/utils/maskKey.js new file mode 100644 index 00000000..a35822d1 --- /dev/null +++ b/AdminPanel/client/src/utils/maskKey.js @@ -0,0 +1,20 @@ +/** + * Masks a key string to show only first 5 and last 10 characters + * Format: ABCDE...FGHIJKLMNO + * @param {string} key - The key string to mask + * @returns {string} - The masked key string + */ +export const maskKey = (key) => { + if (!key || typeof key !== 'string') { + return ''; + } + + if (key.length <= 15) { + return key; + } + + const firstPart = key.substring(0, 5); + const lastPart = key.substring(key.length - 10); + + return `${firstPart}...${lastPart}`; +}; diff --git a/AdminPanel/server/sources/middleware/auth.js b/AdminPanel/server/sources/middleware/auth.js new file mode 100644 index 00000000..202f2aa5 --- /dev/null +++ b/AdminPanel/server/sources/middleware/auth.js @@ -0,0 +1,36 @@ +'use strict'; + +const config = require('config'); +const jwt = require('jsonwebtoken'); +const operationContext = require('../../../../Common/sources/operationContext'); + +const adminPanelJwtSecret = config.get('adminPanel.jwtSecret'); + +/** + * JWT Authentication Middleware + * Validates JWT token from cookies and initializes operation context + * @param {Object} req - Express request object + * @param {Object} res - Express response object + * @param {Function} next - Express next function + */ +const validateJWT = async (req, res, next) => { + const ctx = new operationContext.Context(); + try { + const token = req.cookies.accessToken; + if (!token) { + return res.status(401).json({error: 'Unauthorized - No token provided'}); + } + const decoded = jwt.verify(token, adminPanelJwtSecret); + ctx.init(decoded.tenant); + await ctx.initTenantCache(); + req.user = decoded; + req.ctx = ctx; + return next(); + } catch { + return res.status(401).json({error: 'Unauthorized'}); + } +}; + +module.exports = { + validateJWT +}; diff --git a/AdminPanel/server/sources/routes/config/router.js b/AdminPanel/server/sources/routes/config/router.js index 568a9d64..3e25a643 100644 --- a/AdminPanel/server/sources/routes/config/router.js +++ b/AdminPanel/server/sources/routes/config/router.js @@ -3,13 +3,11 @@ const config = require('config'); const express = require('express'); const bodyParser = require('body-parser'); const tenantManager = require('../../../../../Common/sources/tenantManager'); -const operationContext = require('../../../../../Common/sources/operationContext'); const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager'); const utils = require('../../../../../Common/sources/utils'); const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service'); -const jwt = require('jsonwebtoken'); +const {validateJWT} = require('../../middleware/auth'); const cookieParser = require('cookie-parser'); -const adminPanelJwtSecret = config.get('adminPanel.jwtSecret'); const router = express.Router(); router.use(cookieParser()); @@ -22,23 +20,6 @@ const rawFileParser = bodyParser.raw({ } }); -const validateJWT = async (req, res, next) => { - const ctx = new operationContext.Context(); - try { - const token = req.cookies.accessToken; - if (!token) { - return res.status(401).json({error: 'Unauthorized - No token provided'}); - } - const decoded = jwt.verify(token, adminPanelJwtSecret); - ctx.init(decoded.tenant); - await ctx.initTenantCache(); - req.user = decoded; - req.ctx = ctx; - return next(); - } catch { - return res.status(401).json({error: 'Unauthorized'}); - } -}; router.get('/', validateJWT, async (req, res) => { const ctx = req.ctx; diff --git a/AdminPanel/server/sources/routes/wopi/router.js b/AdminPanel/server/sources/routes/wopi/router.js index f6efe8cf..8ffcf0db 100644 --- a/AdminPanel/server/sources/routes/wopi/router.js +++ b/AdminPanel/server/sources/routes/wopi/router.js @@ -34,9 +34,15 @@ const express = require('express'); const crypto = require('crypto'); -const operationContext = require('../../../../../Common/sources/operationContext'); +const utils = require('../../../../../Common/sources/utils'); +const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager'); +const tenantManager = require('../../../../../Common/sources/tenantManager'); +const {validateJWT} = require('../../middleware/auth'); +const {getScopedConfig} = require('../config/config.service'); +const cookieParser = require('cookie-parser'); const router = express.Router(); +router.use(cookieParser()); /** * Decode a base64url string into a Buffer (RFC 7515) @@ -143,32 +149,51 @@ function generateWopiKeys() { } /** - * Generates WOPI RSA keys and returns key parameters. + * Rotates WOPI keys - moves current keys to Old and generates new ones. */ -router.get('/generate-keys', express.json(), async (req, res) => { - const ctx = new operationContext.Context(); +router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => { + const ctx = req.ctx; try { - ctx.initFromRequest(req); - ctx.logger.info('WOPI key generation start'); + ctx.logger.info('WOPI key rotation start'); - // Generate WOPI keys - const wopiConfig = generateWopiKeys(); + const currentConfig = ctx.getFullCfg(); + const wopiConfig = utils.getImpl(currentConfig, 'wopi') || {}; - res.json({ - publicKey: wopiConfig.publicKey, - modulus: wopiConfig.modulus, - exponent: wopiConfig.exponent, - privateKey: wopiConfig.privateKey - }); + const newWopiConfig = generateWopiKeys(); + + const configUpdate = { + wopi: { + publicKeyOld: wopiConfig.publicKey || '', + modulusOld: wopiConfig.modulus || '', + exponentOld: wopiConfig.exponent || '', + privateKeyOld: wopiConfig.privateKey || '', + publicKey: newWopiConfig.publicKey, + modulus: newWopiConfig.modulus, + exponent: newWopiConfig.exponent, + privateKey: newWopiConfig.privateKey + } + }; + + const newConfig = utils.deepMergeObjects(currentConfig, configUpdate); + + if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) { + await tenantManager.setTenantConfig(ctx, newConfig); + } else { + await runtimeConfigManager.saveConfig(ctx, newConfig); + } + + await ctx.initTenantCache(); + const filteredConfig = getScopedConfig(ctx); + res.status(200).json(filteredConfig); } catch (error) { - ctx.logger.error('WOPI key generation error: %s', error.stack); + ctx.logger.error('WOPI key rotation error: %s', error.stack); res.status(500).json({ success: false, - error: 'Failed to generate WOPI keys', + error: 'Failed to rotate WOPI keys', details: error.message }); } finally { - ctx.logger.info('WOPI key generation end'); + ctx.logger.info('WOPI key rotation end'); } }); diff --git a/Common/config/schemas/config.schema.json b/Common/config/schemas/config.schema.json index 71b8a0da..9a89324a 100644 --- a/Common/config/schemas/config.schema.json +++ b/Common/config/schemas/config.schema.json @@ -167,7 +167,8 @@ "additionalProperties": false, "x-scope": ["admin", "tenant"], "properties": { - "enable": {"type": "boolean"} + "enable": {"type": "boolean"}, + "publicKey": {"type": "string"} } }, "email": { From 7a5b5fc2b8c3a85045d2dfc25132f865c6274150 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Wed, 17 Sep 2025 17:23:35 +0300 Subject: [PATCH 05/13] [bug] Disable response decoding while proxying; Fix bug 76719 --- Common/sources/utils.js | 4 +++- DocService/sources/ai/aiProxyHandler.js | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Common/sources/utils.js b/Common/sources/utils.js index 4b793354..3c249e56 100644 --- a/Common/sources/utils.js +++ b/Common/sources/utils.js @@ -521,9 +521,10 @@ async function postRequestPromise(ctx, uri, postData, postDataStream, postDataSi * @param {object} opt_timeout - Optional timeout configuration. * @param {number} opt_limit - Optional limit on the size of the response. * @param {boolean} opt_filterPrivate - Optional flag to filter private requests. + * @param {Object} [opt_axiosConfig={}] - Optional additional axios configuration options. * @returns {Promise<{response: axios.AxiosResponse, stream: SizeLimitStream}>} - A promise that resolves to an object containing the raw Axios response and a SizeLimitStream. */ -async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, opt_limit, opt_filterPrivate) { +async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, opt_limit, opt_filterPrivate, opt_axiosConfig = {}) { const tenTenantRequestDefaults = ctx.getCfg('services.CoAuthoring.requestDefaults', cfgRequestDefaults); uri = URI.serialize(URI.parse(uri)); const options = config.util.cloneDeep(tenTenantRequestDefaults); @@ -548,6 +549,7 @@ async function httpRequest(ctx, method, uri, opt_headers, opt_body, opt_timeout, const axiosConfig = { ...options, + ...opt_axiosConfig, url: uri, method, headers: requestHeaders, diff --git a/DocService/sources/ai/aiProxyHandler.js b/DocService/sources/ai/aiProxyHandler.js index cf40b3ee..ce25be66 100644 --- a/DocService/sources/ai/aiProxyHandler.js +++ b/DocService/sources/ai/aiProxyHandler.js @@ -300,7 +300,10 @@ async function proxyRequest(req, res) { requestParams.body, // Request body requestParams.timeout, // Timeout configuration requestParams.limit, // Size limit - requestParams.isInJwtToken // Filter private requests + requestParams.isInJwtToken, // Filter private requests + { + decompress: false + } ); // Set the response headers to match the target response From f7aad5eb2afde36e619078c234ab7099ad482492 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Wed, 17 Sep 2025 17:26:54 +0300 Subject: [PATCH 06/13] [config] Increase AI proxy default timeout; Fix bug 76728 --- Common/config/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Common/config/default.json b/Common/config/default.json index 9dd71463..f5df142b 100644 --- a/Common/config/default.json +++ b/Common/config/default.json @@ -14,7 +14,7 @@ "models": [], "providers": {}, "version": 3, - "timeout": "30s", + "timeout": "5m", "allowedCorsOrigins": ["https://onlyoffice.github.io", "https://onlyoffice-plugins.github.io"], "proxy": "", "pluginDir": "../branding/info/ai" From 470e857e6bc984e652b20bd0e02d5248a5d29bd9 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 18 Sep 2025 14:12:07 +0300 Subject: [PATCH 07/13] [fix] Rotate wopi keys --- .../src/pages/WOPISettings/WOPISettings.js | 134 ++++++++++-------- .../WOPISettings/WOPISettings.module.scss | 5 - .../server/sources/routes/wopi/router.js | 10 +- 3 files changed, 77 insertions(+), 72 deletions(-) diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index c43d9c40..067af5af 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -9,8 +9,7 @@ import PageHeader from '../../components/PageHeader/PageHeader'; import PageDescription from '../../components/PageDescription/PageDescription'; import ToggleSwitch from '../../components/ToggleSwitch/ToggleSwitch'; import Input from '../../components/Input/Input'; -import SaveButton from '../../components/SaveButton/SaveButton'; -import Tabs from '../../components/Tabs/Tabs'; +import Checkbox from '../../components/Checkbox/Checkbox'; import FixedSaveButton from '../../components/FixedSaveButton/FixedSaveButton'; import styles from './WOPISettings.module.scss'; @@ -19,25 +18,20 @@ function WOPISettings() { const config = useSelector(selectConfig); const {validateField, hasValidationErrors} = useFieldValidation(); - // Local state for WOPI enable setting + // Local state for WOPI settings const [localWopiEnabled, setLocalWopiEnabled] = useState(false); + const [localRotateKeys, setLocalRotateKeys] = useState(false); const [hasChanges, setHasChanges] = useState(false); - const [activeTab, setActiveTab] = useState('settings'); const hasInitialized = useRef(false); // Get the actual config values const configWopiEnabled = getNestedValue(config, 'wopi.enable', false); const wopiPublicKey = getNestedValue(config, 'wopi.publicKey', ''); - // Tabs configuration - const tabs = [ - { key: 'settings', label: 'Settings' }, - { key: 'keys', label: 'Key Management' } - ]; - const resetToGlobalConfig = () => { if (config) { setLocalWopiEnabled(configWopiEnabled); + setLocalRotateKeys(false); setHasChanges(false); validateField('wopi.enable', configWopiEnabled); } @@ -49,88 +43,102 @@ function WOPISettings() { hasInitialized.current = true; } - const handleTabChange = (newTab) => { - setActiveTab(newTab); - resetToGlobalConfig(); - }; const handleWopiEnabledChange = enabled => { setLocalWopiEnabled(enabled); - setHasChanges(enabled !== configWopiEnabled); + // If WOPI is disabled, uncheck rotate keys + if (!enabled) { + setLocalRotateKeys(false); + } + setHasChanges(enabled !== configWopiEnabled || localRotateKeys); // Validate the boolean field validateField('wopi.enable', enabled); }; + const handleRotateKeysChange = checked => { + setLocalRotateKeys(checked); + setHasChanges(localWopiEnabled !== configWopiEnabled || checked); + }; + const handleSave = async () => { if (!hasChanges) return; try { - const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); - await dispatch(saveConfig(updatedConfig)).unwrap(); + const enableChanged = localWopiEnabled !== configWopiEnabled; + const rotateRequested = localRotateKeys; + + // If only enable changed, just update config + if (enableChanged && !rotateRequested) { + const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); + await dispatch(saveConfig(updatedConfig)).unwrap(); + } + // If only rotate requested, just rotate keys + else if (!enableChanged && rotateRequested) { + await dispatch(rotateWopiKeysAction()).unwrap(); + } + // If both changed, make two requests + else if (enableChanged && rotateRequested) { + // First update the enable setting + const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); + await dispatch(saveConfig(updatedConfig)).unwrap(); + // Then rotate keys + await dispatch(rotateWopiKeysAction()).unwrap(); + } + setHasChanges(false); + setLocalRotateKeys(false); } catch (error) { console.error('Failed to save WOPI settings:', error); // Revert local state on error setLocalWopiEnabled(configWopiEnabled); + setLocalRotateKeys(false); setHasChanges(false); } }; - const handleRotateKeys = async () => { - await dispatch(rotateWopiKeysAction()).unwrap(); - }; - const renderSettingsTab = () => ( -
- -
- ); - - const renderKeysTab = () => ( -
-
Key Management
-
- Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. -
-
- - - Rotate Keys - -
-
- ); - return ( -
+
WOPI Settings Configure WOPI (Web Application Open Platform Interface) support for document editing - - {activeTab === 'settings' && renderSettingsTab()} - {activeTab === 'keys' && renderKeysTab()} - +
+ +
- {activeTab === 'settings' && ( - - Save Changes - + {localWopiEnabled && ( +
+
Key Management
+
+ Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. +
+
+ +
+
+ +
+
)} + + + Save Changes +
); } diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss index 6e5f7207..20f12b08 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.module.scss @@ -2,11 +2,6 @@ padding: 0; } -// Ensure proper spacing with tabs -:global(.tabsContainer) { - margin-bottom: 0; -} - .pageWithFixedSave { padding-bottom: 80px; } diff --git a/AdminPanel/server/sources/routes/wopi/router.js b/AdminPanel/server/sources/routes/wopi/router.js index 8ffcf0db..4615ee21 100644 --- a/AdminPanel/server/sources/routes/wopi/router.js +++ b/AdminPanel/server/sources/routes/wopi/router.js @@ -161,12 +161,14 @@ router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => { const newWopiConfig = generateWopiKeys(); + const hasEmptyKeys = !wopiConfig.publicKey && !wopiConfig.modulus && !wopiConfig.privateKey; + const configUpdate = { wopi: { - publicKeyOld: wopiConfig.publicKey || '', - modulusOld: wopiConfig.modulus || '', - exponentOld: wopiConfig.exponent || '', - privateKeyOld: wopiConfig.privateKey || '', + publicKeyOld: hasEmptyKeys ? newWopiConfig.publicKey : wopiConfig.publicKey, + modulusOld: hasEmptyKeys ? newWopiConfig.modulus : wopiConfig.modulus, + exponentOld: hasEmptyKeys ? newWopiConfig.exponent : wopiConfig.exponent, + privateKeyOld: hasEmptyKeys ? newWopiConfig.privateKey : wopiConfig.privateKey, publicKey: newWopiConfig.publicKey, modulus: newWopiConfig.modulus, exponent: newWopiConfig.exponent, From 498a955e16cbf1565203265d6db76de8401e1d38 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 18 Sep 2025 14:13:39 +0300 Subject: [PATCH 08/13] [feature] Refactor env, routing --- AdminPanel/client/.env | 3 +- AdminPanel/client/.env.example | 13 ++++++++- AdminPanel/client/package-lock.json | 6 ++++ AdminPanel/client/package.json | 3 +- AdminPanel/client/public/index.html | 2 +- AdminPanel/client/src/App.js | 39 ++++++++++++++----------- AdminPanel/client/src/api/index.js | 17 ++++++----- AdminPanel/client/src/index.js | 5 +--- AdminPanel/client/src/utils/basePath.js | 15 ++++++++++ AdminPanel/client/webpack.config.js | 16 +++++++++- 10 files changed, 85 insertions(+), 34 deletions(-) create mode 100644 AdminPanel/client/src/utils/basePath.js diff --git a/AdminPanel/client/.env b/AdminPanel/client/.env index 56dee1ac..d6721def 100644 --- a/AdminPanel/client/.env +++ b/AdminPanel/client/.env @@ -1 +1,2 @@ -REACT_APP_BACKEND_URL=http://localhost:9000 \ No newline at end of file +REACT_APP_BACKEND_URL=http://localhost:9000 +REACT_APP_BASE_PATH=/admin diff --git a/AdminPanel/client/.env.example b/AdminPanel/client/.env.example index 56dee1ac..b6f8791f 100644 --- a/AdminPanel/client/.env.example +++ b/AdminPanel/client/.env.example @@ -1 +1,12 @@ -REACT_APP_BACKEND_URL=http://localhost:9000 \ No newline at end of file +# Admin Panel Environment Variables +# Copy this file to .env for local development + +# Backend URL for API calls +REACT_APP_BACKEND_URL=http://localhost:9000 + +# Base path for the admin panel (empty for root deployment) +# Examples: +# REACT_APP_BASE_PATH= # Root deployment: http://localhost:3000/ +# REACT_APP_BASE_PATH=/admin # Under /admin: http://localhost:3000/admin/ +# REACT_APP_BASE_PATH=/docserver-admin # Under /docserver-admin: http://localhost:3000/docserver-admin/ +REACT_APP_BASE_PATH=/admin diff --git a/AdminPanel/client/package-lock.json b/AdminPanel/client/package-lock.json index 8eed51d7..1ba9d979 100644 --- a/AdminPanel/client/package-lock.json +++ b/AdminPanel/client/package-lock.json @@ -2768,6 +2768,12 @@ "is-obj": "^2.0.0" } }, + "dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "dev": true + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/AdminPanel/client/package.json b/AdminPanel/client/package.json index 6295dc9d..7884270b 100644 --- a/AdminPanel/client/package.json +++ b/AdminPanel/client/package.json @@ -3,7 +3,7 @@ "version": "1.3.0", "private": true, "scripts": { - "start": "set \"REACT_APP_BACKEND_URL=http://localhost:9000\" && webpack serve --mode=development", + "start": "webpack serve --mode=development", "build": "webpack --mode=production" }, "dependencies": { @@ -29,6 +29,7 @@ "babel-loader": "8.2.0", "copy-webpack-plugin": "11.0.0", "css-loader": "^6.2.0", + "dotenv": "^17.2.2", "file-loader": "^6.2.0", "html-webpack-plugin": "5.5.0", "sass": "^1.77.0", diff --git a/AdminPanel/client/public/index.html b/AdminPanel/client/public/index.html index 81bb060f..9ae0a010 100644 --- a/AdminPanel/client/public/index.html +++ b/AdminPanel/client/public/index.html @@ -2,7 +2,7 @@ - + diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index cc16be57..39f57bab 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -1,32 +1,37 @@ import {Provider} from 'react-redux'; -import {Routes, Route, Navigate} from 'react-router-dom'; +import {Routes, Route, Navigate, BrowserRouter} from 'react-router-dom'; import './App.css'; import {store} from './store'; import AuthWrapper from './components/AuthWrapper/AuthWrapper'; import ConfigLoader from './components/ConfigLoader/ConfigLoader'; import Menu from './components/Menu/Menu'; import {menuItems} from './config/menuItems'; +import {getBasePath} from './utils/basePath'; function App() { + const basePath = getBasePath(); + return ( -
- -
- -
- - - } /> - {menuItems.map(item => ( - } /> - ))} - - + +
+ +
+ +
+ + + } /> + {menuItems.map(item => ( + } /> + ))} + + +
-
- -
+ +
+ ); } diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 11e1a3a4..35b7dd02 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -1,7 +1,8 @@ const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? ''; +const API_BASE_PATH = '/api/v1/admin'; export const fetchStatistics = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/stat`); + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/stat`); if (!response.ok) { throw new Error('Failed to fetch statistics'); } @@ -9,7 +10,7 @@ export const fetchStatistics = async () => { }; export const fetchConfiguration = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config`, { credentials: 'include' }); if (!response.ok) { @@ -19,7 +20,7 @@ export const fetchConfiguration = async () => { }; export const fetchConfigurationSchema = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/config/schema`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`, { credentials: 'include' }); if (!response.ok) { @@ -29,7 +30,7 @@ export const fetchConfigurationSchema = async () => { }; export const updateConfiguration = async configData => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/config`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/config`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' @@ -54,7 +55,7 @@ export const updateConfiguration = async configData => { }; export const fetchCurrentUser = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/me`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/me`, { method: 'GET', credentials: 'include' // Include cookies in the request }); @@ -70,7 +71,7 @@ export const fetchCurrentUser = async () => { }; export const login = async ({tenantName, secret}) => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/login`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -90,7 +91,7 @@ export const login = async ({tenantName, secret}) => { }; export const logout = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/logout`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' @@ -106,7 +107,7 @@ export const logout = async () => { }; export const rotateWopiKeys = async () => { - const response = await fetch(`${BACKEND_URL}/api/v1/admin/wopi/rotate-keys`, { + const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/wopi/rotate-keys`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/AdminPanel/client/src/index.js b/AdminPanel/client/src/index.js index 6f50428e..5fbe9e45 100644 --- a/AdminPanel/client/src/index.js +++ b/AdminPanel/client/src/index.js @@ -1,6 +1,5 @@ import {StrictMode} from 'react'; import ReactDOM from 'react-dom/client'; -import {BrowserRouter} from 'react-router-dom'; import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; import App from './App'; @@ -18,9 +17,7 @@ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - - - + ); diff --git a/AdminPanel/client/src/utils/basePath.js b/AdminPanel/client/src/utils/basePath.js new file mode 100644 index 00000000..92ac9204 --- /dev/null +++ b/AdminPanel/client/src/utils/basePath.js @@ -0,0 +1,15 @@ +/** + * Utility functions for handling BASE_PATH environment variable + */ + +// Get the base path from environment variable, with fallback to empty string +export const getBasePath = () => { + return process.env.REACT_APP_BASE_PATH || ''; +}; + +// Create a full path by combining base path with the given path +export const createPath = (path) => { + const basePath = getBasePath(); + const cleanPath = path.startsWith('/') ? path : `/${path}`; + return `${basePath}${cleanPath}`; +}; diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index 33fa3870..23c86507 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -2,6 +2,19 @@ const path = require('path'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const CopyPlugin = require('copy-webpack-plugin'); const webpack = require('webpack'); +const dotenv = require('dotenv'); + +// Load environment variables from .env files +// Priority: .env.local > .env.development/.env.production > .env +const envFiles = [ + '.env.local', + process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', + '.env' +]; + +envFiles.forEach(file => { + dotenv.config({ path: file }); +}); module.exports = { entry: './src/index.js', @@ -39,7 +52,8 @@ module.exports = { ] }), new webpack.DefinePlugin({ - 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL) + 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL), + 'process.env.REACT_APP_BASE_PATH': JSON.stringify(process.env.REACT_APP_BASE_PATH || '') }) ], From d4b498b5ef8fc7efe856dc473b29e31bdc965866 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 18 Sep 2025 14:14:29 +0300 Subject: [PATCH 09/13] [fix]: webpack for static files --- AdminPanel/client/webpack.config.js | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index 23c86507..c905a795 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -16,23 +16,27 @@ envFiles.forEach(file => { dotenv.config({ path: file }); }); +const basePath = process.env.REACT_APP_BASE_PATH || ''; + module.exports = { entry: './src/index.js', output: { filename: 'main.[contenthash].js', path: path.resolve(__dirname, 'build'), - // Use relative URLs so assets load under any prefix (e.g., /admin) - publicPath: '' + // Use BASE_PATH for asset URLs + publicPath: basePath + '/' }, devServer: { static: { directory: path.join(__dirname, 'build'), - publicPath: '/' + publicPath: basePath + '/' }, port: 3000, open: true, - historyApiFallback: true + historyApiFallback: { + index: basePath + '/index.html' + } }, plugins: [ @@ -43,11 +47,18 @@ module.exports = { patterns: [ { context: path.resolve(__dirname, 'public'), - from: 'images/*.*' + from: 'images/*.*', + to: 'images/[name][ext]' + }, + { + context: path.resolve(__dirname, 'src/assets'), + from: '*.svg', + to: 'static/[name][ext]' }, { context: path.resolve(__dirname), - from: 'config.json' + from: 'config.json', + to: 'config.json' } ] }), From 22c800064e60b9566a05536a8f2b32f7af83d105 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 18 Sep 2025 14:15:28 +0300 Subject: [PATCH 10/13] [feat] add refreshLockInterval in Admin Panel --- .../src/pages/WOPISettings/WOPISettings.js | 108 ++++++++++++------ Common/config/schemas/config.schema.json | 7 +- 2 files changed, 80 insertions(+), 35 deletions(-) diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index 067af5af..ecb96776 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -21,19 +21,23 @@ function WOPISettings() { // Local state for WOPI settings const [localWopiEnabled, setLocalWopiEnabled] = useState(false); const [localRotateKeys, setLocalRotateKeys] = useState(false); + const [localRefreshLockInterval, setLocalRefreshLockInterval] = useState(''); const [hasChanges, setHasChanges] = useState(false); const hasInitialized = useRef(false); // Get the actual config values const configWopiEnabled = getNestedValue(config, 'wopi.enable', false); const wopiPublicKey = getNestedValue(config, 'wopi.publicKey', ''); + const configRefreshLockInterval = getNestedValue(config, 'wopi.refreshLockInterval', '10m'); const resetToGlobalConfig = () => { if (config) { setLocalWopiEnabled(configWopiEnabled); setLocalRotateKeys(false); + setLocalRefreshLockInterval(configRefreshLockInterval); setHasChanges(false); validateField('wopi.enable', configWopiEnabled); + validateField('wopi.refreshLockInterval', configRefreshLockInterval); } }; @@ -50,7 +54,7 @@ function WOPISettings() { if (!enabled) { setLocalRotateKeys(false); } - setHasChanges(enabled !== configWopiEnabled || localRotateKeys); + setHasChanges(enabled !== configWopiEnabled || localRotateKeys || localRefreshLockInterval !== configRefreshLockInterval); // Validate the boolean field validateField('wopi.enable', enabled); @@ -58,7 +62,13 @@ function WOPISettings() { const handleRotateKeysChange = checked => { setLocalRotateKeys(checked); - setHasChanges(localWopiEnabled !== configWopiEnabled || checked); + setHasChanges(localWopiEnabled !== configWopiEnabled || checked || localRefreshLockInterval !== configRefreshLockInterval); + }; + + const handleRefreshLockIntervalChange = value => { + setLocalRefreshLockInterval(value); + setHasChanges(localWopiEnabled !== configWopiEnabled || localRotateKeys || value !== configRefreshLockInterval); + validateField('wopi.refreshLockInterval', value); }; const handleSave = async () => { @@ -67,20 +77,30 @@ function WOPISettings() { try { const enableChanged = localWopiEnabled !== configWopiEnabled; const rotateRequested = localRotateKeys; + const refreshLockIntervalChanged = localRefreshLockInterval !== configRefreshLockInterval; - // If only enable changed, just update config - if (enableChanged && !rotateRequested) { - const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); - await dispatch(saveConfig(updatedConfig)).unwrap(); + // Build config update object + const configUpdates = {}; + if (enableChanged) { + configUpdates['wopi.enable'] = localWopiEnabled; } + if (refreshLockIntervalChanged) { + configUpdates['wopi.refreshLockInterval'] = localRefreshLockInterval; + } + // If only rotate requested, just rotate keys - else if (!enableChanged && rotateRequested) { + if (!enableChanged && !refreshLockIntervalChanged && rotateRequested) { await dispatch(rotateWopiKeysAction()).unwrap(); } - // If both changed, make two requests - else if (enableChanged && rotateRequested) { - // First update the enable setting - const updatedConfig = mergeNestedObjects([{'wopi.enable': localWopiEnabled}]); + // If config changes (enable or refreshLockInterval) but no rotate + else if ((enableChanged || refreshLockIntervalChanged) && !rotateRequested) { + const updatedConfig = mergeNestedObjects([configUpdates]); + await dispatch(saveConfig(updatedConfig)).unwrap(); + } + // If both config changes and rotate requested, make two requests + else if ((enableChanged || refreshLockIntervalChanged) && rotateRequested) { + // First update the config settings + const updatedConfig = mergeNestedObjects([configUpdates]); await dispatch(saveConfig(updatedConfig)).unwrap(); // Then rotate keys await dispatch(rotateWopiKeysAction()).unwrap(); @@ -93,6 +113,7 @@ function WOPISettings() { // Revert local state on error setLocalWopiEnabled(configWopiEnabled); setLocalRotateKeys(false); + setLocalRefreshLockInterval(configRefreshLockInterval); setHasChanges(false); } }; @@ -109,31 +130,50 @@ function WOPISettings() {
{localWopiEnabled && ( -
-
Key Management
-
- Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. + <> +
+
Lock Settings
+
+ Configure document lock refresh interval for WOPI sessions. +
+
+ +
-
- + +
+
Key Management
+
+ Rotate WOPI encryption keys. Current keys will be moved to "Old" and new keys will be generated. +
+
+ +
+
+ +
-
- -
-
+ )} diff --git a/Common/config/schemas/config.schema.json b/Common/config/schemas/config.schema.json index 9a89324a..e14a2581 100644 --- a/Common/config/schemas/config.schema.json +++ b/Common/config/schemas/config.schema.json @@ -168,7 +168,12 @@ "x-scope": ["admin", "tenant"], "properties": { "enable": {"type": "boolean"}, - "publicKey": {"type": "string"} + "publicKey": {"type": "string"}, + "refreshLockInterval": { + "type": "string", + "pattern": "^(\\d+[smhd]|\\d+\\s*(second|minute|hour|day)s?)$", + "description": "Refresh lock interval in time format (e.g., '10m', '1h', '30s')" + } } }, "email": { From 9a726cd0dfd238b60e15e6d726ba1f5effd0f884 Mon Sep 17 00:00:00 2001 From: PauI Ostrovckij Date: Thu, 18 Sep 2025 14:42:55 +0300 Subject: [PATCH 11/13] [linter] run prettier --- AdminPanel/client/src/App.js | 8 +- AdminPanel/client/src/api/index.js | 4 +- .../components/ConfigLoader/ConfigLoader.js | 83 +++++++++---------- .../FixedSaveButton.module.scss | 6 +- .../src/pages/EmailConfig/EmailConfig.js | 3 +- .../pages/EmailConfig/EmailConfig.module.scss | 2 +- .../client/src/pages/Expiration/Expiration.js | 3 +- .../pages/Expiration/Expiration.module.scss | 2 +- .../client/src/pages/FileLimits/FileLimits.js | 1 - .../pages/FileLimits/FileLimits.module.scss | 2 +- .../RequestFiltering/RequestFiltering.js | 1 - .../RequestFiltering.module.scss | 2 +- .../SecuritySettings/SecuritySettings.js | 5 +- .../src/pages/WOPISettings/WOPISettings.js | 21 ++--- AdminPanel/client/src/utils/basePath.js | 2 +- AdminPanel/client/src/utils/maskKey.js | 2 +- AdminPanel/client/webpack.config.js | 8 +- .../server/sources/routes/config/router.js | 3 +- .../server/sources/routes/wopi/router.js | 4 +- 19 files changed, 71 insertions(+), 91 deletions(-) diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 39f57bab..4676d07c 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -10,15 +10,15 @@ import {getBasePath} from './utils/basePath'; function App() { const basePath = getBasePath(); - + return (
-
- -
+
+ +
} /> diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 35b7dd02..38f26e6e 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -110,7 +110,7 @@ export const rotateWopiKeys = async () => { const response = await fetch(`${BACKEND_URL}${API_BASE_PATH}/wopi/rotate-keys`, { method: 'POST', headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json' }, credentials: 'include' }); @@ -121,4 +121,4 @@ export const rotateWopiKeys = async () => { } return response.json(); -}; \ No newline at end of file +}; diff --git a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js index cb7b4ff2..7f7fbb2d 100644 --- a/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js +++ b/AdminPanel/client/src/components/ConfigLoader/ConfigLoader.js @@ -1,17 +1,17 @@ -import { useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; -import { - selectConfig, - selectConfigLoading, - selectConfigError, - selectSchema, - selectSchemaLoading, - selectSchemaError, - fetchConfig, - fetchSchema +import {useEffect} from 'react'; +import {useSelector, useDispatch} from 'react-redux'; +import { + selectConfig, + selectConfigLoading, + selectConfigError, + selectSchema, + selectSchemaLoading, + selectSchemaError, + fetchConfig, + fetchSchema } from '../../store/slices/configSlice'; -const ConfigLoader = ({ children }) => { +const ConfigLoader = ({children}) => { const dispatch = useDispatch(); const config = useSelector(selectConfig); const configLoading = useSelector(selectConfigLoading); @@ -28,7 +28,7 @@ const ConfigLoader = ({ children }) => { if (!config && !configLoading && !configError) { dispatch(fetchConfig()); } - + // Fetch schema if not loaded (only once per session) if (!schema && !schemaLoading && !schemaError) { dispatch(fetchSchema()); @@ -37,25 +37,20 @@ const ConfigLoader = ({ children }) => { if (loading) { return ( -
- - + +

Loading configuration...

@@ -65,18 +60,18 @@ const ConfigLoader = ({ children }) => { if (error) { return ( -
-

Error loading configuration: {error}

- +
+

Error loading configuration: {error}

+
); } diff --git a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss index b79fa550..5e3388d3 100644 --- a/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss +++ b/AdminPanel/client/src/components/FixedSaveButton/FixedSaveButton.module.scss @@ -5,15 +5,15 @@ right: 0; background: #ffffff; border-top: 1px solid #e2e2e2; -// box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); + // box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1); z-index: 1000; padding: 16px 0; width: calc(100% - 256px); -// background-color: #fafafa; + // background-color: #fafafa; } .saveButtonWrapper { -// width: calc(100% - 256px); + // width: calc(100% - 256px); margin: 0 auto; padding: 0 24px; display: flex; diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js index bf73beb1..99a7c9c6 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.js @@ -75,7 +75,7 @@ function EmailConfig() { } // Handle tab change and reset state - const handleTabChange = (newTab) => { + const handleTabChange = newTab => { setActiveTab(newTab); resetToGlobalConfig(); }; @@ -254,7 +254,6 @@ function EmailConfig() { } }; - return (
Email Configuration diff --git a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss index 34aa205f..39741d5d 100644 --- a/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss +++ b/AdminPanel/client/src/pages/EmailConfig/EmailConfig.module.scss @@ -46,4 +46,4 @@ .pageWithFixedSave { padding-bottom: 40px; -} \ No newline at end of file +} diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.js b/AdminPanel/client/src/pages/Expiration/Expiration.js index cdbfc2f5..522ac93b 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.js +++ b/AdminPanel/client/src/pages/Expiration/Expiration.js @@ -63,7 +63,7 @@ function Expiration() { }; // Handle tab change and reset state - const handleTabChange = (newTab) => { + const handleTabChange = newTab => { setActiveTab(newTab); resetToGlobalConfig(); }; @@ -215,7 +215,6 @@ function Expiration() { } }; - return (
Expiration Settings diff --git a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss index 91e1fbf3..1c8d8042 100644 --- a/AdminPanel/client/src/pages/Expiration/Expiration.module.scss +++ b/AdminPanel/client/src/pages/Expiration/Expiration.module.scss @@ -46,4 +46,4 @@ .pageWithFixedSave { padding-bottom: 40px; -} \ No newline at end of file +} diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.js b/AdminPanel/client/src/pages/FileLimits/FileLimits.js index 589d73c6..7c4fb525 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.js +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.js @@ -163,7 +163,6 @@ function FileLimits() { setHasChanges(false); }; - return (
File Size Limits diff --git a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss index de6f9704..69380bd5 100644 --- a/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss +++ b/AdminPanel/client/src/pages/FileLimits/FileLimits.module.scss @@ -46,4 +46,4 @@ .pageWithFixedSave { padding-bottom: 40px; -} \ No newline at end of file +} diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js index 325c1f2f..f9313aad 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.js @@ -82,7 +82,6 @@ function RequestFiltering() { setHasChanges(false); }; - return (
Request Filtering diff --git a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss index 94c6cf3f..9ac325b9 100644 --- a/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss +++ b/AdminPanel/client/src/pages/RequestFiltering/RequestFiltering.module.scss @@ -70,4 +70,4 @@ .pageWithFixedSave { padding-bottom: 40px; -} \ No newline at end of file +} diff --git a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js index ceb03f87..0dcb893e 100644 --- a/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js +++ b/AdminPanel/client/src/pages/SecuritySettings/SecuritySettings.js @@ -38,13 +38,13 @@ function SecuritySettings() { }; // Handle tab change and reset state - const handleTabChange = (newTab) => { + const handleTabChange = newTab => { setActiveTab(newTab); resetToGlobalConfig(); }; const hasInitialized = useRef(false); - + if (config && !hasInitialized.current) { resetToGlobalConfig(); hasInitialized.current = true; @@ -101,7 +101,6 @@ function SecuritySettings() { } }; - return (
Security Settings diff --git a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js index ecb96776..70fca1b4 100644 --- a/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js +++ b/AdminPanel/client/src/pages/WOPISettings/WOPISettings.js @@ -47,7 +47,6 @@ function WOPISettings() { hasInitialized.current = true; } - const handleWopiEnabledChange = enabled => { setLocalWopiEnabled(enabled); // If WOPI is disabled, uncheck rotate keys @@ -118,8 +117,6 @@ function WOPISettings() { } }; - - return (
WOPI Settings @@ -133,16 +130,14 @@ function WOPISettings() { <>
Lock Settings
-
- Configure document lock refresh interval for WOPI sessions. -
+
Configure document lock refresh interval for WOPI sessions.
@@ -155,17 +150,17 @@ function WOPISettings() {
{ }; // Create a full path by combining base path with the given path -export const createPath = (path) => { +export const createPath = path => { const basePath = getBasePath(); const cleanPath = path.startsWith('/') ? path : `/${path}`; return `${basePath}${cleanPath}`; diff --git a/AdminPanel/client/src/utils/maskKey.js b/AdminPanel/client/src/utils/maskKey.js index a35822d1..5be9841e 100644 --- a/AdminPanel/client/src/utils/maskKey.js +++ b/AdminPanel/client/src/utils/maskKey.js @@ -4,7 +4,7 @@ * @param {string} key - The key string to mask * @returns {string} - The masked key string */ -export const maskKey = (key) => { +export const maskKey = key => { if (!key || typeof key !== 'string') { return ''; } diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index c905a795..32875c5b 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -6,14 +6,10 @@ const dotenv = require('dotenv'); // Load environment variables from .env files // Priority: .env.local > .env.development/.env.production > .env -const envFiles = [ - '.env.local', - process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', - '.env' -]; +const envFiles = ['.env.local', process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development', '.env']; envFiles.forEach(file => { - dotenv.config({ path: file }); + dotenv.config({path: file}); }); const basePath = process.env.REACT_APP_BASE_PATH || ''; diff --git a/AdminPanel/server/sources/routes/config/router.js b/AdminPanel/server/sources/routes/config/router.js index 3e25a643..255e85e2 100644 --- a/AdminPanel/server/sources/routes/config/router.js +++ b/AdminPanel/server/sources/routes/config/router.js @@ -20,7 +20,6 @@ const rawFileParser = bodyParser.raw({ } }); - router.get('/', validateJWT, async (req, res) => { const ctx = req.ctx; try { @@ -70,7 +69,7 @@ router.patch('/', validateJWT, rawFileParser, async (req, res) => { } else { await runtimeConfigManager.saveConfig(ctx, newConfig); } - + await ctx.initTenantCache(); const filteredConfig = getScopedConfig(ctx); res.status(200).json(filteredConfig); diff --git a/AdminPanel/server/sources/routes/wopi/router.js b/AdminPanel/server/sources/routes/wopi/router.js index 4615ee21..85e9b797 100644 --- a/AdminPanel/server/sources/routes/wopi/router.js +++ b/AdminPanel/server/sources/routes/wopi/router.js @@ -162,7 +162,7 @@ router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => { const newWopiConfig = generateWopiKeys(); const hasEmptyKeys = !wopiConfig.publicKey && !wopiConfig.modulus && !wopiConfig.privateKey; - + const configUpdate = { wopi: { publicKeyOld: hasEmptyKeys ? newWopiConfig.publicKey : wopiConfig.publicKey, @@ -177,7 +177,7 @@ router.post('/rotate-keys', validateJWT, express.json(), async (req, res) => { }; const newConfig = utils.deepMergeObjects(currentConfig, configUpdate); - + if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) { await tenantManager.setTenantConfig(ctx, newConfig); } else { From ebe7dc502145ffb65c0879353b670a9c91f9a671 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Fri, 19 Sep 2025 16:58:16 +0300 Subject: [PATCH 12/13] [feature] Revert REACT_APP_BASE_PATH --- AdminPanel/client/.env | 1 - AdminPanel/client/.env.example | 7 ---- AdminPanel/client/src/App.js | 29 ++++++++++++-- AdminPanel/client/src/components/Menu/Menu.js | 2 +- AdminPanel/client/src/utils/basePath.js | 15 -------- AdminPanel/client/webpack.config.js | 15 +++----- DocService/sources/server.js | 38 +++++++++++++++++++ 7 files changed, 69 insertions(+), 38 deletions(-) delete mode 100644 AdminPanel/client/src/utils/basePath.js diff --git a/AdminPanel/client/.env b/AdminPanel/client/.env index d6721def..f0801fcd 100644 --- a/AdminPanel/client/.env +++ b/AdminPanel/client/.env @@ -1,2 +1 @@ REACT_APP_BACKEND_URL=http://localhost:9000 -REACT_APP_BASE_PATH=/admin diff --git a/AdminPanel/client/.env.example b/AdminPanel/client/.env.example index b6f8791f..6f7c3da9 100644 --- a/AdminPanel/client/.env.example +++ b/AdminPanel/client/.env.example @@ -3,10 +3,3 @@ # Backend URL for API calls REACT_APP_BACKEND_URL=http://localhost:9000 - -# Base path for the admin panel (empty for root deployment) -# Examples: -# REACT_APP_BASE_PATH= # Root deployment: http://localhost:3000/ -# REACT_APP_BASE_PATH=/admin # Under /admin: http://localhost:3000/admin/ -# REACT_APP_BASE_PATH=/docserver-admin # Under /docserver-admin: http://localhost:3000/docserver-admin/ -REACT_APP_BASE_PATH=/admin diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 4676d07c..aa291604 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -6,14 +6,35 @@ import AuthWrapper from './components/AuthWrapper/AuthWrapper'; import ConfigLoader from './components/ConfigLoader/ConfigLoader'; import Menu from './components/Menu/Menu'; import {menuItems} from './config/menuItems'; -import {getBasePath} from './utils/basePath'; + +/** + * Simple basename computation from URL path. + * Basename is everything before the last path segment. + * Examples: + * - '/statistics' -> basename '' + * - '/admin/' -> basename '/admin' + * - '/admin/statistics' -> basename '/admin' + * - '/admin/su/statistics' -> basename '/admin/su' + * @returns {string} basename + */ +const getBasename = () => { + const path = window.location.pathname || '/'; + if (path === '/') return ''; + // Treat '/prefix/' as a directory prefix + if (path.endsWith('/')) return path.slice(0, -1); + // Remove trailing slash (keep root '/') for consistent parsing + const normalized = path; + const lastSlash = normalized.lastIndexOf('/'); + // If no parent directory, there is no basename + if (lastSlash <= 0) return ''; + return normalized.slice(0, lastSlash); +}; function App() { - const basePath = getBasePath(); - + const basename = getBasename(); return ( - +
diff --git a/AdminPanel/client/src/components/Menu/Menu.js b/AdminPanel/client/src/components/Menu/Menu.js index 630244e2..d4afc213 100644 --- a/AdminPanel/client/src/components/Menu/Menu.js +++ b/AdminPanel/client/src/components/Menu/Menu.js @@ -33,7 +33,7 @@ function Menu() { }; const isActiveItem = path => { - return location.pathname === path; + return location.pathname.endsWith(path); }; return ( diff --git a/AdminPanel/client/src/utils/basePath.js b/AdminPanel/client/src/utils/basePath.js deleted file mode 100644 index 390fa7fe..00000000 --- a/AdminPanel/client/src/utils/basePath.js +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Utility functions for handling BASE_PATH environment variable - */ - -// Get the base path from environment variable, with fallback to empty string -export const getBasePath = () => { - return process.env.REACT_APP_BASE_PATH || ''; -}; - -// Create a full path by combining base path with the given path -export const createPath = path => { - const basePath = getBasePath(); - const cleanPath = path.startsWith('/') ? path : `/${path}`; - return `${basePath}${cleanPath}`; -}; diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index 32875c5b..739485dd 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -12,27 +12,23 @@ envFiles.forEach(file => { dotenv.config({path: file}); }); -const basePath = process.env.REACT_APP_BASE_PATH || ''; - module.exports = { entry: './src/index.js', output: { filename: 'main.[contenthash].js', path: path.resolve(__dirname, 'build'), - // Use BASE_PATH for asset URLs - publicPath: basePath + '/' + // Use relative URLs so assets load under any prefix (e.g., /admin) + publicPath: '' }, devServer: { static: { directory: path.join(__dirname, 'build'), - publicPath: basePath + '/' + publicPath: '' }, port: 3000, open: true, - historyApiFallback: { - index: basePath + '/index.html' - } + historyApiFallback: true }, plugins: [ @@ -59,8 +55,7 @@ module.exports = { ] }), new webpack.DefinePlugin({ - 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL), - 'process.env.REACT_APP_BASE_PATH': JSON.stringify(process.env.REACT_APP_BASE_PATH || '') + 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL) }) ], diff --git a/DocService/sources/server.js b/DocService/sources/server.js index 81b75984..0db3713d 100644 --- a/DocService/sources/server.js +++ b/DocService/sources/server.js @@ -170,6 +170,44 @@ docsCoServer.install(server, app, () => { ); }); + // Proxy AdminPanel endpoints for testing + if (process.env.NODE_ENV.startsWith('development-')) { + /** + * Simple proxy to localhost:9000 for testing AdminPanel routes + * @param {string} pathPrefix - Path to prepend or empty string to strip mount path + */ + const proxyToAdmin = + (pathPrefix = '') => + (req, res) => { + const targetPath = pathPrefix + req.url; + const options = { + hostname: 'localhost', + port: 9000, + path: targetPath, + method: req.method, + headers: {...req.headers, host: 'localhost:9000'} + }; + + const proxyReq = http.request(options, proxyRes => { + res.status(proxyRes.statusCode); + Object.entries(proxyRes.headers).forEach(([key, value]) => res.setHeader(key, value)); + proxyRes.pipe(res); + }); + + proxyReq.on('error', () => res.sendStatus(502)); + req.pipe(proxyReq); + }; + + app.use('/api/v1/admin', proxyToAdmin('/api/v1/admin')); + app.all('/admin', (req, res, next) => { + if (req.path === '/admin' && !req.path.endsWith('/')) { + return res.redirect(302, '/admin/'); + } + next(); + }); + app.use('/admin', proxyToAdmin()); + } + app.get('/index.html', (req, res) => { return co(function* () { const ctx = new operationContext.Context(); From a7ceb4487687bbe1ff1508e58d5069291d701c62 Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Sat, 20 Sep 2025 02:21:35 +0300 Subject: [PATCH 13/13] [linter] run prettier --- DocService/sources/server.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/DocService/sources/server.js b/DocService/sources/server.js index 0db3713d..4bdbb4b4 100644 --- a/DocService/sources/server.js +++ b/DocService/sources/server.js @@ -179,25 +179,25 @@ docsCoServer.install(server, app, () => { const proxyToAdmin = (pathPrefix = '') => (req, res) => { - const targetPath = pathPrefix + req.url; - const options = { - hostname: 'localhost', - port: 9000, - path: targetPath, - method: req.method, - headers: {...req.headers, host: 'localhost:9000'} + const targetPath = pathPrefix + req.url; + const options = { + hostname: 'localhost', + port: 9000, + path: targetPath, + method: req.method, + headers: {...req.headers, host: 'localhost:9000'} + }; + + const proxyReq = http.request(options, proxyRes => { + res.status(proxyRes.statusCode); + Object.entries(proxyRes.headers).forEach(([key, value]) => res.setHeader(key, value)); + proxyRes.pipe(res); + }); + + proxyReq.on('error', () => res.sendStatus(502)); + req.pipe(proxyReq); }; - const proxyReq = http.request(options, proxyRes => { - res.status(proxyRes.statusCode); - Object.entries(proxyRes.headers).forEach(([key, value]) => res.setHeader(key, value)); - proxyRes.pipe(res); - }); - - proxyReq.on('error', () => res.sendStatus(502)); - req.pipe(proxyReq); - }; - app.use('/api/v1/admin', proxyToAdmin('/api/v1/admin')); app.all('/admin', (req, res, next) => { if (req.path === '/admin' && !req.path.endsWith('/')) {