From dcea07c4c0b8ff2b40caf1ee6c31c111a9e65adf Mon Sep 17 00:00:00 2001 From: Sergey Konovalov Date: Wed, 15 Oct 2025 17:24:14 +0300 Subject: [PATCH] [bug] Refactor admin panel routing; Fix bug 77576 --- AdminPanel/client/.env.example | 2 - AdminPanel/client/src/App.js | 24 +--- AdminPanel/client/src/api/index.js | 38 ++--- .../client/src/pages/Example/Example.js | 14 +- AdminPanel/client/src/utils/paths.js | 35 +++++ AdminPanel/client/webpack.config.js | 33 +++-- AdminPanel/server/sources/devProxy.js | 136 ++++++++++++++++++ AdminPanel/server/sources/server.js | 61 +++++--- DocService/sources/server.js | 52 ++----- 9 files changed, 276 insertions(+), 119 deletions(-) create mode 100644 AdminPanel/client/src/utils/paths.js create mode 100644 AdminPanel/server/sources/devProxy.js diff --git a/AdminPanel/client/.env.example b/AdminPanel/client/.env.example index f588ab97..6f7c3da9 100644 --- a/AdminPanel/client/.env.example +++ b/AdminPanel/client/.env.example @@ -3,5 +3,3 @@ # Backend URL for API calls REACT_APP_BACKEND_URL=http://localhost:9000 -# Docservice Backend URL(only for dev mode) -REACT_APP_DOCSERVICE_URL=http://localhost:8000 diff --git a/AdminPanel/client/src/App.js b/AdminPanel/client/src/App.js index 5524b64b..4a029b5c 100644 --- a/AdminPanel/client/src/App.js +++ b/AdminPanel/client/src/App.js @@ -6,29 +6,7 @@ import AuthWrapper from './components/AuthWrapper/AuthWrapper'; import ConfigLoader from './components/ConfigLoader/ConfigLoader'; import Menu from './components/Menu/Menu'; import {menuItems} from './config/menuItems'; - -/** - * 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); -}; +import {getBasename} from './utils/paths'; function App() { const basename = getBasename(); diff --git a/AdminPanel/client/src/api/index.js b/AdminPanel/client/src/api/index.js index 3d889c03..b290d489 100644 --- a/AdminPanel/client/src/api/index.js +++ b/AdminPanel/client/src/api/index.js @@ -1,5 +1,7 @@ -const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? ''; -const API_BASE_PATH = '/api/v1/admin'; +import {getApiBasePath, getDocServicePath} from '../utils/paths'; + +const API_BASE_PATH = getApiBasePath(); +const DOCSERVICE_URL = getDocServicePath(); const isNetworkError = error => { if (!error) return false; @@ -21,27 +23,27 @@ const safeFetch = async (url, options = {}) => { }; export const fetchStatistics = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/stat`); + const response = await safeFetch(`${API_BASE_PATH}/stat`); if (!response.ok) throw new Error('Failed to fetch statistics'); return response.json(); }; export const fetchConfiguration = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config`, {credentials: 'include'}); + const response = await safeFetch(`${API_BASE_PATH}/config`, {credentials: 'include'}); if (response.status === 401) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('Failed to fetch configuration'); return response.json(); }; export const fetchConfigurationSchema = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/schema`, {credentials: 'include'}); + const response = await safeFetch(`${API_BASE_PATH}/config/schema`, {credentials: 'include'}); if (response.status === 401) throw new Error('UNAUTHORIZED'); if (!response.ok) throw new Error('Failed to fetch configuration schema'); return response.json(); }; export const updateConfiguration = async configData => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config`, { + const response = await safeFetch(`${API_BASE_PATH}/config`, { method: 'PATCH', headers: {'Content-Type': 'application/json'}, credentials: 'include', @@ -61,7 +63,7 @@ export const updateConfiguration = async configData => { }; export const fetchCurrentUser = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/me`, {credentials: 'include'}); + const response = await safeFetch(`${API_BASE_PATH}/me`, {credentials: 'include'}); if (!response.ok) throw new Error('Failed to fetch current user'); const data = await response.json(); if (data && data.authorized === false) { @@ -71,13 +73,13 @@ export const fetchCurrentUser = async () => { }; export const checkSetupRequired = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/setup/required`, {credentials: 'include'}); + const response = await safeFetch(`${API_BASE_PATH}/setup/required`, {credentials: 'include'}); if (!response.ok) throw new Error('Failed to check setup status'); return response.json(); }; export const setupAdminPassword = async ({bootstrapToken, password}) => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/setup`, { + const response = await safeFetch(`${API_BASE_PATH}/setup`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include', @@ -97,7 +99,7 @@ export const setupAdminPassword = async ({bootstrapToken, password}) => { }; export const login = async password => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/login`, { + const response = await safeFetch(`${API_BASE_PATH}/login`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include', @@ -120,7 +122,7 @@ export const login = async password => { }; export const changePassword = async ({currentPassword, newPassword}) => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/change-password`, { + const response = await safeFetch(`${API_BASE_PATH}/change-password`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include', @@ -140,7 +142,7 @@ export const changePassword = async ({currentPassword, newPassword}) => { }; export const logout = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/logout`, { + const response = await safeFetch(`${API_BASE_PATH}/logout`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include' @@ -150,7 +152,7 @@ export const logout = async () => { }; export const rotateWopiKeys = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/wopi/rotate-keys`, { + const response = await safeFetch(`${API_BASE_PATH}/wopi/rotate-keys`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include' @@ -169,8 +171,7 @@ export const rotateWopiKeys = async () => { }; export const checkHealth = async () => { - const url = process.env.NODE_ENV === 'development' ? '/healthcheck-api' : '../healthcheck'; - const response = await safeFetch(url); + const response = await safeFetch(`${DOCSERVICE_URL}/healthcheck`); if (!response.ok) throw new Error('DocService health check failed'); const result = await response.text(); if (result !== 'true') throw new Error('DocService health check failed'); @@ -178,7 +179,7 @@ export const checkHealth = async () => { }; export const resetConfiguration = async () => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/reset`, { + const response = await safeFetch(`${API_BASE_PATH}/config/reset`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include' @@ -188,7 +189,7 @@ export const resetConfiguration = async () => { }; export const generateDocServerToken = async body => { - const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/generate-docserver-token`, { + const response = await safeFetch(`${API_BASE_PATH}/generate-docserver-token`, { method: 'POST', headers: {'Content-Type': 'application/json'}, credentials: 'include', @@ -204,8 +205,7 @@ const callCommandService = async body => { const {token} = await generateDocServerToken(body); body.token = token; - const url = process.env.REACT_APP_DOCSERVICE_URL ? `${process.env.REACT_APP_DOCSERVICE_URL}/command` : '../command'; - const response = await safeFetch(url, { + const response = await safeFetch(`${DOCSERVICE_URL}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/AdminPanel/client/src/pages/Example/Example.js b/AdminPanel/client/src/pages/Example/Example.js index 3ea8b636..e040b7c3 100644 --- a/AdminPanel/client/src/pages/Example/Example.js +++ b/AdminPanel/client/src/pages/Example/Example.js @@ -1,5 +1,6 @@ import {useState, useEffect, useRef, useCallback} from 'react'; import {generateDocServerToken} from '../../api'; +import {getBasename, getDocServicePath} from '../../utils/paths'; /** * Preview page component with ONLYOFFICE Document Editor @@ -17,12 +18,18 @@ function Preview(props) { */ const initEditor = useCallback(async () => { const userName = user?.email?.split('@')[0] || 'admin'; + let url = `${getBasename()}/assets/sample.docx`; + + // Add origin only if URL is not absolute (doesn't start with http:// or https://) + if (!/^https?:\/\//i.test(url)) { + url = window.location.origin + url; + } const document = { fileType: 'docx', key: '0' + Math.random(), title: 'Example Document', - url: `${window.location.origin}/${window.location.pathname.split('/')[1].includes('example') ? '' : window.location.pathname.split('/')[1] + '/'}assets/sample.docx`, + url, permissions: { edit: true, review: true, @@ -71,10 +78,7 @@ function Preview(props) { useEffect(() => { // Load ONLYOFFICE API script const script = document.createElement('script'); - const url = process.env.REACT_APP_DOCSERVICE_URL - ? `${process.env.REACT_APP_DOCSERVICE_URL}/web-apps/apps/api/documents/api.js` - : '../web-apps/apps/api/documents/api.js'; - script.src = url; + script.src = `${getDocServicePath()}/web-apps/apps/api/documents/api.js`; script.async = true; script.onload = () => { initEditor(); diff --git a/AdminPanel/client/src/utils/paths.js b/AdminPanel/client/src/utils/paths.js new file mode 100644 index 00000000..a8a638c6 --- /dev/null +++ b/AdminPanel/client/src/utils/paths.js @@ -0,0 +1,35 @@ +/** + * Get the base path for AdminPanel routes + * Supports both regular /admin and vpath /vpath/admin routes + * @returns {string} Base path (e.g., '/admin' or '/vpath/admin') + */ +export const getBasename = () => { + const pathname = window.location.pathname; + const adminMatch = pathname.match(/^(.*?\/admin)(?:\/|$)/); + + if (adminMatch) { + return adminMatch[1]; // Returns /admin or /vpath/admin + } + + return ''; +}; + +/** + * Backend URL for API calls + */ +const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? ''; + +/** + * Get relative path to DocService resources from AdminPanel + * @returns {string} Relative path prefix + */ +export const getDocServicePath = () => { + return `${BACKEND_URL}${getBasename()}/..`; +}; + +/** + * API base path including basename + */ +export const getApiBasePath = () => { + return `${BACKEND_URL}${getBasename()}/api/v1`; +}; diff --git a/AdminPanel/client/webpack.config.js b/AdminPanel/client/webpack.config.js index a33ffd44..b6dc8e48 100644 --- a/AdminPanel/client/webpack.config.js +++ b/AdminPanel/client/webpack.config.js @@ -27,21 +27,25 @@ module.exports = (env, argv) => { }, devServer: { - static: { - directory: path.join(__dirname, 'build'), - publicPath: '' - }, + static: path.join(__dirname, 'public'), port: 3000, - open: true, - historyApiFallback: true, - proxy: { - '/healthcheck-api': { - target: process.env.REACT_APP_DOCSERVICE_URL, - changeOrigin: true, - pathRewrite: { - '^/healthcheck-api': '/healthcheck' + open: '/admin/', + hot: true, + historyApiFallback: { + index: '/admin/index.html' + }, + devMiddleware: { + publicPath: '/admin' + }, + setupMiddlewares: (middlewares, devServer) => { + // Redirect /admin -> /admin/ (only exact match without trailing slash) + devServer.app.use((req, res, next) => { + if (req.path === '/admin' && !req.originalUrl.endsWith('/')) { + return res.redirect(302, '/admin/'); } - } + next(); + }); + return middlewares; } }, @@ -85,8 +89,7 @@ module.exports = (env, argv) => { ] }), new webpack.DefinePlugin({ - 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL), - 'process.env.REACT_APP_DOCSERVICE_URL': JSON.stringify(process.env.REACT_APP_DOCSERVICE_URL) + 'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL) }) ], diff --git a/AdminPanel/server/sources/devProxy.js b/AdminPanel/server/sources/devProxy.js new file mode 100644 index 00000000..79581b06 --- /dev/null +++ b/AdminPanel/server/sources/devProxy.js @@ -0,0 +1,136 @@ +/* + * (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 http = require('http'); +const cors = require('cors'); +const operationContext = require('../../../Common/sources/operationContext'); + +/** + * Setup development proxy to DocService + * @param {object} app - Express app + * @param {object} server - HTTP server + * @param {number} targetPort - DocService port + */ +function setupDevProxy(app, server, targetPort) { + /** + * Simple proxy middleware for DocService HTTP requests + * @param {object} req - Express request + * @param {object} res - Express response + * @param {Function} next - Express next + */ + function proxyToDocService(req, res, next) { + // Skip requests that start with /admin + if (req.path.startsWith('/admin')) { + return next(); + } + + const proxyReq = http.request( + { + hostname: req.hostname || 'localhost', + port: targetPort, + path: req.url, + method: req.method, + headers: req.headers + }, + proxyRes => { + res.writeHead(proxyRes.statusCode, proxyRes.headers); + proxyRes.pipe(res); + } + ); + + proxyReq.on('error', err => { + operationContext.global.logger.error('Proxy error: %s', err.message); + res.status(502).send('Bad Gateway'); + }); + + req.pipe(proxyReq); + } + + app.use(proxyToDocService); + + /** + * Proxy WebSocket upgrade requests to DocService + */ + server.on('upgrade', (req, socket, _head) => { + // Skip WebSocket requests that start with /admin + if (req.url.startsWith('/admin')) { + socket.destroy(); + return; + } + + const proxyReq = http.request({ + hostname: req.headers.host?.split(':')[0] || 'localhost', + port: targetPort, + path: req.url, + method: req.method, + headers: req.headers + }); + + proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => { + // Forward upgrade response to client + socket.write(`HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n`); + Object.entries(proxyRes.headers).forEach(([key, value]) => { + socket.write(`${key}: ${value}\r\n`); + }); + socket.write('\r\n'); + socket.write(proxyHead); + + // Bidirectional pipe + proxySocket.pipe(socket); + socket.pipe(proxySocket); + }); + + proxyReq.on('error', err => { + operationContext.global.logger.error('WebSocket proxy error: %s', err.message); + socket.destroy(); + }); + + proxyReq.end(); + }); +} + +/** + * Get CORS middleware for development + * @returns {Function} CORS middleware + */ +function getDevCors() { + return cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] + }); +} + +module.exports = {setupDevProxy, getDevCors}; diff --git a/AdminPanel/server/sources/server.js b/AdminPanel/server/sources/server.js index 214da33d..2be45e3d 100644 --- a/AdminPanel/server/sources/server.js +++ b/AdminPanel/server/sources/server.js @@ -42,7 +42,6 @@ const runtimeConfigManager = require('../../../Common/sources/runtimeConfigManag const express = require('express'); const http = require('http'); -const cors = require('cors'); const path = require('path'); const fs = require('fs'); const infoRouter = require('../../../DocService/sources/routes/info'); @@ -52,9 +51,11 @@ const adminpanelRouter = require('./routes/adminpanel/router'); const wopiRouter = require('./routes/wopi/router'); const passwordManager = require('./passwordManager'); const bootstrap = require('./bootstrap'); +const devProxy = require('./devProxy'); const port = config.get('adminPanel.port'); const cfgLicenseFile = config.get('license.license_file'); +const cfgCoAuthoringPort = config.get('services.CoAuthoring.server.port'); const app = express(); app.disable('x-powered-by'); @@ -111,12 +112,10 @@ setInterval(updateLicense, 86400000); } })(); -const corsWithCredentials = cors({ - origin: true, - credentials: true, - methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'] -}); +// Development mode setup: CORS + DocService proxy +if (process.env.NODE_ENV.startsWith('development-')) { + app.use(devProxy.getDevCors()); +} operationContext.global.logger.warn('AdminPanel server starting...'); @@ -129,26 +128,54 @@ function disableCache(req, res, next) { next(); } -app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, disableCache, configRouter); -app.use('/api/v1/admin/wopi', corsWithCredentials, utils.checkClientIp, disableCache, wopiRouter); -app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, disableCache, adminpanelRouter); -app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, disableCache, async (req, res) => { +// API routes under /admin prefix +app.use('/admin/api/v1/config', utils.checkClientIp, disableCache, configRouter); +app.use('/admin/api/v1/wopi', utils.checkClientIp, disableCache, wopiRouter); +app.use('/admin/api/v1', utils.checkClientIp, disableCache, adminpanelRouter); +app.get('/admin/api/v1/stat', utils.checkClientIp, disableCache, async (req, res) => { await infoRouter.licenseInfo(req, res); }); -// Serve AdminPanel client build as static assets -const clientBuildPath = path.resolve('client/build'); -app.use('/', express.static(clientBuildPath)); +// Serve AdminPanel client build as static assets under /admin +const clientBuildPath = path.resolve('client/build'); + +/** + * Custom middleware to handle /admin redirect with relative path + * Prevents express.static from doing absolute 302 redirect + */ +app.use('/admin', (req, res, next) => { + // If path is exactly /admin (no trailing slash), redirect relatively + if ((req.path === '' || req.path === '/') && !req.originalUrl.endsWith('/')) { + // Relative redirect preserves virtual path prefix + return res.redirect(302, 'admin/'); + } + next(); +}); + +app.use('/admin', express.static(clientBuildPath)); + +/** + * Serves SPA index.html for client-side routing + * @param {object} req - Express request + * @param {object} res - Express response + * @param {Function} next - Express next middleware + */ function serveSpaIndex(req, res, next) { - if (req.path.startsWith('/api')) return next(); + if (req.path.startsWith('/admin/api')) return next(); // Disable caching for SPA index.html to ensure updates work disableCache(req, res, () => {}); res.sendFile(path.join(clientBuildPath, 'index.html')); } -// Client SPA routes fallback -app.get('*', serveSpaIndex); + +// Client SPA routes fallback for /admin/* +app.get('/admin/*', serveSpaIndex); + +// Development mode: proxy non-/admin requests to DocService (must be last) +if (process.env.NODE_ENV.startsWith('development-')) { + devProxy.setupDevProxy(app, server, cfgCoAuthoringPort); +} app.use((err, req, res, _next) => { const ctx = new operationContext.Context(); diff --git a/DocService/sources/server.js b/DocService/sources/server.js index a5773a20..ef826174 100644 --- a/DocService/sources/server.js +++ b/DocService/sources/server.js @@ -92,6 +92,20 @@ const cfgDownloadMaxBytes = config.get('FileConverter.converter.maxDownloadBytes const app = express(); app.disable('x-powered-by'); + +// Enable CORS in development mode for AdminPanel webpack dev server +if (process.env.NODE_ENV.startsWith('development-')) { + const cors = require('cors'); + app.use( + cors({ + origin: true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] + }) + ); +} + //path.resolve uses __dirname by default(unexpected path in pkg) app.set('views', path.resolve(process.cwd(), cfgHtmlTemplate)); app.set('view engine', 'ejs'); @@ -162,44 +176,6 @@ 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();