Merge remote-tracking branch 'remotes/origin/hotfix/v9.2.0' into fix/admin-panel-bugs

# Conflicts:
#	AdminPanel/client/src/App.js
#	AdminPanel/client/src/api/index.js
#	AdminPanel/client/src/components/AuthWrapper/AuthWrapper.js
#	AdminPanel/client/webpack.config.js
This commit is contained in:
Sergey Konovalov
2025-10-25 11:16:54 +03:00
14 changed files with 466 additions and 141 deletions

View File

@ -3,5 +3,3 @@
# Backend URL for API calls # Backend URL for API calls
REACT_APP_BACKEND_URL=http://localhost:9000 REACT_APP_BACKEND_URL=http://localhost:9000
# Docservice Backend URL(only for dev mode)
REACT_APP_DOCSERVICE_URL=http://localhost:8000

View File

@ -7,6 +7,7 @@ import ConfigLoader from './components/ConfigLoader/ConfigLoader';
import {useSchemaLoader} from './hooks/useSchemaLoader'; import {useSchemaLoader} from './hooks/useSchemaLoader';
import Menu from './components/Menu/Menu'; import Menu from './components/Menu/Menu';
import {menuItems} from './config/menuItems'; import {menuItems} from './config/menuItems';
import {getBasename} from './utils/paths';
function AppContent() { function AppContent() {
useSchemaLoader(); useSchemaLoader();
@ -33,29 +34,6 @@ function AppContent() {
); );
} }
/**
* 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() { function App() {
const basename = getBasename(); const basename = getBasename();
return ( return (

View File

@ -1,5 +1,7 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? ''; import {getApiBasePath, getDocServicePath} from '../utils/paths';
const API_BASE_PATH = '/api/v1/admin';
const API_BASE_PATH = getApiBasePath();
const DOCSERVICE_URL = getDocServicePath();
const isNetworkError = error => { const isNetworkError = error => {
if (!error) return false; if (!error) return false;
@ -21,26 +23,26 @@ const safeFetch = async (url, options = {}) => {
}; };
export const fetchStatistics = async () => { export const fetchStatistics = async () => {
const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/stat`, {credentials: 'include'}); const response = await safeFetch(`${API_BASE_PATH}/stat`, {credentials: 'include'});
if (!response.ok) throw new Error('Failed to fetch statistics'); if (!response.ok) throw new Error('Failed to fetch statistics');
return response.json(); return response.json();
}; };
export const fetchConfiguration = async () => { 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.status === 401) throw new Error('UNAUTHORIZED');
if (!response.ok) throw new Error('Failed to fetch configuration'); if (!response.ok) throw new Error('Failed to fetch configuration');
return response.json(); return response.json();
}; };
export const fetchConfigurationSchema = async () => { 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.ok) throw new Error('Failed to fetch configuration schema'); if (!response.ok) throw new Error('Failed to fetch configuration schema');
return response.json(); return response.json();
}; };
export const updateConfiguration = async configData => { 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', method: 'PATCH',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -61,7 +63,7 @@ export const updateConfiguration = async configData => {
}; };
export const fetchCurrentUser = async () => { 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'});
const data = await response.json(); const data = await response.json();
if (data && data.authorized === false) { if (data && data.authorized === false) {
throw new Error('UNAUTHORIZED'); throw new Error('UNAUTHORIZED');
@ -70,13 +72,13 @@ export const fetchCurrentUser = async () => {
}; };
export const checkSetupRequired = 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'); if (!response.ok) throw new Error('Failed to check setup status');
return response.json(); return response.json();
}; };
export const setupAdminPassword = async ({bootstrapToken, password}) => { 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -96,7 +98,7 @@ export const setupAdminPassword = async ({bootstrapToken, password}) => {
}; };
export const login = async 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -119,7 +121,7 @@ export const login = async password => {
}; };
export const changePassword = async ({currentPassword, newPassword}) => { 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -140,7 +142,7 @@ export const changePassword = async ({currentPassword, newPassword}) => {
}; };
export const logout = async () => { export const logout = async () => {
const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/logout`, { const response = await safeFetch(`${API_BASE_PATH}/logout`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include' credentials: 'include'
@ -150,7 +152,7 @@ export const logout = async () => {
}; };
export const rotateWopiKeys = 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include' credentials: 'include'
@ -170,8 +172,7 @@ export const rotateWopiKeys = async () => {
}; };
export const checkHealth = async () => { export const checkHealth = async () => {
const url = process.env.NODE_ENV === 'development' ? '/healthcheck-api' : '../healthcheck'; const response = await safeFetch(`${DOCSERVICE_URL}/healthcheck`);
const response = await safeFetch(url);
if (!response.ok) throw new Error('DocService health check failed'); if (!response.ok) throw new Error('DocService health check failed');
const result = await response.text(); const result = await response.text();
if (result !== 'true') throw new Error('DocService health check failed'); if (result !== 'true') throw new Error('DocService health check failed');
@ -181,7 +182,7 @@ export const checkHealth = async () => {
export const resetConfiguration = async (paths = ['*']) => { export const resetConfiguration = async (paths = ['*']) => {
const pathsArray = Array.isArray(paths) ? paths : [paths]; const pathsArray = Array.isArray(paths) ? paths : [paths];
const response = await safeFetch(`${BACKEND_URL}${API_BASE_PATH}/config/reset`, { const response = await safeFetch(`${API_BASE_PATH}/config/reset`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -195,7 +196,7 @@ export const resetConfiguration = async (paths = ['*']) => {
}; };
export const generateDocServerToken = async body => { 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', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -211,8 +212,7 @@ const callCommandService = async body => {
const {token} = await generateDocServerToken(body); const {token} = await generateDocServerToken(body);
body.token = token; body.token = token;
const url = process.env.REACT_APP_DOCSERVICE_URL ? `${process.env.REACT_APP_DOCSERVICE_URL}/command` : '../command'; const response = await safeFetch(`${DOCSERVICE_URL}/command`, {
const response = await safeFetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View File

@ -29,9 +29,8 @@ export default function AuthWrapper({children}) {
dispatch(setPasswordSchema(result.passwordValidationSchema)); dispatch(setPasswordSchema(result.passwordValidationSchema));
} }
} catch (error) { } catch (error) {
if (error.message === 'SERVER_UNAVAILABLE') { console.error('Error checking setup:', error);
setServerUnavailable(true); setServerUnavailable(true);
}
} finally { } finally {
setCheckingSetup(false); setCheckingSetup(false);
} }

View File

@ -1,5 +1,6 @@
import {useState, useEffect, useRef, useCallback} from 'react'; import {useState, useEffect, useRef, useCallback} from 'react';
import {generateDocServerToken} from '../../api'; import {generateDocServerToken} from '../../api';
import {getBasename, getDocServicePath} from '../../utils/paths';
/** /**
* Preview page component with ONLYOFFICE Document Editor * Preview page component with ONLYOFFICE Document Editor
@ -17,12 +18,18 @@ function Preview(props) {
*/ */
const initEditor = useCallback(async () => { const initEditor = useCallback(async () => {
const userName = user?.email?.split('@')[0] || 'admin'; 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 = { const document = {
fileType: 'docx', fileType: 'docx',
key: '0' + Math.random(), key: '0' + Math.random(),
title: 'Example Document', title: 'Example Document',
url: `${window.location.origin}/${window.location.pathname.split('/')[1].includes('example') ? '' : window.location.pathname.split('/')[1] + '/'}assets/sample.docx`, url,
permissions: { permissions: {
edit: true, edit: true,
review: true, review: true,
@ -71,10 +78,7 @@ function Preview(props) {
useEffect(() => { useEffect(() => {
// Load ONLYOFFICE API script // Load ONLYOFFICE API script
const script = document.createElement('script'); const script = document.createElement('script');
const url = process.env.REACT_APP_DOCSERVICE_URL script.src = `${getDocServicePath()}/web-apps/apps/api/documents/api.js`;
? `${process.env.REACT_APP_DOCSERVICE_URL}/web-apps/apps/api/documents/api.js`
: '../web-apps/apps/api/documents/api.js';
script.src = url;
script.async = true; script.async = true;
script.onload = () => { script.onload = () => {
initEditor(); 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

@ -24,21 +24,25 @@ module.exports = (env, argv) => {
}, },
devServer: { devServer: {
static: { static: path.join(__dirname, 'public'),
directory: path.join(__dirname, 'build'),
publicPath: ''
},
port: 3000, port: 3000,
open: true, open: '/admin/',
historyApiFallback: true, hot: true,
proxy: { historyApiFallback: {
'/healthcheck-api': { index: '/admin/index.html'
target: process.env.REACT_APP_DOCSERVICE_URL, },
changeOrigin: true, devMiddleware: {
pathRewrite: { publicPath: '/admin'
'^/healthcheck-api': '/healthcheck' },
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;
} }
}, },
@ -82,8 +86,7 @@ module.exports = (env, argv) => {
] ]
}), }),
new webpack.DefinePlugin({ 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_DOCSERVICE_URL': JSON.stringify(process.env.REACT_APP_DOCSERVICE_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 express = require('express');
const http = require('http'); const http = require('http');
const cors = require('cors');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const infoRouter = require('../../../DocService/sources/routes/info'); const infoRouter = require('../../../DocService/sources/routes/info');
@ -52,9 +51,11 @@ const adminpanelRouter = require('./routes/adminpanel/router');
const wopiRouter = require('./routes/wopi/router'); const wopiRouter = require('./routes/wopi/router');
const passwordManager = require('./passwordManager'); const passwordManager = require('./passwordManager');
const bootstrap = require('./bootstrap'); const bootstrap = require('./bootstrap');
const devProxy = require('./devProxy');
const port = config.get('adminPanel.port'); const port = config.get('adminPanel.port');
const cfgLicenseFile = config.get('license.license_file'); const cfgLicenseFile = config.get('license.license_file');
const cfgCoAuthoringPort = config.get('services.CoAuthoring.server.port');
const app = express(); const app = express();
app.disable('x-powered-by'); app.disable('x-powered-by');
@ -111,12 +112,10 @@ setInterval(updateLicense, 86400000);
} }
})(); })();
const corsWithCredentials = cors({ // Development mode setup: CORS + DocService proxy
origin: true, if (process.env.NODE_ENV.startsWith('development-')) {
credentials: true, app.use(devProxy.getDevCors());
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], }
allowedHeaders: ['Content-Type', 'Authorization']
});
operationContext.global.logger.warn('AdminPanel server starting...'); operationContext.global.logger.warn('AdminPanel server starting...');
@ -129,26 +128,54 @@ function disableCache(req, res, next) {
next(); next();
} }
app.use('/api/v1/admin/config', corsWithCredentials, utils.checkClientIp, disableCache, configRouter); // API routes under /admin prefix
app.use('/api/v1/admin/wopi', corsWithCredentials, utils.checkClientIp, disableCache, wopiRouter); app.use('/admin/api/v1/config', utils.checkClientIp, disableCache, configRouter);
app.use('/api/v1/admin', corsWithCredentials, utils.checkClientIp, disableCache, adminpanelRouter); app.use('/admin/api/v1/wopi', utils.checkClientIp, disableCache, wopiRouter);
app.get('/api/v1/admin/stat', corsWithCredentials, utils.checkClientIp, disableCache, async (req, res) => { 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); 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) { 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 // Disable caching for SPA index.html to ensure updates work
disableCache(req, res, () => {}); disableCache(req, res, () => {});
res.sendFile(path.join(clientBuildPath, 'index.html')); 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) => { app.use((err, req, res, _next) => {
const ctx = new operationContext.Context(); const ctx = new operationContext.Context();

View File

@ -439,6 +439,17 @@ function getTableColumns(ctx, tableName) {
}); });
} }
/**
* Generate SQL condition to check if a field is not empty (fallback implementation)
* Database-specific connectors can override this function
* @param {string} fieldName - Name of the field to check
* @returns {string} SQL condition string
*/
function getNotEmptyConditionFallback(fieldName) {
// Default implementation for most databases: check both NULL and empty string
return `${fieldName} IS NOT NULL AND ${fieldName} != ''`;
}
module.exports = { module.exports = {
insertChangesPromise, insertChangesPromise,
deleteChangesPromise, deleteChangesPromise,
@ -454,5 +465,7 @@ module.exports = {
getTableColumns, getTableColumns,
getDateTime: _getDateTime2, getDateTime: _getDateTime2,
...connectorUtilities, ...connectorUtilities,
...dbInstance ...dbInstance,
// Use connector-specific implementation if available, otherwise use fallback
getNotEmptyCondition: dbInstance.getNotEmptyCondition || getNotEmptyConditionFallback
}; };

View File

@ -75,14 +75,60 @@ let pool = null;
oracledb.fetchAsString = [oracledb.NCLOB, oracledb.CLOB]; oracledb.fetchAsString = [oracledb.NCLOB, oracledb.CLOB];
oracledb.autoCommit = true; oracledb.autoCommit = true;
function columnsToLowercase(rows) { /**
* WeakMap cache for column type maps
* Key: metaData array reference from Oracle result
* Value: Object mapping column names (lowercase) to boolean (is NCLOB/CLOB)
* Automatically garbage collected when metaData is no longer referenced
*/
const columnTypeMapCache = new WeakMap();
/**
* Get or build column type map from metadata
* @param {Array} metaData - Column metadata from Oracle
* @returns {Object.<string, boolean>} Map of column name (lowercase) to isClobColumn flag
*/
function getColumnTypeMap(metaData) {
let columnTypeMap = columnTypeMapCache.get(metaData);
if (!columnTypeMap) {
columnTypeMap = {};
for (let i = 0; i < metaData.length; i++) {
const col = metaData[i];
// Check if column is NCLOB/CLOB (converted to string by fetchAsString config)
const isClobColumn = col.dbType === oracledb.DB_TYPE_NCLOB || col.dbType === oracledb.DB_TYPE_CLOB;
columnTypeMap[col.name.toLowerCase()] = isClobColumn;
}
columnTypeMapCache.set(metaData, columnTypeMap);
}
return columnTypeMap;
}
/**
* Convert column names to lowercase and normalize null values
* Oracle returns null for empty NCLOB/CLOB fields, converts to empty string for consistency with other databases
* @param {Array<Object>} rows - Query result rows
* @param {Array} metaData - Column metadata from Oracle (optional)
* @returns {Array<Object>} Formatted rows with lowercase column names and normalized values
*/
function columnsToLowercase(rows, metaData) {
const columnTypeMap = metaData ? getColumnTypeMap(metaData) : null;
const formattedRows = []; const formattedRows = [];
for (const row of rows) { for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const newRow = {}; const newRow = {};
for (const column in row) { for (const column in row) {
if (Object.hasOwn(row, column)) { if (!Object.hasOwn(row, column)) continue;
newRow[column.toLowerCase()] = row[column];
const columnLower = column.toLowerCase();
let value = row[column];
// Normalize null to empty string for NCLOB/CLOB columns
if (value === null && columnTypeMap?.[columnLower]) {
value = '';
} }
newRow[columnLower] = value;
} }
formattedRows.push(newRow); formattedRows.push(newRow);
@ -121,7 +167,7 @@ async function executeQuery(ctx, sqlCommand, values = [], noModifyRes = false, n
} }
if (result?.rows) { if (result?.rows) {
output = columnsToLowercase(result.rows); output = columnsToLowercase(result.rows, result.metaData);
} }
} else { } else {
output = result; output = result;
@ -204,6 +250,7 @@ async function executeBunch(ctx, sqlCommand, values = [], options, noLog = false
} }
function closePool() { function closePool() {
// WeakMap cache is automatically garbage collected, no manual cleanup needed
return pool?.close(forceClosingCountdownMs); return pool?.close(forceClosingCountdownMs);
} }
@ -445,6 +492,16 @@ async function insertChangesAsync(ctx, tableChanges, startIndex, objChanges, doc
return result; return result;
} }
/**
* Generate SQL condition to check if a field is not empty
* Oracle-specific: NCLOB cannot be compared with != operator, and empty strings are NULL
* @param {string} fieldName - Name of the field to check
* @returns {string} SQL condition string
*/
function getNotEmptyCondition(fieldName) {
return `${fieldName} IS NOT NULL`;
}
module.exports = { module.exports = {
sqlQuery, sqlQuery,
closePool, closePool,
@ -456,5 +513,6 @@ module.exports = {
getDocumentsWithChanges, getDocumentsWithChanges,
getExpired, getExpired,
upsert, upsert,
insertChanges insertChanges,
getNotEmptyCondition
}; };

View File

@ -92,6 +92,20 @@ const cfgDownloadMaxBytes = config.get('FileConverter.converter.maxDownloadBytes
const app = express(); const app = express();
app.disable('x-powered-by'); 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) //path.resolve uses __dirname by default(unexpected path in pkg)
app.set('views', path.resolve(process.cwd(), cfgHtmlTemplate)); app.set('views', path.resolve(process.cwd(), cfgHtmlTemplate));
app.set('view engine', 'ejs'); 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) => { app.get('/index.html', (req, res) => {
return co(function* () { return co(function* () {
const ctx = new operationContext.Context(); const ctx = new operationContext.Context();

View File

@ -135,7 +135,7 @@ function select(ctx, docId) {
}); });
} }
/** /**
* Convert task object to SQL update/condition array * Generate SQL SET/WHERE clauses from task object
* @param {TaskResultData} task - Task data object * @param {TaskResultData} task - Task data object
* @param {boolean} updateTime - Whether to update last_open_date * @param {boolean} updateTime - Whether to update last_open_date
* @param {boolean} isMask - Whether this is for WHERE clause (mask mode) * @param {boolean} isMask - Whether this is for WHERE clause (mask mode)
@ -145,7 +145,7 @@ function select(ctx, docId) {
* *
* Special mask values: * Special mask values:
* - Use 'NOT_EMPTY' as field value in mask mode to check for non-empty callback * - Use 'NOT_EMPTY' as field value in mask mode to check for non-empty callback
* - Example: {callback: 'NOT_EMPTY'} generates "callback IS NOT NULL AND callback != ''" * - Uses baseConnector.getNotEmptyCondition() for database-specific SQL generation
*/ */
function toUpdateArray(task, updateTime, isMask, values, setPassword) { function toUpdateArray(task, updateTime, isMask, values, setPassword) {
const res = []; const res = [];
@ -177,7 +177,8 @@ function toUpdateArray(task, updateTime, isMask, values, setPassword) {
} }
// Add callback non-empty check for mask // Add callback non-empty check for mask
if (isMask && task.callback === 'NOT_EMPTY') { if (isMask && task.callback === 'NOT_EMPTY') {
res.push(`callback IS NOT NULL AND callback != ''`); // Use database-specific condition (Oracle NCLOB needs special handling)
res.push(sqlBase.getNotEmptyCondition('callback'));
} }
if (null != task.baseurl) { if (null != task.baseurl) {
const sqlParam = addSqlParam(task.baseurl, values); const sqlParam = addSqlParam(task.baseurl, values);

View File

@ -100,6 +100,11 @@ const upsertCases = {
insert: 'baseConnector-upsert()-tester-row-inserted', insert: 'baseConnector-upsert()-tester-row-inserted',
update: 'baseConnector-upsert()-tester-row-updated' update: 'baseConnector-upsert()-tester-row-updated'
}; };
const updateIfCases = {
notEmpty: 'baseConnector-updateIf()-tester-not-empty-callback',
emptyCallback: 'baseConnector-updateIf()-tester-empty-callback'
};
const oracleNullHandlingCases = ['baseConnector-oracle-nclob-null-handling', 'baseConnector-oracle-nclob-null-handling-2'];
function createChanges(changesLength, date) { function createChanges(changesLength, date) {
const objChanges = [ const objChanges = [
@ -203,9 +208,18 @@ afterAll(async () => {
const insertIds = Object.values(insertCases); const insertIds = Object.values(insertCases);
const changesIds = Object.values(changesCases); const changesIds = Object.values(changesCases);
const upsertIds = Object.values(upsertCases); const upsertIds = Object.values(upsertCases);
const updateIfIds = Object.values(updateIfCases);
const tableChangesIds = [...emptyCallbacksCase, ...documentsWithChangesCase, ...changesIds, ...insertIds]; const tableChangesIds = [...emptyCallbacksCase, ...documentsWithChangesCase, ...changesIds, ...insertIds];
const tableResultIds = [...emptyCallbacksCase, ...documentsWithChangesCase, ...getExpiredCase, ...getCountWithStatusCase, ...upsertIds]; const tableResultIds = [
...emptyCallbacksCase,
...documentsWithChangesCase,
...getExpiredCase,
...getCountWithStatusCase,
...upsertIds,
...updateIfIds,
...oracleNullHandlingCases
];
const deletionPool = [ const deletionPool = [
deleteRowsByIds(cfgTableChanges, tableChangesIds), deleteRowsByIds(cfgTableChanges, tableChangesIds),
@ -287,17 +301,23 @@ describe('Base database connector', () => {
describe('Add changes', () => { describe('Add changes', () => {
for (const testCase in insertCases) { for (const testCase in insertCases) {
test(`${testCase} rows inserted`, async () => { // Increase timeout for large inserts (5000+ rows can take longer on some databases)
const docId = insertCases[testCase]; const timeout = +testCase >= 5000 ? 15000 : 5000;
const objChanges = createChanges(+testCase, date); test(
`${testCase} rows inserted`,
async () => {
const docId = insertCases[testCase];
const objChanges = createChanges(+testCase, date);
await noRowsExistenceCheck(cfgTableChanges, docId); await noRowsExistenceCheck(cfgTableChanges, docId);
await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user); await baseConnector.insertChangesPromise(ctx, objChanges, docId, index, user);
const result = await getRowsCountById(cfgTableChanges, docId); const result = await getRowsCountById(cfgTableChanges, docId);
expect(result).toEqual(objChanges.length); expect(result).toEqual(objChanges.length);
}); },
timeout
);
} }
}); });
@ -469,4 +489,81 @@ describe('Base database connector', () => {
expect(updatedRow).toEqual(expectedUrlChanges); expect(updatedRow).toEqual(expectedUrlChanges);
}); });
}); });
describe('Oracle NCLOB null handling', () => {
const nullHandlingCase = 'baseConnector-oracle-nclob-null-handling';
test('Empty callback is retrieved as empty string (not null)', async () => {
const date = new Date();
const task = createTask(nullHandlingCase, ''); // Empty callback
await noRowsExistenceCheck(cfgTableResult, task.key);
await insertIntoResultTable(date, task);
// Retrieve the row and check callback field
const result = await executeSql(`SELECT callback, baseurl FROM ${cfgTableResult} WHERE id = '${task.key}';`);
expect(result.length).toEqual(1);
// Oracle should normalize null NCLOB to empty string
expect(result[0].callback).toEqual('');
expect(result[0].baseurl).toEqual('');
// Verify they are strings, not null
expect(typeof result[0].callback).toEqual('string');
expect(typeof result[0].baseurl).toEqual('string');
});
test('Null callback does not cause TypeError in getCallbackByUserIndex', async () => {
const date = new Date();
const task = createTask(nullHandlingCase + '-2', '');
await insertIntoResultTable(date, task);
const result = await executeSql(`SELECT callback FROM ${cfgTableResult} WHERE id = '${task.key}';`);
// This should not throw TypeError
const userCallback = new baseConnector.UserCallback();
expect(() => {
userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
}).not.toThrow();
const callbackResult = userCallback.getCallbackByUserIndex(ctx, result[0].callback, 1);
expect(callbackResult).toEqual('');
});
});
describe('updateIf() method', () => {
test('Update with NOT_EMPTY callback mask', async () => {
const date = new Date();
const taskWithCallback = createTask(updateIfCases.notEmpty, 'http://example.com/callback');
const taskEmptyCallback = createTask(updateIfCases.emptyCallback, '');
// Insert two rows: one with callback, one without
await Promise.all([insertIntoResultTable(date, taskWithCallback), insertIntoResultTable(date, taskEmptyCallback)]);
// Update mask: only update rows with non-empty callback and status=None
const mask = new taskResult.TaskResultData();
mask.tenant = ctx.tenant;
mask.key = taskWithCallback.key;
mask.status = commonDefines.FileStatus.None;
mask.callback = 'NOT_EMPTY';
// Update task: change status to SaveVersion
const updateTask = new taskResult.TaskResultData();
updateTask.status = commonDefines.FileStatus.SaveVersion;
updateTask.statusInfo = constants.NO_ERROR;
const result = await taskResult.updateIf(ctx, updateTask, mask);
// Should update exactly 1 row (the one with callback)
expect(result.affectedRows).toEqual(1);
// Verify the row with callback was updated
const updatedRow = await executeSql(`SELECT status FROM ${cfgTableResult} WHERE id = '${taskWithCallback.key}';`);
expect(updatedRow[0].status).toEqual(commonDefines.FileStatus.SaveVersion);
// Verify the row without callback was NOT updated
const notUpdatedRow = await executeSql(`SELECT status FROM ${cfgTableResult} WHERE id = '${taskEmptyCallback.key}';`);
expect(notUpdatedRow[0].status).toEqual(commonDefines.FileStatus.None);
});
});
}); });