mirror of
https://github.com/ONLYOFFICE/server.git
synced 2026-04-07 14:04:35 +08:00
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:
@ -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
|
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
35
AdminPanel/client/src/utils/paths.js
Normal file
35
AdminPanel/client/src/utils/paths.js
Normal 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`;
|
||||||
|
};
|
||||||
@ -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)
|
|
||||||
})
|
})
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
136
AdminPanel/server/sources/devProxy.js
Normal file
136
AdminPanel/server/sources/devProxy.js
Normal 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};
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user