[bug] Refactor admin panel routing; Fix bug 77576

This commit is contained in:
Sergey Konovalov
2025-10-15 17:24:14 +03:00
parent 318d81c02f
commit dcea07c4c0
9 changed files with 276 additions and 119 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
})
],

View File

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

View File

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

View File

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