Merge branch 'feature/admin-panel2' into release/v9.1.0

# Conflicts:
#	Common/sources/operationContext.js
#	Common/sources/utils.js
#	DocService/sources/DocsCoServer.js
#	DocService/sources/server.js
#	npm-shrinkwrap.json
#	package.json
This commit is contained in:
Sergey Konovalov
2025-09-05 02:41:00 +03:00
74 changed files with 10618 additions and 197 deletions

3
.gitignore vendored
View File

@ -10,4 +10,5 @@ node_modules
/Gruntfile.js.out /Gruntfile.js.out
local-development-*.json local-development-*.json
*.pyc *.pyc
run-develop-local.py run-develop-local.py
runtime.json

View File

@ -1 +0,0 @@
npx lint-staged

1
AdminPanel/client/.env Normal file
View File

@ -0,0 +1 @@
REACT_APP_BACKEND_URL=http://localhost:9000

View File

@ -0,0 +1 @@
REACT_APP_BACKEND_URL=http://localhost:9000

23
AdminPanel/client/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

6091
AdminPanel/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
{
"name": "docscloud",
"version": "1.3.0",
"private": true,
"scripts": {
"start": "set \"REACT_APP_BACKEND_URL=http://localhost:9000\" && webpack serve --mode=development",
"build": "webpack --mode=production"
},
"dependencies": {
"@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-query": "^5.83.0",
"axios": "1.7.4",
"ajv": "^8.17.1",
"prop-types": "^15.8.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.1",
"redux": "^4.2.1",
"redux-thunk": "^2.4.2",
"styled-components": "^5.3.11"
},
"devDependencies": {
"@babel/cli": "7.17.0",
"@babel/core": "7.17.0",
"@babel/preset-env": "^7.15.6",
"@babel/preset-react": "7.16.0",
"babel-loader": "8.2.0",
"copy-webpack-plugin": "11.0.0",
"css-loader": "^6.2.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "5.5.0",
"style-loader": "3.2.1",
"webpack": "5.70.0",
"webpack-cli": "4.10.0",
"webpack-dev-server": "4.11.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

View File

@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/images/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Document Server Admin Panel" />
<title>ONLIOFFICE Admin</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,56 @@
.App {
text-align: center;
width: 300px;
}
body {
font-family: 'Open Sans', sans-serif, Arial;
font-size: 1rem;
font-weight: 400;
margin: 0px !important;
overflow-y: scroll;
scrollbar-gutter: stable;
color: rgb(51, 51, 51);
}
.App,
.content,
h1,
h2,
h3,
h4,
h5,
h6,
p,
div,
span,
td,
th,
label,
a,
li,
ul,
ol {
color: rgb(51, 51, 51);
}
body::-webkit-scrollbar {
width: 6px;
background: #ffffff;
}
body::-webkit-scrollbar-thumb {
background: #efefef;
}
.content {
padding-right: 20%;
padding-left: 20%;
}
@media (max-width: 1200px) {
.content {
padding-right: 5%;
padding-left: 5%;
}
}

View File

@ -0,0 +1,23 @@
import {Provider} from 'react-redux';
import './App.css';
import {store} from './store';
import AuthWrapper from './components/AuthWrapper';
import Header from './components/Header';
import Home from './pages/Home';
function App() {
return (
<Provider store={store}>
<div>
<Header />
<AuthWrapper>
<div className='content'>
<Home />
</div>
</AuthWrapper>
</div>
</Provider>
);
}
export default App;

View File

@ -0,0 +1,111 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL ?? '';
export const fetchStatistics = async () => {
const response = await fetch(`${BACKEND_URL}/info/info.json`);
if (!response.ok) {
throw new Error('Failed to fetch statistics');
}
return response.json();
};
export const fetchConfiguration = async () => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch configuration');
}
return response.json();
};
export const fetchConfigurationSchema = async () => {
const response = await fetch(`${BACKEND_URL}/info/config/schema`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('Failed to fetch configuration schema');
}
return response.json();
};
export const updateConfiguration = async configData => {
const response = await fetch(`${BACKEND_URL}/info/config`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(configData)
});
if (!response.ok) {
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const errorData = await response.json();
throw errorData;
} else {
const errorText = await response.text();
throw new Error(errorText);
}
}
// Try to parse as JSON, fallback to text if it's not JSON
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return response.json();
} else {
return response.text();
}
};
export const fetchCurrentUser = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/me`, {
method: 'GET',
credentials: 'include' // Include cookies in the request
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Unauthorized');
}
throw new Error('Failed to fetch current user');
}
return response.json();
};
export const login = async ({tenantName, secret}) => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({tenantName, secret})
});
if (!response.ok) {
if (response.status === 401) {
throw new Error('Invalid credentials');
}
throw new Error('Login failed');
}
return response.json();
};
export const logout = async () => {
const response = await fetch(`${BACKEND_URL}/info/adminpanel/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include'
});
if (!response.ok) {
throw new Error('Logout failed');
}
return response.json();
};

View File

@ -0,0 +1,15 @@
<svg width="144" height="23" viewBox="0 0 144 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 22.7181L0.656893 17.9809C-0.218964 17.5673 -0.218964 16.9282 0.656893 16.5522L4.23648 14.8979L10.9006 17.9809C11.7765 18.3945 13.1855 18.3945 14.0232 17.9809L20.6874 14.8979L24.267 16.5522C25.1428 16.9658 25.1428 17.6049 24.267 17.9809L13.9852 22.7181C13.1855 23.0941 11.7765 23.0941 10.9387 22.7181Z" fill="#FF6F3D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 16.8905L0.656893 12.1533C-0.218964 11.7397 -0.218964 11.1005 0.656893 10.7246L4.16032 9.10791L10.9387 12.2285C11.8145 12.642 13.2235 12.642 14.0613 12.2285L20.8397 9.10791L24.3431 10.7246C25.219 11.1381 25.219 11.7773 24.3431 12.1533L14.0613 16.8905C13.1855 17.304 11.7765 17.304 10.9387 16.8905Z" fill="#95C038"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.9387 11.2133L0.656893 6.47608C-0.218964 6.06251 -0.218964 5.42336 0.656893 5.04739L10.9387 0.310175C11.8145 -0.103392 13.2235 -0.103392 14.0613 0.310175L24.3431 5.04739C25.219 5.46096 25.219 6.10011 24.3431 6.47608L14.0613 11.2133C13.1855 11.5893 11.7765 11.5893 10.9387 11.2133Z" fill="#5DC0E8"/>
<path d="M33 11.4781C33 9.15204 33.6769 7.39655 35.076 6.25549C36.4298 5.07053 38.0545 4.5 39.9048 4.5C41.7551 4.5 43.3346 5.07053 44.6885 6.25549C46.0424 7.44044 46.7194 9.15204 46.7194 11.5219C46.7194 13.848 46.0424 15.6034 44.6885 16.7445C43.3346 17.9295 41.71 18.5 39.9048 18.5C38.0545 18.5 36.475 17.9295 35.076 16.7445C33.6769 15.5596 33 13.8041 33 11.4781ZM35.9785 11.4781C35.9785 13.1019 36.2944 14.2429 36.8811 14.989C37.5129 15.7351 38.1899 16.2179 38.912 16.3934C39.0925 16.4373 39.2279 16.4812 39.4084 16.4812C39.5438 16.4812 39.7243 16.5251 39.8597 16.5251C40.0402 16.5251 40.1756 16.5251 40.3561 16.4812C40.5366 16.4812 40.672 16.4373 40.8525 16.3934C41.5746 16.2179 42.2515 15.7351 42.8382 14.989C43.4249 14.2429 43.7408 13.058 43.7408 11.5219C43.7408 9.94201 43.4249 8.80094 42.8382 8.05486C42.2515 7.30878 41.5746 6.82602 40.8525 6.65047C40.672 6.60658 40.4915 6.5627 40.3561 6.5627C40.1756 6.5627 40.0402 6.51881 39.8597 6.51881C39.6792 6.51881 39.5438 6.51881 39.4084 6.5627C39.273 6.5627 39.0925 6.60658 38.912 6.65047C38.1899 6.82602 37.5129 7.30878 36.8811 8.05486C36.2944 8.71317 35.9785 9.89812 35.9785 11.4781Z" fill="white"/>
<path d="M48.2087 4.63184H51.9094L56.7833 13.2337L57.5054 15.1209H57.5505L57.5054 12.6632V4.63184H60.3486V18.3246H56.648L51.774 9.37165L51.0519 7.8356H51.0068L51.0519 10.2494V18.3246H48.2087V4.63184Z" fill="white"/>
<path d="M63.282 4.63184H66.1251V15.9986H71.7212V18.3246H63.282V4.63184Z" fill="white"/>
<path d="M69.9612 4.63184H73.2556L76.1439 9.41554L76.5952 10.3811H76.6855L77.1368 9.41554L80.0702 4.63184H83.0939L77.9942 12.751V18.3246H75.1511V12.7071L69.9612 4.63184Z" fill="white"/>
<path d="M82.7327 11.4781C82.7327 9.15204 83.4096 7.39655 84.8086 6.25548C86.1625 5.07053 87.7872 4.5 89.6375 4.5C91.4878 4.5 93.0673 5.07053 94.4212 6.25548C95.7751 7.44044 96.452 9.15204 96.452 11.5219C96.452 13.848 95.7751 15.6034 94.4212 16.7445C93.0673 17.9295 91.4427 18.5 89.6375 18.5C87.7872 18.5 86.2076 17.9295 84.8086 16.7445C83.4547 15.5596 82.7327 13.8041 82.7327 11.4781ZM85.7112 11.4781C85.7112 13.1019 86.0271 14.2429 86.6138 14.989C87.2456 15.7351 87.8774 16.2179 88.6446 16.3934C88.8251 16.4373 88.9605 16.4812 89.1411 16.4812C89.2764 16.4812 89.457 16.5251 89.5924 16.5251C89.7729 16.5251 89.9083 16.5251 90.0888 16.4812C90.2693 16.4812 90.4047 16.4373 90.5852 16.3934C91.3073 16.2179 91.9842 15.7351 92.5709 14.989C93.1576 14.2429 93.4735 13.058 93.4735 11.5219C93.4735 9.942 93.1576 8.80094 92.5709 8.05486C91.9842 7.30878 91.3073 6.82602 90.5852 6.65047C90.4047 6.60658 90.2242 6.5627 90.0888 6.5627C89.9083 6.5627 89.7729 6.51881 89.5924 6.51881C89.4118 6.51881 89.2764 6.51881 89.1411 6.5627C89.0057 6.5627 88.8251 6.60658 88.6446 6.65047C87.9226 6.82602 87.2456 7.30878 86.6138 8.05486C86.0271 8.71316 85.7112 9.89812 85.7112 11.4781Z" fill="white"/>
<path d="M98.4375 4.63184H106.29V6.91397H101.281V10.2494H106.064V12.5754H101.281V18.3246H98.4375V4.63184Z" fill="white"/>
<path d="M108.411 4.63184H116.264V6.91397H111.255V10.2494H116.038V12.5754H111.255V18.3246H108.411V4.63184Z" fill="white"/>
<path d="M117.934 18.3246V4.63184H120.777V18.3246H117.934Z" fill="white"/>
<path d="M133.304 4.93899V7.30889C132.808 7.13335 132.311 7.00168 131.77 6.91391C131.228 6.82613 130.597 6.78225 129.965 6.78225C128.475 6.78225 127.347 7.22112 126.535 8.14275C125.723 9.02049 125.316 10.1616 125.316 11.5221C125.316 12.8387 125.677 13.9359 126.445 14.8136C127.212 15.6913 128.295 16.1741 129.694 16.1741C130.19 16.1741 130.687 16.1302 131.274 16.0863C131.86 15.9985 132.447 15.8669 133.079 15.6036L133.259 17.9296C133.169 17.9735 133.034 18.0174 132.898 18.0612C132.718 18.1051 132.537 18.149 132.311 18.1929C131.95 18.2807 131.499 18.3246 130.958 18.4123C130.416 18.4562 129.874 18.5001 129.288 18.5001C129.198 18.5001 129.107 18.5001 129.062 18.5001C128.972 18.5001 128.882 18.5001 128.837 18.5001C127.212 18.4123 125.723 17.7979 124.369 16.7446C123.015 15.6475 122.338 13.9359 122.338 11.6537C122.338 9.41548 123.015 7.65999 124.324 6.43115C125.632 5.20231 127.438 4.58789 129.649 4.58789C130.236 4.58789 130.777 4.58789 131.228 4.63178C131.725 4.67567 132.176 4.76344 132.673 4.85121C132.763 4.8951 132.898 4.8951 132.988 4.93899C133.079 4.8951 133.169 4.93899 133.304 4.93899Z" fill="white"/>
<path d="M135.561 4.63184H144V6.78231H138.449V10.2055H143.458V12.3121H138.449V16.1742H144V18.3246H135.561V4.63184Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,3 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06812 15.75C5.29857 15.75 2.25568 12.735 2.25568 9C2.25568 5.265 5.29857 2.25 9.06812 2.25C10.8812 2.25 12.5247 2.97759 13.7397 4.12194C13.8255 4.20274 13.9152 4.2797 14.0198 4.33409C14.3161 4.48823 14.9843 4.74308 15.487 4.245C15.9865 3.75001 15.7356 3.09308 15.5798 2.79677C15.5233 2.6894 15.4438 2.59682 15.3556 2.51353C13.7181 0.967092 11.5151 0 9.06812 0C4.05719 0 0 4.035 0 9C0 13.965 4.05719 18 9.06812 18C13.0816 18 16.4798 15.4184 17.6694 11.8342C17.8962 11.1509 17.3444 10.5 16.6244 10.5C16.0825 10.5 15.6221 10.8784 15.4283 11.3844C14.4527 13.9315 11.9806 15.75 9.06812 15.75Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 719 B

View File

@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.81342 10.8535L1.66675 6.70683L0.253418 8.12016L5.81342 13.6668L17.7468 1.7335L16.3334 0.333496L5.81342 10.8535Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1,62 @@
import {useEffect, useState} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {fetchUser, selectUser, selectUserLoading, selectIsAuthenticated} from '../../store/slices/userSlice';
import Spinner from '../../assets/Spinner.svg';
import Login from '../../pages/Login';
export default function AuthWrapper({children}) {
const dispatch = useDispatch();
const user = useSelector(selectUser);
const loading = useSelector(selectUserLoading);
const isAuthenticated = useSelector(selectIsAuthenticated);
const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => {
dispatch(fetchUser()).finally(() => {
setHasInitialized(true);
});
}, [dispatch]);
// Show loading spinner only for initial auth check, not for login operations
if ((loading || !hasInitialized) && !isAuthenticated) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
width: '100vw'
}}
>
<img
src={Spinner}
alt='Loading'
style={{
width: '50px',
height: '50px',
filter: 'invert(1) brightness(0.5)', // Makes white SVG dark gray
animation: 'spin 1s linear infinite' // Rotates continuously
}}
/>
<style>{`
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
`}</style>
</div>
);
}
if (!isAuthenticated || !user) {
return <Login />;
}
// Show the main app content if user is authenticated
return children;
}

View File

@ -0,0 +1,72 @@
import {useState, forwardRef} from 'react';
import styles from './styles.module.css';
import Spinner from '../../assets/Spinner.svg';
import Success from '../../assets/Success.svg';
const Button = forwardRef(({onClick, disabled, children, className, errorText = 'FAILED'}, ref) => {
const [state, setState] = useState('idle'); // 'idle', 'loading', 'success', 'error'
const [isProcessing, setIsProcessing] = useState(false);
const handleClick = async () => {
if (isProcessing) return;
setIsProcessing(true);
setState('loading');
try {
await onClick();
setState('success');
// Show success for 3 seconds
setTimeout(() => {
setState('idle');
setIsProcessing(false);
}, 1000);
} catch (_error) {
setState('error');
// Show error for 3 seconds
setTimeout(() => {
setState('idle');
setIsProcessing(false);
}, 1000);
}
};
const getButtonContent = () => {
switch (state) {
case 'loading':
return (
<>
<img src={Spinner} alt='Loading' className={styles.icon} />
</>
);
case 'success':
return (
<>
<img src={Success} alt='Success' className={styles.icon} />
</>
);
case 'error':
return errorText;
default:
return children;
}
};
const getButtonClassName = () => {
const baseClass = styles.button;
const stateClass = state !== 'idle' ? styles[state] : '';
return `${baseClass} ${stateClass} ${className || ''}`.trim();
};
return (
<button ref={ref} className={getButtonClassName()} onClick={handleClick} disabled={disabled || isProcessing}>
{getButtonContent()}
</button>
);
});
Button.displayName = 'Button';
export default Button;

View File

@ -0,0 +1,53 @@
.button {
background: rgb(255, 111, 61);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 16px;
width: 160px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 46px;
}
.button:hover:not(:disabled) {
background: rgb(255, 149, 113);
}
.button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.button.loading,
.button.success {
background-color: rgb(139, 184, 37);
}
.button.error {
background-color: rgb(187, 5, 5);
}
.icon {
width: 16px;
height: 16px;
}
.button.loading .icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -0,0 +1,279 @@
import {useState, useEffect} from 'react';
import {useSelector} from 'react-redux';
import Ajv from 'ajv';
import {fetchConfiguration, updateConfiguration, fetchConfigurationSchema} from '../../api';
import {getNestedValue} from '../../utils/getNestedValue';
import {mergeNestedObjects} from '../../utils/mergeNestedObjects';
import {configurationSections, ROLES} from '../../config/configurationSchema';
import {selectUser} from '../../store/slices/userSlice';
import ExpandableSection from '../ExpandableSection';
import ConfigurationInput from '../ConfigurationInput';
import SelectField from '../SelectField';
import JsonField from '../JsonField';
import Button from '../Button';
import styles from './styles.module.css';
export default function Configuration() {
const user = useSelector(selectUser);
const [, setConfig] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fieldValues, setFieldValues] = useState({});
const [fieldErrors, setFieldErrors] = useState({});
const [validator, setValidator] = useState(null);
// Cron expression with 6 space-separated fields (server-compatible)
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
/**
* Converts Ajv errors to a field error map suitable for UI display.
* @param {Ajv.ErrorObject[]} errors
* @param {Set<string>} allowedPaths - Field paths in the current section to filter on
* @returns {Record<string, string>}
*/
const ajvErrorsToFieldErrors = (errors, allowedPaths) => {
const result = {};
if (!Array.isArray(errors)) return result;
for (const err of errors) {
const fieldPath = (err.instancePath || '').replace(/^\/|\/$/g, '').replace(/\//g, '.');
if (allowedPaths.has(fieldPath)) {
result[fieldPath] = err.message || 'Invalid value';
}
}
return result;
};
const filteredSections = configurationSections
.map(section => ({
...section,
fields: section.fields.filter(field => {
if (user?.isAdmin && field.roles.includes(ROLES.ADMIN)) {
return true;
}
if (!user?.isAdmin && field.roles.includes(ROLES.USER)) {
return true;
}
return false;
})
}))
.filter(section => section.fields.length > 0);
/**
* Builds an Ajv validator instance for the provided JSON Schema.
* @param {object} schema - Derived per-scope JSON schema
* @returns {Ajv.ValidateFunction}
*/
const buildValidator = schema => {
const ajv = new Ajv({allErrors: true, strict: false});
ajv.addFormat('cron6', CRON6_REGEX);
return ajv.compile(schema);
};
useEffect(() => {
const loadConfiguration = async () => {
try {
setLoading(true);
setError(null);
// Fetch config and schema in parallel
const [data, schema] = await Promise.all([fetchConfiguration(), fetchConfigurationSchema()]);
setConfig(data);
const initialValues = {};
filteredSections.forEach(section => {
section.fields.forEach(field => {
let value = getNestedValue(data, field.path, '');
// Stringify JSON values for json type fields
if (field.type === 'json' && value !== '') {
try {
value = JSON.stringify(value, null, 2);
} catch (error) {
console.warn(`Failed to stringify JSON for field ${field.path}:`, error);
}
}
initialValues[field.path] = value;
});
});
setFieldValues(initialValues);
// Build Ajv validator from schema
const validateFn = buildValidator(schema);
setValidator(() => validateFn);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
loadConfiguration();
}, []);
const handleFieldChange = (path, value) => {
setFieldValues(prev => ({
...prev,
[path]: value
}));
// Clear error for this field when user modifies it
if (fieldErrors[path]) {
setFieldErrors(prev => {
const newErrors = {...prev};
delete newErrors[path];
return newErrors;
});
}
};
const handleSaveSection = async sectionTitle => {
const section = filteredSections.find(s => s.title === sectionTitle);
if (!section) return;
// Clear previous errors for this section
const newFieldErrors = {...fieldErrors};
section.fields.forEach(field => {
delete newFieldErrors[field.path];
});
setFieldErrors(newFieldErrors);
const changedObjects = section.fields.map(field => {
const obj = {};
let value = fieldValues[field.path];
// Parse JSON values for json type fields
if (field.type === 'json') {
try {
value = JSON.parse(value);
} catch (error) {
// If JSON parsing fails, keep the string value and let backend validation handle it
console.warn(`Failed to parse JSON for field ${field.path}:`, error);
}
}
if (field.type === 'checkbox') {
value = Boolean(value);
}
obj[field.path] = value;
return obj;
});
const mergedConfig = mergeNestedObjects(changedObjects);
// Client-side validation using Ajv and the server-provided schema
if (validator) {
const valid = validator(mergedConfig);
if (!valid) {
const allowed = new Set(section.fields.map(f => f.path));
const errorsMap = ajvErrorsToFieldErrors(validator.errors, allowed);
if (Object.keys(errorsMap).length > 0) {
setFieldErrors(prev => ({...prev, ...errorsMap}));
throw new Error('Validation failed');
}
}
}
try {
await updateConfiguration(mergedConfig);
} catch (error) {
// Handle validation errors from backend
if (error.error && error.error.details && Array.isArray(error.error.details)) {
const errors = {};
error.error.details.forEach(detail => {
if (detail.path && detail.message) {
// Find the field that contains this error path
let fieldPath = null;
// Check each field in the current section to see if the error path starts with the field path
section.fields.forEach(field => {
const fieldPathParts = field.path.split('.');
const errorPathParts = detail.path;
// Check if the error path starts with the field path
if (fieldPathParts.length <= errorPathParts.length) {
let matches = true;
for (let i = 0; i < fieldPathParts.length; i++) {
if (fieldPathParts[i] !== errorPathParts[i]) {
matches = false;
break;
}
}
if (matches) {
fieldPath = field.path;
}
}
});
// If we found a matching field, use it; otherwise use the full path
if (fieldPath) {
errors[fieldPath] = detail.message;
} else {
// Fallback: use the full path
errors[detail.path.join('.')] = detail.message;
}
}
});
setFieldErrors(prev => ({...prev, ...errors}));
} else {
// Handle other types of errors
console.error('Save error:', error);
}
throw error; // Re-throw to trigger error state in Button component
}
};
if (loading) {
return <div className={styles.loading}>Loading configuration...</div>;
}
if (error) {
return <div className={styles.error}>Error: {error}</div>;
}
return (
<div className={styles.configuration}>
{filteredSections.map((section, index) => {
return (
<ExpandableSection key={index} title={section.title}>
{section.fields.map(field => {
// Select component based on type
let FieldComponent;
switch (field.type) {
case 'select':
FieldComponent = SelectField;
break;
case 'json':
FieldComponent = JsonField;
break;
default:
FieldComponent = ConfigurationInput;
break;
}
return (
<FieldComponent
key={field.path}
label={field.label}
value={fieldValues[field.path] || ''}
onChange={value => handleFieldChange(field.path, value)}
type={field.type}
error={fieldErrors[field.path]}
min={field.min}
max={field.max}
options={field.options}
// description={field.description}
/>
);
})}
<Button onClick={() => handleSaveSection(section.title)} errorText='FAILED'>
SAVE
</Button>
</ExpandableSection>
);
})}
</div>
);
}

View File

@ -0,0 +1,66 @@
.configuration {
}
.title {
margin-bottom: 24px;
color: #333;
font-size: 24px;
font-weight: 600;
}
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 200px;
font-size: 16px;
color: #666;
}
.error {
padding: 16px;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 16px;
}
.saveError {
padding: 12px;
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
border-radius: 4px;
margin-bottom: 16px;
font-size: 14px;
}
.saveButton {
padding: 16px;
background: rgb(255, 111, 61);
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
margin-top: 16px;
width: 160px;
}
.saveButton:hover:not(:disabled) {
background: rgb(255, 149, 113);
}
.saveButton:disabled {
background: #6c757d;
cursor: not-allowed;
}
.saveButton.loading {
cursor: not-allowed;
height: auto;
display: inline-block;
}

View File

@ -0,0 +1,32 @@
import Input from '../Input';
import styles from './styles.module.css';
export default function ConfigurationInput({
label,
value,
onChange,
placeholder = '',
type = 'text',
error = null,
min = null,
max = null,
description = null
}) {
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
<Input
type={type}
value={value}
onChange={onChange}
placeholder={placeholder}
min={min}
max={max}
error={error}
/>
{/* {error && <div className={styles.errorMessage}>{error}</div>} */}
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 300px;
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -0,0 +1,20 @@
import {useState} from 'react';
import styles from './styles.module.css';
export default function ExpandableSection({title, children}) {
const [isExpanded, setIsExpanded] = useState(false);
const toggleExpanded = () => {
setIsExpanded(!isExpanded);
};
return (
<div className={styles.expandableSection}>
<div className={styles.header} onClick={toggleExpanded}>
<span className={styles.title}>{title}</span>
<span className={`${styles.arrow} ${isExpanded ? styles.expanded : styles.collapsed}`}></span>
</div>
{isExpanded && <div className={styles.content}>{children}</div>}
</div>
);
}

View File

@ -0,0 +1,44 @@
.expandableSection {
border: 1px solid #e0e0e0;
border-radius: 4px;
margin-bottom: 16px;
background: white;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
cursor: pointer;
background: #f8f9fa;
border-bottom: 1px solid #e0e0e0;
transition: background-color 0.2s ease;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.header:hover {
background: #e9ecef;
}
.title {
font-weight: 600;
color: #333;
}
.arrow {
font-size: 12px;
color: #666;
transition: transform 0.2s ease;
}
.arrow.collapsed {
transform: rotate(-90deg);
}
.content {
padding: 16px;
}

View File

@ -0,0 +1,39 @@
import {Link} from 'react-router-dom';
import {useSelector} from 'react-redux';
import {selectIsAuthenticated} from '../../store/slices/userSlice';
import {logout} from '../../api';
import Logo from '@assets/AppLogo.svg';
import styles from './styles.module.css';
function Header() {
const isAuthenticated = useSelector(selectIsAuthenticated);
const handleLogout = async () => {
try {
await logout();
// Reload the page after successful logout
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
// Still reload the page even if logout API fails
window.location.reload();
}
};
return (
<div className={styles.header}>
<div className={styles.headerContent}>
<Link to='/'>
<img src={Logo} alt='ONLYOFFICE' style={{cursor: 'pointer'}} />
</Link>
{isAuthenticated && (
<button onClick={handleLogout} className={styles.logoutButton}>
Logout
</button>
)}
</div>
</div>
);
}
export default Header;

View File

@ -0,0 +1,42 @@
.header {
height: 48px;
position: fixed;
width: 100%;
top: 0px;
background: #333333;
z-index: 2;
}
.headerContent {
height: 23px;
margin-top: calc(48px / 2 - 23px / 2);
display: flex;
align-items: center;
justify-content: space-between;
width: 60%;
margin-left: 20%;
}
.headerContent img {
height: 23px;
width: auto;
margin-left: 0;
display: block;
}
.logoutButton {
background: #666666;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
}
.logoutButton:hover {
background: #888888;
}
@media (max-width: 1200px) {
.headerContent {
width: 90%;
margin-left: 5%;
}
}

View File

@ -0,0 +1,40 @@
import styles from './styles.module.css';
export default function Input({
_label,
_description,
value,
onChange,
placeholder = '',
type = 'text',
error = null,
className = '',
onKeyDown = null,
min = null,
max = null
}) {
if (type === 'checkbox') {
return (
<input
type="checkbox"
checked={Boolean(value)}
onChange={e => onChange(e.target.checked)}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
/>
);
}
return (
<input
type={type}
value={value}
onChange={e => onChange(type === 'number' ? e.target.valueAsNumber : e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
className={`${styles.input} ${error ? styles.inputError : ''} ${className}`}
min={min}
max={max}
/>
);
}

View File

@ -0,0 +1,55 @@
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
font-weight: 500;
color: #333;
margin: 0;
}
.input {
padding: 11px 20px;
border: 1px solid rgb(170, 170, 170);
border-radius: 3px;
font-size: 12px;
transition: border-color 0.2s ease;
background-color: rgb(249, 249, 249);
}
.input:focus {
outline: none;
border-color: #000000;
}
/* Remove number input spinners */
.input[type='number']::-webkit-outer-spin-button,
.input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.input[type='number'] {
-moz-appearance: textfield;
}
.inputError {
border-color: #dc3545;
}
.description {
font-size: 11px;
color: #666;
margin: 0;
line-height: 1.3;
}
.error {
font-size: 11px;
color: #dc3545;
margin: 0;
line-height: 1.3;
}

View File

@ -0,0 +1,81 @@
import {useState, useEffect} from 'react';
import styles from './styles.module.css';
export default function JsonField({
label,
value,
onChange,
placeholder = '',
error = null,
description = null
}) {
const [jsonError, setJsonError] = useState(null);
const [isValidJson, setIsValidJson] = useState(true);
// Validate JSON on every change
useEffect(() => {
if (value.trim() === '') {
setJsonError(null);
setIsValidJson(true);
return;
}
try {
JSON.parse(value);
setJsonError(null);
setIsValidJson(true);
} catch (err) {
setJsonError(`Invalid JSON: ${err.message}`);
setIsValidJson(false);
}
}, [value]);
const formatJson = () => {
if (value.trim() !== '') {
try {
const parsed = JSON.parse(value);
const formatted = JSON.stringify(parsed, null, 2);
onChange(formatted);
} catch (err) {
// If JSON is invalid, don't format
console.warn('Cannot format invalid JSON:', err);
}
}
};
const lines = value.split('\n');
const lineNumbers = lines.map((_, index) => index + 1);
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
<div className={styles.jsonContainer}>
<div className={styles.lineNumbers}>
{lineNumbers.map(num => (
<div key={num} className={styles.lineNumber}>{num}</div>
))}
</div>
<textarea
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={`${styles.textarea} ${error || jsonError ? styles.inputError : ''}`}
rows={10}
spellCheck={false}
/>
<button
type="button"
className={styles.formatButton}
onClick={formatJson}
disabled={!isValidJson}
title="Format JSON"
>
Format
</button>
</div>
{(error || jsonError) && <div className={styles.errorMessage}>{error || jsonError}</div>}
</div>
</div>
);
}

View File

@ -0,0 +1,116 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 600px;
}
.jsonContainer {
display: flex;
position: relative;
}
.lineNumbers {
background: #f8f9fa;
border: 1px solid #ccc;
border-right: none;
border-radius: 4px 0 0 4px;
padding: 8px 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
line-height: 1.4;
color: #6c757d;
user-select: none;
min-width: 40px;
text-align: right;
}
.lineNumber {
height: 1.4em;
display: flex;
align-items: center;
justify-content: flex-end;
}
.textarea {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 0 4px 4px 0;
border-left: none;
font-size: 14px;
width: 100%;
box-sizing: border-box;
font-family: 'Courier New', monospace;
resize: vertical;
min-height: 200px;
line-height: 1.4;
flex: 1;
outline: none;
}
.textarea:focus {
outline: none !important;
box-shadow: none !important;
}
.jsonContainer:focus-within .lineNumbers {
border-color: #007bff;
outline: none;
}
.inputError {
border-color: #dc3545;
}
.formatButton {
position: absolute;
bottom: 8px;
right: 8px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.formatButton:hover {
opacity: 1;
}
.formatButton:disabled {
background: #6c757d;
cursor: not-allowed;
opacity: 0.5;
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -0,0 +1,30 @@
import styles from './styles.module.css';
export default function SelectField({
label,
value,
onChange,
options = [],
error = null,
description = null
}) {
return (
<div className={styles.field}>
<label className={styles.label}>{label}</label>
<div className={styles.inputContainer}>
<select
value={value}
onChange={e => onChange(e.target.value)}
className={`${styles.select} ${error ? styles.inputError : ''}`}
>
{options.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* {error && <div className={styles.errorMessage}>{error}</div>} */}
</div>
</div>
);
}

View File

@ -0,0 +1,58 @@
.field {
margin-bottom: 16px;
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.label {
display: block;
min-width: 120px;
flex-shrink: 0;
color: rgb(68, 68, 68);
font-weight: 400;
font-size: 13px;
margin-top: 11px;
}
.label::after {
content: ':';
}
.inputContainer {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
max-width: 300px;
}
.select {
padding: 8px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.select:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.inputError {
border-color: #dc3545;
}
.inputError:focus {
border-color: #dc3545;
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.25);
}
.errorMessage {
color: #dc3545;
font-size: 12px;
}

View File

@ -0,0 +1,41 @@
import styles from './styles.module.css';
export default function InfoTable({caption, editor, viewer, desc}) {
return (
<div className={styles.container}>
{caption && <div className={styles.sectionHeader}>{caption}</div>}
<div className={styles.editorsLabel}>EDITORS</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{editor.map((v, i) => (
<div key={i} className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => (
<div key={i} className={styles.labelCell}>
{d}
</div>
))}
</div>
<div className={styles.viewerLabel}>LIVE VIEWER</div>
<div className={styles.divider}></div>
<div className={styles.row}>
{viewer.map((v, i) => (
<div key={i} className={`${styles.valueCell} ${desc[i] === 'Remaining' ? styles.remainingValue : ''}`}>
{v[0]}
</div>
))}
</div>
<div className={styles.row}>
{desc.map((d, i) => (
<div key={i} className={styles.labelCell}>
{d}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
.container {
margin-bottom: 25px;
}
.sectionHeader {
background: #f5f5f5;
font-weight: 400;
padding: 6px 8px;
margin-bottom: 8px;
}
.row {
display: flex;
justify-content: start;
}
.valueCell {
font-size: 22px;
padding: 8px 0;
width: 25%;
padding-left: 10px;
}
.labelCell {
font-size: 13px;
color: #555;
padding-bottom: 8px;
width: 25%;
padding-left: 10px;
}
.editorsLabel,
.viewerLabel {
font-weight: 600;
font-size: 12px;
padding: 8px 0 0 10px;
}
.viewerLabel {
margin-top: 15px;
}
.divider {
border-bottom: 1px solid #ddd;
margin: 2px 10px;
}
.remainingValue {
color: rgb(1, 125, 28);
}

View File

@ -0,0 +1,10 @@
import styles from './styles.module.css';
export default function TopBlock({title, children}) {
return (
<div className={styles.block}>
<div className={styles.title}>{title}</div>
<div className={styles.content}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
.block {
flex: 1;
min-width: 180px;
}
.title {
font-weight: 400;
font-size: 18px;
border-bottom: 1px solid #ddd;
padding: 0 0 5px 10px;
}
.content {
font-size: 12px;
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
padding-left: 10px;
}

View File

@ -0,0 +1,75 @@
import {useQuery} from '@tanstack/react-query';
import TopBlock from './TopBlock/index';
import InfoTable from './InfoTable/index';
import styles from './styles.module.css';
import {fetchStatistics} from '../../api';
export default function Statistics() {
const {data, isLoading, error} = useQuery({
queryKey: ['statistics'],
queryFn: fetchStatistics
});
if (isLoading) return <div>Loading statistics...</div>;
if (error) return <div style={{color: 'red'}}>Error: {error.message}</div>;
if (!data) return null;
const {licenseInfo, quota, connectionsStat, serverInfo} = data;
const buildDate = licenseInfo.buildDate ? new Date(licenseInfo.buildDate).toLocaleDateString() : '';
const buildBlock = (
<TopBlock title='Build'>
<div>Type: {licenseInfo.packageType === 0 ? 'Open source' : licenseInfo.packageType === 1 ? 'Enterprise Edition' : 'Developer Edition'}</div>
<div>
Version: {serverInfo.buildVersion}.{serverInfo.buildNumber}
</div>
<div>Release date: {buildDate}</div>
</TopBlock>
);
const licenseBlock = (
<TopBlock title='License'>
{licenseInfo.startDate === null ? (
'No license'
) : (
<div>Start date: {licenseInfo.startDate ? new Date(licenseInfo.startDate).toLocaleDateString() : ''}</div>
)}
</TopBlock>
);
const connectionsBlock = (
<TopBlock title='Connections limit'>
<div>Editors: {licenseInfo.connections}</div>
<div>Live Viewer: {licenseInfo.connectionsView}</div>
</TopBlock>
);
const valueEdit = licenseInfo.connections - (quota.edit.connectionsCount || 0);
const valueView = licenseInfo.connectionsView - (quota.view.connectionsCount || 0);
const editor = [
[quota.edit.connectionsCount || 0, ''],
[valueEdit, valueEdit > licenseInfo.connections * 0.1 ? 'normal' : 'critical']
];
const viewer = [
[quota.view.connectionsCount || 0, ''],
[valueView, valueView > licenseInfo.connectionsView * 0.1 ? 'normal' : 'critical']
];
const desc = ['Active', 'Remaining'];
const peaksDesc = ['Last Hour', '24 Hours', 'Week', 'Month'];
const peaksEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.max || 0]);
const peaksViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.max || 0]);
const avrEditor = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.edit?.avr || 0]);
const avrViewer = ['hour', 'day', 'week', 'month'].map(k => [connectionsStat?.[k]?.liveview?.avr || 0]);
return (
<div>
<div className={styles.topRow}>
{buildBlock}
{licenseBlock}
{connectionsBlock}
</div>
<InfoTable caption='Current connections' editor={editor} viewer={viewer} desc={desc} />
<InfoTable caption='Peaks' editor={peaksEditor} viewer={peaksViewer} desc={peaksDesc} />
<InfoTable caption='Average' editor={avrEditor} viewer={avrViewer} desc={peaksDesc} />
</div>
);
}

View File

@ -0,0 +1,5 @@
.topRow {
display: flex;
margin-bottom: 24px;
gap: 24px;
}

View File

@ -0,0 +1,20 @@
import styles from './styles.module.css';
export default function Tabs({tabs, activeTab, onTabChange, children}) {
return (
<div>
<div className={styles.tabContainer}>
{tabs.map(tab => (
<div
key={tab.key}
onClick={() => onTabChange(tab.key)}
className={activeTab === tab.key ? `${styles.tab} ${styles.tabActive}` : styles.tab}
>
{tab.label}
</div>
))}
</div>
<div className={styles.tabContent}>{children}</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
.tabContainer {
display: flex;
border-bottom: 1px solid rgb(239, 239, 239);
margin-bottom: 24px;
}
.tab {
padding: 5px 12px;
cursor: pointer;
border-bottom: 2px solid transparent;
color: rgb(68, 68, 68);
font-size: 13px;
font-weight: 600;
transition:
color 0.2s,
border-bottom 0.2s;
}
.tabActive {
border-bottom: 2px solid rgb(255, 111, 61);
color: rgb(255, 111, 61);
font-weight: bold;
}
.tabContent {
/* Add any content-specific styles here */
}

View File

@ -0,0 +1,172 @@
// Role enum for access control
export const ROLES = {
ADMIN: 'admin',
USER: 'user'
};
export const configurationSections = [
{
title: 'Garbage Collector',
fields: [
{
path: 'services.CoAuthoring.expire.filesCron',
label: 'Files Cron',
type: 'text',
roles: [ROLES.ADMIN],
description: 'Cron expression for file cleanup (admin only)'
},
{
path: 'services.CoAuthoring.expire.documentsCron',
label: 'Documents Cron',
type: 'text',
roles: [ROLES.ADMIN],
description: 'Cron expression for document cleanup (admin only)'
},
{
path: 'services.CoAuthoring.expire.files',
label: 'Files Expiration Time',
type: 'number',
min: 0,
roles: [ROLES.ADMIN],
description: 'Files expiration time in seconds (admin only)'
},
{
path: 'services.CoAuthoring.expire.filesremovedatonce',
label: 'Files Removed At Once',
type: 'number',
min: 0,
roles: [ROLES.ADMIN],
description: 'Number of files to remove at once (admin only)'
},
{
path: 'services.CoAuthoring.expire.sessionidle',
label: 'Session Idle Timeout',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Session idle timeout (e.g., "1h", "30m")'
},
{
path: 'services.CoAuthoring.expire.sessionabsolute',
label: 'Session Absolute Timeout',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Session absolute timeout (e.g., "30d", "24h")'
}
]
},
{
title: 'Auto Assembly',
fields: [
{
path: 'services.CoAuthoring.autoAssembly.step',
label: 'Auto Assembly Step',
type: 'select',
options: [
{value: '1m', label: '1 minute'},
{value: '5m', label: '5 minutes'},
{value: '10m', label: '10 minutes'},
{value: '15m', label: '15 minutes'},
{value: '30m', label: '30 minutes'}
],
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Step interval for auto assembly process'
}
]
},
{
title: 'File Size Limits',
fields: [
{
path: 'FileConverter.converter.maxDownloadBytes',
label: 'Max Download Bytes',
type: 'number',
min: 0,
max: 104857600,
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Maximum number of bytes allowed for download (max: 100MB)'
},
{
path: 'FileConverter.converter.inputLimits',
label: 'Input Limits',
type: 'json',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'File type limits for conversion. Format: [{"type": "docx;dotx", "zip": {"uncompressed": "50MB", "template": "*.xml"}}]'
}
]
},
{
title: 'WOPI Configuration',
fields: [
{
path: 'wopi.enable',
label: 'Enable WOPI',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Enable WOPI (Web Application Open Platform Interface) support'
}
]
},
{
title: 'Email Configuration',
fields: [
{
path: 'email.smtpServerConfiguration.host',
label: 'SMTP Host',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP server hostname'
},
{
path: 'email.smtpServerConfiguration.port',
label: 'SMTP Port',
type: 'number',
min: 1,
max: 65535,
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP server port number'
},
{
path: 'email.smtpServerConfiguration.auth.user',
label: 'SMTP Username',
type: 'text',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP authentication username'
},
{
path: 'email.smtpServerConfiguration.auth.pass',
label: 'SMTP Password',
type: 'password',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'SMTP authentication password'
},
{
path: 'email.connectionConfiguration.disableFileAccess',
label: 'Disable File Access',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Disable file access for email connections'
},
{
path: 'email.connectionConfiguration.disableUrlAccess',
label: 'Disable URL Access',
type: 'checkbox',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Disable URL access for email connections'
},
{
path: 'email.contactDefaults.from',
label: 'Default From Email',
type: 'email',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Default sender email address'
},
{
path: 'email.contactDefaults.to',
label: 'Default To Email',
type: 'email',
roles: [ROLES.ADMIN, ROLES.USER],
description: 'Default recipient email address'
}
]
}
];

View File

@ -0,0 +1,26 @@
import {StrictMode} from 'react';
import ReactDOM from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import App from './App';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 60 * 1000
}
}
});
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>
);

View File

@ -0,0 +1,28 @@
import {useState} from 'react';
import Statistics from '../components/Statistics';
import Configuration from '../components/Configuration';
import Tabs from '../components/Tabs';
import styles from './styles.module.css';
const tabs = [
{key: 'statistics', label: 'STATISTICS'},
{key: 'configuration', label: 'CONFIGURATION'}
];
const tabComponents = {
statistics: <Statistics />,
configuration: <Configuration />
};
export default function Home() {
const [activeTab, setActiveTab] = useState('statistics');
return (
<div className={styles.container}>
<h1 className={styles.title}>Document Server Admin Panel</h1>
<Tabs tabs={tabs} activeTab={activeTab} onTabChange={setActiveTab}>
{tabComponents[activeTab]}
</Tabs>
</div>
);
}

View File

@ -0,0 +1,72 @@
import {useState, useRef} from 'react';
import {useDispatch} from 'react-redux';
import {loginUser} from '../../store/slices/userSlice';
import Input from '../../components/Input';
import Button from '../../components/Button';
import styles from './styles.module.css';
export default function Login() {
const [tenantName, setTenantName] = useState('localhost');
const [secret, setSecret] = useState('');
const [error, setError] = useState('');
const dispatch = useDispatch();
const buttonRef = useRef();
const handleSubmit = async () => {
setError('');
try {
await dispatch(loginUser({tenantName, secret})).unwrap();
} catch (error) {
setError(error || 'Invalid credentials. Please try again.');
throw error; // Re-throw to trigger error state in Button component
}
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
if (buttonRef.current) {
buttonRef.current.click();
}
}
};
return (
<div className={styles.loginContainer}>
<div className={styles.loginCard}>
<h1 className={styles.title}>ONLYOFFICE Admin Panel</h1>
<p className={styles.subtitle}>Enter your secret key to access the admin panel</p>
<p className={styles.description}>The session is valid for 60 minutes.</p>
<div className={styles.form}>
<div className={styles.inputGroup}>
<Input
type='text'
value={tenantName}
onChange={setTenantName}
placeholder='Enter your tenant name'
description='The name of your tenant organization'
onKeyDown={handleKeyDown}
/>
</div>
<div className={styles.inputGroup}>
<Input
type='password'
value={secret}
onChange={setSecret}
placeholder='Enter your secret key'
description='The secret key associated with your tenant'
error={error}
onKeyDown={handleKeyDown}
/>
</div>
<Button ref={buttonRef} onClick={handleSubmit} errorText='FAILED'>
LOGIN
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,51 @@
.loginContainer {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px); /* Account for header height */
background: white;
padding: 20px;
}
.loginCard {
background: white;
width: 100%;
max-width: 500px;
text-align: center;
}
.title {
color: #333;
font-size: 32px;
font-weight: bold;
margin: 0 0 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.subtitle {
color: #333;
font-size: 16px;
font-weight: bold;
margin: 0 0 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.description {
color: #333;
font-size: 14px;
margin: 0 0 32px 0;
line-height: 1.5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.form {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.inputGroup {
width: 100%;
max-width: 300px;
}

View File

@ -0,0 +1,9 @@
.container {
padding-top: 90px;
}
.title {
color: rgb(51, 51, 51);
margin-bottom: 10px;
font-size: 2rem;
font-weight: 600;
}

View File

@ -0,0 +1,8 @@
import {configureStore} from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
export const store = configureStore({
reducer: {
user: userReducer
}
});

View File

@ -0,0 +1,102 @@
import {createSlice, createAsyncThunk} from '@reduxjs/toolkit';
import {fetchCurrentUser, login} from '../../api';
export const fetchUser = createAsyncThunk('user/fetchUser', async (_, {rejectWithValue}) => {
try {
return await fetchCurrentUser();
} catch (error) {
return rejectWithValue(error.message);
}
});
export const loginUser = createAsyncThunk('user/loginUser', async ({tenantName, secret}, {rejectWithValue}) => {
try {
const response = await login({tenantName, secret});
return response;
} catch (error) {
return rejectWithValue(error.message);
}
});
const initialState = {
user: null,
loading: false,
loginLoading: false,
error: null,
isAuthenticated: false
};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
// Clear user data (logout)
clearUser: state => {
state.user = null;
state.isAuthenticated = false;
state.error = null;
},
// Set user data manually
setUser: (state, action) => {
state.user = action.payload;
state.isAuthenticated = true;
state.error = null;
},
// Clear error
clearError: state => {
state.error = null;
}
},
extraReducers: builder => {
builder
// Fetch user cases
.addCase(fetchUser.pending, state => {
state.loading = true;
state.error = null;
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.loading = false;
state.user = {
tenant: action.payload.tenant,
isAdmin: action.payload.isAdmin
};
state.isAuthenticated = true;
state.error = null;
})
.addCase(fetchUser.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
state.isAuthenticated = false;
})
// Login cases
.addCase(loginUser.pending, state => {
state.loginLoading = true;
state.error = null;
})
.addCase(loginUser.fulfilled, (state, action) => {
state.loginLoading = false;
state.user = {
tenant: action.payload.tenant,
isAdmin: action.payload.isAdmin
};
state.isAuthenticated = true;
state.error = null;
})
.addCase(loginUser.rejected, (state, action) => {
state.loginLoading = false;
state.error = action.payload;
state.isAuthenticated = false;
});
}
});
export const {clearUser, setUser, clearError} = userSlice.actions;
// Selectors
export const selectUser = state => state.user.user;
export const selectUserLoading = state => state.user.loading;
export const selectLoginLoading = state => state.user.loginLoading;
export const selectUserError = state => state.user.error;
export const selectIsAuthenticated = state => state.user.isAuthenticated;
export default userSlice.reducer;

View File

@ -0,0 +1,16 @@
export function getNestedValue(obj, path, defaultValue = '') {
if (!obj || !path) return defaultValue;
const keys = path.split('.');
let current = obj;
for (const key of keys) {
if (current && typeof current === 'object' && key in current) {
current = current[key];
} else {
return defaultValue;
}
}
return current !== undefined ? current : defaultValue;
}

View File

@ -0,0 +1,27 @@
export function mergeNestedObjects(objects) {
const result = {};
for (const obj of objects) {
for (const key in obj) {
if (Object.hasOwn(obj, key)) {
const keys = key.split('.');
let current = result;
for (let i = 0; i < keys.length; i++) {
const part = keys[i];
if (i === keys.length - 1) {
current[part] = obj[key];
} else {
if (!current[part]) {
current[part] = {};
}
current = current[part];
}
}
}
}
}
return result;
}

View File

@ -0,0 +1,82 @@
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.[contenthash].js',
path: path.resolve(__dirname, 'build'),
publicPath: '/'
},
devServer: {
static: {
directory: path.join(__dirname, 'build'),
publicPath: '/'
},
port: 3000,
open: true,
historyApiFallback: true
},
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, 'public', 'index.html')
}),
new CopyPlugin({
patterns: [
{
context: path.resolve(__dirname, 'public'),
from: 'images/*.*'
},
{
context: path.resolve(__dirname),
from: 'config.json'
}
]
}),
new webpack.DefinePlugin({
'process.env.REACT_APP_BACKEND_URL': JSON.stringify(process.env.REACT_APP_BACKEND_URL)
})
],
module: {
rules: [
{
test: /\.(js)$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: [['@babel/preset-react', {runtime: 'automatic'}], '@babel/preset-env']
}
}
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader']
},
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'static/[hash][ext]'
}
}
]
},
resolve: {
extensions: ['', '.js'],
alias: {
'@components': path.resolve(__dirname, 'src/components'),
'@screen': path.resolve(__dirname, 'src/screen'),
'@services': path.resolve(__dirname, 'src/services'),
'@store': path.resolve(__dirname, 'src/store'),
'@utility': path.resolve(__dirname, 'src/utility'),
'@assets': path.resolve(__dirname, 'src/assets')
}
}
};

4
AdminPanel/server/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
runtime/config.json
node_modules/

755
AdminPanel/server/package-lock.json generated Normal file
View File

@ -0,0 +1,755 @@
{
"name": "onlyoffice-adminpanel-server",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ=="
},
"@hapi/topo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
},
"@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
"requires": {
"@hapi/hoek": "^9.0.0"
}
},
"@sideway/formula": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg=="
},
"@sideway/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="
},
"accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"requires": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
}
},
"ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"requires": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
}
},
"ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"requires": {
"ajv": "^8.0.0"
}
},
"apicache": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/apicache/-/apicache-1.6.3.tgz",
"integrity": "sha512-jS3VfUFpQ9BesFQZcdd1vVYg3ZsO2kGPmTJHqycIYPAQs54r74CRiyj8DuzJpwzLwIfCBYzh4dy9Jt8xYbo27w=="
},
"array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="
},
"body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"requires": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
}
},
"buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
},
"bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
}
},
"call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
}
},
"config": {
"version": "3.3.12",
"resolved": "https://registry.npmjs.org/config/-/config-3.3.12.tgz",
"integrity": "sha512-Vmx389R/QVM3foxqBzXO8t2tUikYZP64Q6vQxGrsMpREeJc/aWRnPRERXWsYzOHAumx/AOoILWe6nU3ZJL+6Sw==",
"requires": {
"json5": "^2.2.3"
}
},
"content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"requires": {
"safe-buffer": "5.2.1"
}
},
"content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="
},
"cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
},
"cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"requires": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
}
},
"cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"requires": {
"object-assign": "^4",
"vary": "^1"
}
},
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
},
"dependencies": {
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}
}
},
"depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="
},
"destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"requires": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
}
},
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="
},
"es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
},
"es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
},
"es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"requires": {
"es-errors": "^1.3.0"
}
},
"escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="
},
"express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"requires": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"dependencies": {
"cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="
}
}
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-uri": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="
},
"finalhandler": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
"integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
"requires": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "2.4.1",
"parseurl": "~1.3.3",
"statuses": "2.0.1",
"unpipe": "~1.0.0"
}
},
"forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="
},
"fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="
},
"function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
},
"get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
}
},
"get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"requires": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
}
},
"gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
},
"has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
},
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"requires": {
"function-bind": "^1.1.2"
}
},
"http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
"requires": {
"depd": "2.0.0",
"inherits": "2.0.4",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"toidentifier": "1.0.1"
}
},
"iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"requires": {
"safer-buffer": ">= 2.1.2 < 3"
}
},
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="
},
"joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"requires": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
"@sideway/address": "^4.1.5",
"@sideway/formula": "^3.0.1",
"@sideway/pinpoint": "^2.0.0"
}
},
"json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
},
"json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
},
"jsonwebtoken": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
"requires": {
"jws": "^3.2.2",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1",
"semver": "^7.5.4"
}
},
"jwa": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"requires": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"requires": {
"jwa": "^1.4.1",
"safe-buffer": "^5.0.1"
}
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
},
"lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
},
"lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
},
"lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
},
"lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
},
"lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
},
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="
},
"merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="
},
"methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="
},
"mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="
},
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
},
"mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"requires": {
"mime-db": "1.52.0"
}
},
"ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="
},
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
"object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="
},
"on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"requires": {
"ee-first": "1.1.1"
}
},
"parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
},
"path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
},
"proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"requires": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
}
},
"qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
"integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
"requires": {
"side-channel": "^1.0.6"
}
},
"range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="
},
"raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"requires": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
}
},
"require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="
},
"send": {
"version": "0.19.0",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
"requires": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~1.0.2",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "2.4.1",
"range-parser": "~1.2.1",
"statuses": "2.0.1"
},
"dependencies": {
"encodeurl": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="
}
}
},
"serve-static": {
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
"requires": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "0.19.0"
}
},
"setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"requires": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
}
},
"side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"requires": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
}
},
"side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"requires": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
}
},
"side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"requires": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
}
},
"statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="
},
"toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
},
"type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"requires": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
}
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="
},
"utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="
},
"vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="
}
}
}

View File

@ -0,0 +1,32 @@
{
"name": "adminpanel",
"version": "1.0.0",
"homepage": "https://www.onlyoffice.com",
"private": true,
"bin": {
"adminpanel": "sources/server.js"
},
"main": "sources/server.js",
"type": "commonjs",
"scripts": {
"start": "cd .. && npm --prefix client run build && set \"NODE_CONFIG_DIR=../Common/config\" && set \"NODE_ENV=development-windows\" && node server/sources/server.js"
},
"dependencies": {
"apicache": "^1.6.3",
"config": "^3.3.11",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.19.2",
"ajv": "^8.17.1",
"ajv-formats": "^2.1.1",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3"
},
"pkg": {
"scripts": [
"../../DocService/sources/editorDataMemory.js",
"../../DocService/sources/editorDataRedis.js"
]
}
}

View File

@ -0,0 +1,97 @@
'use strict';
const config = require('config');
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs').promises;
const path = require('path');
const cookieParser = require('cookie-parser');
const tenantBaseDir = config.get('tenants.baseDir');
const defaultTenantSecret = config.get('services.CoAuthoring.secret.browser.string');
const filenameSecret = config.get('tenants.filenameSecret');
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
const router = express.Router();
router.use(express.json());
router.use(cookieParser());
router.get('/me', async (req, res) => {
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({error: 'Unauthorized'});
}
const decoded = jwt.verify(token, adminPanelJwtSecret);
res.json(decoded);
} catch {
res.status(401).json({error: 'Unauthorized'});
}
});
router.post('/login', async (req, res) => {
try {
const {tenantName, secret} = req.body;
if (!tenantName || !secret) {
return res.status(400).json({error: 'Tenant name and secret are required'});
}
const tenant = await verifyTenantCredentials(tenantName, secret);
if (!tenant) {
return res.status(401).json({error: 'Invalid tenant name or secret'});
}
const token = jwt.sign({...tenant}, adminPanelJwtSecret, {expiresIn: '1h'});
res.cookie('accessToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000,
path: '/'
});
res.json({tenant: tenant.tenant, isAdmin: tenant.isAdmin});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({error: 'Internal server error'});
}
});
router.post('/logout', async (req, res) => {
try {
res.clearCookie('accessToken', {
httpOnly: true,
sameSite: 'strict',
path: '/'
});
res.json({message: 'Logged out successfully'});
} catch {
res.status(500).json({error: 'Internal server error'});
}
});
async function verifyTenantCredentials(tenantName, secret) {
if (tenantName === config.get('tenants.defaultTenant') && secret === defaultTenantSecret) {
return {tenant: tenantName, isAdmin: true};
}
if (tenantBaseDir) {
try {
const tenantPath = path.join(tenantBaseDir, tenantName);
const tenantSecretPath = path.join(tenantPath, filenameSecret);
const tenantSecret = await fs.readFile(tenantSecretPath, 'utf8');
if (tenantSecret.trim() === secret) {
return {tenant: tenantName, isAdmin: true};
}
} catch {
return null;
}
}
return null;
}
module.exports = router;

View File

@ -0,0 +1,166 @@
/*
* ONLYOFFICE Document Server
* Copyright (c) Ascensio System SIA. All rights reserved.
*
* This program is a free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ONLYOFFICE is a trademark of Ascensio System SIA. Other brand and product
* names mentioned herein may be trademarks of their respective owners.
*/
'use strict';
// Constants
const X_SCOPE_KEYWORD = 'x-scope';
const SCHEMA_COMBINATORS = ['anyOf', 'oneOf', 'allOf'];
const SCHEMA_DEFINITIONS = ['definitions', '$defs'];
/**
* Checks if a node should be included in the target scope.
* @param {any} node
* @param {string} scope
* @returns {boolean}
*/
function isNodeAllowedInScope(node, scope) {
if (!node || typeof node !== 'object') return true;
if (!Object.prototype.hasOwnProperty.call(node, X_SCOPE_KEYWORD)) return true;
const marker = node[X_SCOPE_KEYWORD];
return Array.isArray(marker) ? marker.includes(scope) : marker === scope;
}
/**
* Processes object properties by pruning each property.
* @param {Object} properties
* @param {Function} pruneFn
* @returns {Object}
*/
function processObjectProperties(properties, pruneFn) {
const newProps = {};
for (const [key, value] of Object.entries(properties)) {
const pruned = pruneFn(value);
if (pruned) newProps[key] = pruned;
}
return newProps;
}
/**
* Processes schema combinators (anyOf, oneOf, allOf).
* @param {Object} result
* @param {Function} pruneFn
*/
function processCombinators(result, pruneFn) {
for (const key of SCHEMA_COMBINATORS) {
if (Array.isArray(result[key])) {
const mapped = result[key].map(pruneFn).filter(Boolean);
if (mapped.length === 0) delete result[key];
else result[key] = mapped;
}
}
}
/**
* Processes schema definitions (definitions, $defs).
* @param {Object} result
* @param {Function} pruneFn
*/
function processDefinitions(result, pruneFn) {
for (const defKey of SCHEMA_DEFINITIONS) {
if (result[defKey] && typeof result[defKey] === 'object') {
result[defKey] = processObjectProperties(result[defKey], pruneFn);
}
}
}
/**
* Processes conditional schemas (if/then/else).
* @param {Object} result
* @param {Function} pruneFn
*/
function processConditionals(result, pruneFn) {
if (!result.if) return;
const pIf = pruneFn(result.if);
if (pIf === null) delete result.if;
else result.if = pIf;
if (result.then) {
const pThen = pruneFn(result.then);
if (pThen === null) delete result.then;
else result.then = pThen;
}
if (result.else) {
const pElse = pruneFn(result.else);
if (pElse === null) delete result.else;
else result.else = pElse;
}
}
/**
* Build a per-scope schema by pruning nodes marked with x-scope.
* @param {object} schema - Superset JSON schema object
* @param {'admin'|'tenant'} scope - Target scope
* @returns {object} Derived schema for scope
*/
function deriveSchemaForScope(schema, scope) {
const prune = node => {
if (!node || typeof node !== 'object') return node;
if (!isNodeAllowedInScope(node, scope)) return null;
const result = Array.isArray(node) ? node.map(prune).filter(Boolean) : {...node};
if (result[X_SCOPE_KEYWORD] !== undefined) delete result[X_SCOPE_KEYWORD];
// Handle object properties
if (result.type === 'object') {
if (result.properties && typeof result.properties === 'object') {
result.properties = processObjectProperties(result.properties, prune);
}
if (result.patternProperties && typeof result.patternProperties === 'object') {
result.patternProperties = processObjectProperties(result.patternProperties, prune);
}
if (Array.isArray(result.required)) {
result.required = result.required.filter(k => result.properties && Object.prototype.hasOwnProperty.call(result.properties, k));
if (result.required.length === 0) delete result.required;
}
if (typeof result.additionalProperties === 'object') {
const prunedAP = prune(result.additionalProperties);
result.additionalProperties = prunedAP === null ? false : prunedAP;
}
}
// Handle array items
if (result.items) {
const prunedItems = prune(result.items);
if (prunedItems === null) delete result.items;
else result.items = prunedItems;
}
processCombinators(result, prune);
processConditionals(result, prune);
processDefinitions(result, prune);
return result;
};
const derived = prune(schema);
if (derived && typeof derived === 'object') {
derived.$id = derived.$id ? `${derived.$id}:${scope}` : `urn:onlyoffice:config:derived:${scope}`;
}
return derived;
}
module.exports = {
deriveSchemaForScope,
X_SCOPE_KEYWORD
};

View File

@ -0,0 +1,118 @@
/*
* (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 Ajv = require('ajv');
const addFormats = require('ajv-formats');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const supersetSchema = require('../../../../../Common/config/schemas/config.schema.json');
const {deriveSchemaForScope, X_SCOPE_KEYWORD} = require('./config.schema.utils');
// Constants
const CRON6_REGEX = /^\s*\S+(?:\s+\S+){5}\s*$/;
const AJV_CONFIG = {allErrors: true, strict: false};
const AJV_FILTER_CONFIG = {allErrors: true, strict: false, removeAdditional: true};
/**
* Registers custom keyword and formats on an AJV instance.
* @param {Ajv.default} instance
*/
function registerAjvExtras(instance) {
instance.addKeyword({keyword: X_SCOPE_KEYWORD, schemaType: ['string', 'array'], errors: false});
instance.addFormat('cron6', CRON6_REGEX);
}
/**
* Creates and configures an AJV instance.
* @param {Object} config - AJV configuration
* @returns {Ajv.default}
*/
function createAjvInstance(config) {
const instance = new Ajv(config);
addFormats(instance);
registerAjvExtras(instance);
return instance;
}
const ajvValidator = createAjvInstance(AJV_CONFIG);
const ajvFilter = createAjvInstance(AJV_FILTER_CONFIG);
// Derive and compile per-scope schemas
const adminSchema = deriveSchemaForScope(supersetSchema, 'admin');
const tenantSchema = deriveSchemaForScope(supersetSchema, 'tenant');
const validateAdmin = ajvValidator.compile(adminSchema);
const validateTenant = ajvValidator.compile(tenantSchema);
const filterAdmin = ajvFilter.compile(adminSchema);
const filterTenant = ajvFilter.compile(tenantSchema);
function isAdminScope(ctx) {
return tenantManager.isDefaultTenant(ctx);
}
/**
* Validates updateData against the derived per-scope schema selected by ctx.
* @param {operationContext} ctx
* @param {Object} updateData
* @returns {{ value?: Object, errors?: any, errorsText?: string }}
*/
function validateScoped(ctx, updateData) {
const validator = isAdminScope(ctx) ? validateAdmin : validateTenant;
const valid = validator(updateData);
return valid
? {value: updateData, errors: null, errorsText: null}
: {value: null, errors: validator.errors, errorsText: ajvValidator.errorsText(validator.errors)};
}
/**
* Filters configuration to include only fields defined in the appropriate schema
* @param {operationContext} ctx - Operation context
* @returns {Object} Filtered configuration object
*/
function getScopedConfig(ctx) {
const cfg = ctx.getFullCfg();
const configCopy = JSON.parse(JSON.stringify(cfg));
const filter = isAdminScope(ctx) ? filterAdmin : filterTenant;
filter(configCopy);
return configCopy;
}
/**
* Returns the derived per-scope schema for ctx (admin or tenant).
* @param {operationContext} ctx
* @returns {object}
*/
function getScopedSchema(ctx) {
return isAdminScope(ctx) ? adminSchema : tenantSchema;
}
module.exports = {validateScoped, getScopedConfig, getScopedSchema};

View File

@ -0,0 +1,101 @@
'use strict';
const config = require('config');
const express = require('express');
const bodyParser = require('body-parser');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const operationContext = require('../../../../../Common/sources/operationContext');
const runtimeConfigManager = require('../../../../../Common/sources/runtimeConfigManager');
const utils = require('../../../../../Common/sources/utils');
const {getScopedConfig, validateScoped, getScopedSchema} = require('./config.service');
const jwt = require('jsonwebtoken');
const cookieParser = require('cookie-parser');
const adminPanelJwtSecret = config.get('adminPanel.jwtSecret');
const router = express.Router();
router.use(cookieParser());
const rawFileParser = bodyParser.raw({
inflate: true,
limit: config.get('services.CoAuthoring.server.limits_tempfile_upload'),
type() {
return true;
}
});
const validateJWT = async (req, res, next) => {
const ctx = new operationContext.Context();
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({error: 'Unauthorized - No token provided'});
}
const decoded = jwt.verify(token, adminPanelJwtSecret);
ctx.init(decoded.tenant);
await ctx.initTenantCache();
req.user = decoded;
req.ctx = ctx;
return next();
} catch {
return res.status(401).json({error: 'Unauthorized'});
}
};
router.get('/', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('config get start');
const filteredConfig = getScopedConfig(ctx);
res.setHeader('Content-Type', 'application/json');
res.json(filteredConfig);
} catch (error) {
ctx.logger.error('Config get error: %s', error.stack);
res.status(500).json({error: 'Internal server error'});
} finally {
ctx.logger.info('config get end');
}
});
router.get('/schema', validateJWT, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('config schema start');
const schema = getScopedSchema(ctx);
res.json(schema);
} catch (error) {
ctx.logger.error('Config schema error: %s', error.stack);
res.status(500).json({error: 'Internal server error'});
} finally {
ctx.logger.info('config schema end');
}
});
router.patch('/', validateJWT, rawFileParser, async (req, res) => {
const ctx = req.ctx;
try {
ctx.logger.info('config patch start');
const currentConfig = ctx.getFullCfg();
const updateData = JSON.parse(req.body);
const validationResult = validateScoped(ctx, updateData);
if (validationResult.errors) {
ctx.logger.error('Config save error: %j', validationResult.errors);
return res.status(400).json({
errors: validationResult.errors,
errorsText: validationResult.errorsText
});
}
const newConfig = utils.deepMergeObjects(currentConfig, validationResult.value);
if (tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)) {
await tenantManager.setTenantConfig(ctx, newConfig);
} else {
await runtimeConfigManager.saveConfig(ctx, newConfig);
}
res.sendStatus(200);
} catch (error) {
ctx.logger.error('Configuration save error: %s', error.stack);
res.status(500).json({error: 'Internal server error', details: error.message});
} finally {
ctx.logger.info('config patch end');
}
});
module.exports = router;

View File

@ -0,0 +1,108 @@
/*
* (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 moduleReloader = require('../../../Common/sources/moduleReloader');
const config = moduleReloader.requireConfigWithRuntime();
const operationContext = require('../../../Common/sources/operationContext');
const tenantManager = require('../../../Common/sources/tenantManager');
const license = require('../../../Common/sources/license');
const utils = require('../../../Common/sources/utils');
const express = require('express');
const http = require('http');
const cors = require('cors');
const path = require('path');
const infoRouter = require('../../../DocService/sources/routes/info');
const configRouter = require('./routes/config/router');
const adminpanelRouter = require('./routes/adminpanel/router');
const app = express();
app.disable('x-powered-by');
const server = http.createServer(app);
// Initialize license on startup
(async () => {
try {
let licenseFile;
try {
licenseFile = config.get('license.license_file');
} catch (_) {
licenseFile = null;
}
const [info, original] = await license.readLicense(licenseFile);
tenantManager.setDefLicense(info, original);
} catch (e) {
operationContext.global.logger.warn('License init error: %s', e.message);
}
})();
const corsWithCredentials = cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
});
operationContext.global.logger.warn('AdminPanel server starting...');
app.use('/info/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/info/adminpanel', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
// Shared Info router (provides /info.json)
app.use('/info', infoRouter());
// todo config or _dirname. Serve AdminPanel client build as static assets
const clientBuildPath = path.resolve('client/build');
app.use(express.static(clientBuildPath));
function serveSpaIndex(req, res, next) {
if (req.path.startsWith('/info/')) return next();
res.sendFile(path.join(clientBuildPath, 'index.html'));
}
// client SPA routes
app.get('*', serveSpaIndex);
app.use((err, req, res) => {
const ctx = new operationContext.Context();
ctx.initFromRequest(req);
ctx.logger.error('default error handler:%s', err.stack);
res.sendStatus(500);
});
const port = 9000;
server.listen(port, () => {
operationContext.global.logger.warn('AdminPanel server listening on port %d', port);
});

View File

@ -1,4 +1,7 @@
{ {
"adminPanel": {
"jwtSecret": "secret"
},
"statsd": { "statsd": {
"useMetrics": false, "useMetrics": false,
"host": "localhost", "host": "localhost",

View File

@ -0,0 +1,132 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "urn:onlyoffice:config:superset:1",
"title": "Config Patch Schema (Superset for Admin and Tenant)",
"description": "Superset schema with x-scope markers. Use at runtime to derive per-scope schemas.",
"type": "object",
"additionalProperties": false,
"properties": {
"services": {
"type": "object",
"additionalProperties": false,
"properties": {
"CoAuthoring": {
"type": "object",
"additionalProperties": false,
"properties": {
"expire": {
"type": "object",
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"filesCron": {"type": "string", "format": "cron6", "x-scope": "admin"},
"documentsCron": {"type": "string", "format": "cron6", "x-scope": "admin"},
"files": {"type": "integer", "minimum": 0, "x-scope": "admin"},
"filesremovedatonce": {"type": "integer", "minimum": 0, "x-scope": "admin"},
"sessionidle": {"type": "string", "x-scope": ["admin", "tenant"]},
"sessionabsolute": {"type": "string", "x-scope": ["admin", "tenant"]}
}
},
"autoAssembly": {
"type": "object",
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"step": {"type": "string", "enum": ["1m", "5m", "10m", "15m", "30m"]}
}
}
}
}
}
},
"FileConverter": {
"type": "object",
"additionalProperties": false,
"properties": {
"converter": {
"type": "object",
"additionalProperties": false,
"properties": {
"maxDownloadBytes": {"type": "integer", "minimum": 0, "maximum": 104857600, "x-scope": ["admin", "tenant"]},
"inputLimits": {
"type": "array",
"x-scope": ["admin", "tenant"],
"items": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string",
"description": "File types this limit applies to (e.g., 'docx;dotx;docm;dotm')"
},
"zip": {
"type": "object",
"additionalProperties": false,
"properties": {
"uncompressed": {
"type": "string",
"description": "Maximum uncompressed size (e.g., '50MB', '300MB')"
},
"template": {
"type": "string",
"description": "Template pattern for file matching (e.g., '*.xml')"
}
}
}
}
}
}
}
}
}
},
"wopi": {
"type": "object",
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"enable": {"type": "boolean"}
}
},
"email": {
"type": "object",
"additionalProperties": false,
"x-scope": ["admin", "tenant"],
"properties": {
"smtpServerConfiguration": {
"type": "object",
"additionalProperties": false,
"properties": {
"host": {"type": "string"},
"port": {"type": "integer", "minimum": 1, "maximum": 65535},
"auth": {
"type": "object",
"additionalProperties": false,
"properties": {
"user": {"type": "string"},
"pass": {"type": "string"}
}
}
}
},
"connectionConfiguration": {
"type": "object",
"additionalProperties": false,
"properties": {
"disableFileAccess": {"type": "boolean"},
"disableUrlAccess": {"type": "boolean"}
}
},
"contactDefaults": {
"type": "object",
"additionalProperties": false,
"properties": {
"from": {"type": "string"},
"to": {"type": "string"}
}
}
}
}
}
}

View File

@ -142,7 +142,7 @@ Context.prototype.toJSON = function () {
}; };
Context.prototype.getCfg = function (property, defaultValue) { Context.prototype.getCfg = function (property, defaultValue) {
if (this.config) { if (this.config) {
return getImpl(this.config, property) ?? defaultValue; return utils.getImpl(this.config, property) ?? defaultValue;
} }
return defaultValue; return defaultValue;
}; };
@ -154,29 +154,5 @@ Context.prototype.getFullCfg = function () {
return utils.deepMergeObjects(config.util.toObject(), this.config); return utils.deepMergeObjects(config.util.toObject(), this.config);
}; };
/**
* Underlying get mechanism
*
* @private
* @method getImpl
* @param object {object} - Object to get the property for
* @param property {string | array[string]} - The property name to get (as an array or '.' delimited string)
* @return value {*} - Property value, including undefined if not defined.
*/
function getImpl(object, property) {
//from https://github.com/node-config/node-config/blob/a8b91ac86b499d11b90974a2c9915ce31266044a/lib/config.js#L137
const elems = Array.isArray(property) ? property : property.split('.'),
name = elems[0],
value = object[name];
if (elems.length <= 1) {
return value;
}
// Note that typeof null === 'object'
if (value === null || typeof value !== 'object') {
return undefined;
}
return getImpl(value, elems.slice(1));
}
exports.Context = Context; exports.Context = Context;
exports.global = new Context(); exports.global = new Context();

View File

@ -1479,3 +1479,29 @@ exports.watchWithFallback = async function watchWithFallback(ctx, dirPath, fileP
return fs.watchFile(filePath, opts, listener); return fs.watchFile(filePath, opts, listener);
} }
}; };
/**
* Underlying get mechanism
*
* @private
* @method getImpl
* @param object {object} - Object to get the property for
* @param property {string | array[string]} - The property name to get (as an array or '.' delimited string)
* @return value {*} - Property value, including undefined if not defined.
*/
function getImpl(object, property) {
//from https://github.com/node-config/node-config/blob/a8b91ac86b499d11b90974a2c9915ce31266044a/lib/config.js#L137
const _t = this,
elems = Array.isArray(property) ? property : property.split('.'),
name = elems[0],
value = object[name];
if (elems.length <= 1) {
return value;
}
// Note that typeof null === 'object'
if (value === null || typeof value !== 'object') {
return undefined;
}
return getImpl(value, elems.slice(1));
}
exports.getImpl = getImpl;

View File

@ -961,6 +961,22 @@
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==" "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="
}, },
"cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"requires": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"dependencies": {
"cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="
}
}
},
"cookie-signature": { "cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@ -16,6 +16,8 @@
"bytes": "3.1.2", "bytes": "3.1.2",
"co": "4.6.0", "co": "4.6.0",
"config": "3.3.12", "config": "3.3.12",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cron": "1.5.0", "cron": "1.5.0",
"dmdb": "1.0.36002", "dmdb": "1.0.36002",
"ejs": "3.1.10", "ejs": "3.1.10",

View File

@ -4424,174 +4424,6 @@ exports.healthCheck = function (req, res) {
} }
}); });
}; };
exports.licenseInfo = function (req, res) {
return co(function* () {
let isError = false;
const serverDate = new Date();
//security risk of high-precision time
serverDate.setMilliseconds(0);
const output = {
connectionsStat: {},
licenseInfo: {},
serverInfo: {
buildVersion: commonDefines.buildVersion,
buildNumber: commonDefines.buildNumber,
date: serverDate.toISOString()
},
quota: {
edit: {
connectionsCount: 0,
usersCount: {
unique: 0,
anonymous: 0
}
},
view: {
connectionsCount: 0,
usersCount: {
unique: 0,
anonymous: 0
}
},
byMonth: []
}
};
const ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
yield ctx.initTenantCache();
ctx.logger.debug('licenseInfo start');
const [licenseInfo] = yield tenantManager.getTenantLicense(ctx);
Object.assign(output.licenseInfo, licenseInfo);
const precisionSum = {};
for (let i = 0; i < PRECISION.length; ++i) {
precisionSum[PRECISION[i].name] = {
edit: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0},
liveview: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0},
view: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0}
};
output.connectionsStat[PRECISION[i].name] = {
edit: {min: 0, avr: 0, max: 0},
liveview: {min: 0, avr: 0, max: 0},
view: {min: 0, avr: 0, max: 0}
};
}
const redisRes = yield editorStat.getEditorConnections(ctx);
const now = Date.now();
if (redisRes.length > 0) {
const expDocumentsStep95 = expDocumentsStep * 0.95;
let precisionIndex = 0;
for (let i = redisRes.length - 1; i >= 0; i--) {
const elem = redisRes[i];
let edit = elem.edit || 0;
let view = elem.view || 0;
let liveview = elem.liveview || 0;
//for cluster
while (i > 0 && elem.time - redisRes[i - 1].time < expDocumentsStep95) {
edit += elem.edit || 0;
view += elem.view || 0;
liveview += elem.liveview || 0;
i--;
}
for (let j = precisionIndex; j < PRECISION.length; ++j) {
if (now - elem.time < PRECISION[j].val) {
const precision = precisionSum[PRECISION[j].name];
precision.edit.min = Math.min(precision.edit.min, edit);
precision.edit.max = Math.max(precision.edit.max, edit);
precision.edit.sum += edit;
precision.edit.count++;
precision.view.min = Math.min(precision.view.min, view);
precision.view.max = Math.max(precision.view.max, view);
precision.view.sum += view;
precision.view.count++;
precision.liveview.min = Math.min(precision.liveview.min, liveview);
precision.liveview.max = Math.max(precision.liveview.max, liveview);
precision.liveview.sum += liveview;
precision.liveview.count++;
} else {
precisionIndex = j + 1;
}
}
}
for (const i in precisionSum) {
const precision = precisionSum[i];
const precisionOut = output.connectionsStat[i];
if (precision.edit.count > 0) {
precisionOut.edit.avr = Math.round(precision.edit.sum / precision.edit.intervalsInPresision);
precisionOut.edit.min = precision.edit.min;
precisionOut.edit.max = precision.edit.max;
}
if (precision.liveview.count > 0) {
precisionOut.liveview.avr = Math.round(precision.liveview.sum / precision.liveview.intervalsInPresision);
precisionOut.liveview.min = precision.liveview.min;
precisionOut.liveview.max = precision.liveview.max;
}
if (precision.view.count > 0) {
precisionOut.view.avr = Math.round(precision.view.sum / precision.view.intervalsInPresision);
precisionOut.view.min = precision.view.min;
precisionOut.view.max = precision.view.max;
}
}
}
const nowUTC = getLicenseNowUtc();
let execRes;
execRes = yield editorStat.getPresenceUniqueUser(ctx, nowUTC);
output.quota.edit.connectionsCount = yield editorStat.getEditorConnectionsCount(ctx, connections);
output.quota.edit.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
output.quota.edit.usersCount.anonymous++;
}
});
execRes = yield editorStat.getPresenceUniqueViewUser(ctx, nowUTC);
output.quota.view.connectionsCount = yield editorStat.getLiveViewerConnectionsCount(ctx, connections);
output.quota.view.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
output.quota.view.usersCount.anonymous++;
}
});
const byMonth = yield editorStat.getPresenceUniqueUsersOfMonth(ctx);
const byMonthView = yield editorStat.getPresenceUniqueViewUsersOfMonth(ctx);
const byMonthMerged = [];
for (const i in byMonth) {
if (Object.hasOwn(byMonth, i)) {
byMonthMerged[i] = {date: i, users: byMonth[i], usersView: {}};
}
}
for (const i in byMonthView) {
if (Object.hasOwn(byMonthView, i)) {
if (Object.hasOwn(byMonthMerged, i)) {
byMonthMerged[i].usersView = byMonthView[i];
} else {
byMonthMerged[i] = {date: i, users: {}, usersView: byMonthView[i]};
}
}
}
output.quota.byMonth = Object.values(byMonthMerged);
output.quota.byMonth.sort((a, b) => {
return a.date.localeCompare(b.date);
});
ctx.logger.debug('licenseInfo end');
} catch (err) {
isError = true;
ctx.logger.error('licenseInfo error %s', err.stack);
} finally {
if (!isError) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(output));
} else {
res.sendStatus(400);
}
}
});
};
function validateInputParams(ctx, authRes, command) { function validateInputParams(ctx, authRes, command) {
const commandsWithoutKey = ['version', 'license', 'getForgottenList']; const commandsWithoutKey = ['version', 'license', 'getForgottenList'];
const isValidWithoutKey = commandsWithoutKey.includes(command.c); const isValidWithoutKey = commandsWithoutKey.includes(command.c);

View File

@ -0,0 +1,154 @@
/*
* (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 config = require('config');
const express = require('express');
const operationContext = require('../../../../Common/sources/operationContext');
const tenantBaseDir = config.get('tenants.baseDir');
// const isMultitenantMode = config.get('tenants.isMultitenantMode');
const defaultTenantSecret = config.get('services.CoAuthoring.secret.browser.string');
const filenameSecret = config.get('tenants.filenameSecret');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const cookieParser = require('cookie-parser');
const router = express.Router();
// Middleware to parse JSON request bodies
router.use(express.json());
// Middleware to parse cookies
router.use(cookieParser());
router.get('/me', async (req, res) => {
try {
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({error: 'Unauthorized'});
}
// Try to verify with default tenant secret first
try {
const decoded = jwt.verify(token, defaultTenantSecret);
res.json(decoded);
} catch {
// If default secret fails, try to find the tenant and verify with their secret
const tenantList = fs.readdirSync(tenantBaseDir);
for (const tenant of tenantList) {
try {
const tenantSecret = fs.readFileSync(path.join(tenantBaseDir, tenant, filenameSecret), 'utf8');
const decoded = jwt.verify(token, tenantSecret);
res.json({
tenant: decoded.tenant,
isAdmin: decoded.isAdmin
});
return;
} catch {
// Continue to next tenant
continue;
}
}
// If no tenant secret works, return unauthorized
return res.status(401).json({error: 'Invalid token'});
}
} catch (error) {
console.log('error', error);
res.status(401).json({error: 'Unauthorized'});
}
});
router.post('/login', async (req, res) => {
const ctx = new operationContext.Context();
ctx.initDefault();
try {
const {secret} = req.body;
const tenant = findTenantBySecret(secret);
if (!tenant) {
return res.status(401).json({error: 'Invalid secret'});
}
const token = jwt.sign({...tenant}, secret, {expiresIn: '1h'});
res.cookie('accessToken', token, {
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 1000,
path: '/'
});
res.json({tenant: tenant.tenant, isAdmin: tenant.isAdmin});
} catch (error) {
ctx.logger.error('Config get error: %s', error.stack);
res.status(500).json({error: 'Internal server error'});
}
});
router.post('/logout', async (req, res) => {
try {
// Clear the httpOnly accessToken cookie
res.clearCookie('accessToken', {
httpOnly: true,
sameSite: 'strict',
path: '/'
});
res.json({message: 'Logged out successfully'});
} catch (error) {
console.log('logout error', error);
res.status(500).json({error: 'Internal server error'});
}
});
//TODO: make function async, use cache
function findTenantBySecret(secret) {
if (secret === defaultTenantSecret) {
return {
tenant: config.get('tenants.defaultTenant'),
isAdmin: true
};
}
const tenantList = fs.readdirSync(tenantBaseDir);
for (const tenant of tenantList) {
const tenantSecret = fs.readFileSync(path.join(tenantBaseDir, tenant, filenameSecret), 'utf8');
if (tenantSecret === secret) {
return {
tenant,
isAdmin: true
};
}
}
return null;
}
module.exports = router;

View File

@ -0,0 +1,244 @@
'use strict';
const express = require('express');
const cors = require('cors');
const ms = require('ms');
const config = require('config');
const cron = require('cron');
const utils = require('../../../Common/sources/utils');
const commonDefines = require('../../../Common/sources/commondefines');
const operationContext = require('../../../Common/sources/operationContext');
const tenantManager = require('../../../Common/sources/tenantManager');
// Configuration values
const cfgExpDocumentsCron = config.get('services.CoAuthoring.expire.documentsCron');
const cfgEditorStatStorage =
config.get('services.CoAuthoring.server.editorStatStorage') || config.get('services.CoAuthoring.server.editorDataStorage');
// Initialize editor stat storage
const editorStatStorage = require(`../${cfgEditorStatStorage}`);
const editorStat = new editorStatStorage.EditorStat();
console.error(`../${cfgEditorStatStorage}`);
console.error(editorStat);
// Constants
const PRECISION = [
{name: 'hour', val: ms('1h')},
{name: 'day', val: ms('1d')},
{name: 'week', val: ms('7d')},
{name: 'month', val: ms('30d')},
{name: 'year', val: ms('365d')}
];
/**
* Get the time step in milliseconds between cron job executions
* @param {string} cronTime - Cron time expression
* @returns {number} Time difference in milliseconds between consecutive executions
*/
function getCronStep(cronTime) {
const cronJob = new cron.CronJob(cronTime, () => {});
const dates = cronJob.nextDates(2);
return dates[1] - dates[0];
}
const expDocumentsStep = getCronStep(cfgExpDocumentsCron);
/**
* Get current UTC timestamp for license calculations
* @returns {number} UTC timestamp in seconds
*/
function getLicenseNowUtc() {
const now = new Date();
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), now.getUTCHours(), now.getUTCMinutes(), now.getUTCSeconds()) / 1000;
}
/**
* License info endpoint handler
* @param {import('express').Request} req Express request
* @param {import('express').Response} res Express response
*/
async function licenseInfo(req, res) {
let isError = false;
const serverDate = new Date();
// Security risk of high-precision time
serverDate.setMilliseconds(0);
const output = {
connectionsStat: {},
licenseInfo: {},
serverInfo: {
buildVersion: commonDefines.buildVersion,
buildNumber: commonDefines.buildNumber,
date: serverDate.toISOString()
},
quota: {
edit: {
connectionsCount: 0,
usersCount: {
unique: 0,
anonymous: 0
}
},
view: {
connectionsCount: 0,
usersCount: {
unique: 0,
anonymous: 0
}
},
byMonth: []
}
};
const ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
ctx.logger.debug('licenseInfo start');
const tenantLicense = await tenantManager.getTenantLicense(ctx);
if (tenantLicense && Array.isArray(tenantLicense) && tenantLicense.length > 0) {
const [licenseInfo] = tenantLicense;
Object.assign(output.licenseInfo, licenseInfo);
}
const precisionSum = {};
for (let i = 0; i < PRECISION.length; ++i) {
precisionSum[PRECISION[i].name] = {
edit: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0},
liveview: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0},
view: {min: Number.MAX_VALUE, sum: 0, count: 0, intervalsInPresision: PRECISION[i].val / expDocumentsStep, max: 0}
};
output.connectionsStat[PRECISION[i].name] = {
edit: {min: 0, avr: 0, max: 0},
liveview: {min: 0, avr: 0, max: 0},
view: {min: 0, avr: 0, max: 0}
};
}
const redisRes = await editorStat.getEditorConnections(ctx);
const now = Date.now();
if (redisRes.length > 0) {
const expDocumentsStep95 = expDocumentsStep * 0.95;
let precisionIndex = 0;
for (let i = redisRes.length - 1; i >= 0; i--) {
const elem = redisRes[i];
let edit = elem.edit || 0;
let view = elem.view || 0;
let liveview = elem.liveview || 0;
// For cluster
while (i > 0 && elem.time - redisRes[i - 1].time < expDocumentsStep95) {
edit += elem.edit || 0;
view += elem.view || 0;
liveview += elem.liveview || 0;
i--;
}
for (let j = precisionIndex; j < PRECISION.length; ++j) {
if (now - elem.time < PRECISION[j].val) {
const precision = precisionSum[PRECISION[j].name];
precision.edit.min = Math.min(precision.edit.min, edit);
precision.edit.max = Math.max(precision.edit.max, edit);
precision.edit.sum += edit;
precision.edit.count++;
precision.view.min = Math.min(precision.view.min, view);
precision.view.max = Math.max(precision.view.max, view);
precision.view.sum += view;
precision.view.count++;
precision.liveview.min = Math.min(precision.liveview.min, liveview);
precision.liveview.max = Math.max(precision.liveview.max, liveview);
precision.liveview.sum += liveview;
precision.liveview.count++;
} else {
precisionIndex = j + 1;
}
}
}
for (const i in precisionSum) {
const precision = precisionSum[i];
const precisionOut = output.connectionsStat[i];
if (precision.edit.count > 0) {
precisionOut.edit.avr = Math.round(precision.edit.sum / precision.edit.intervalsInPresision);
precisionOut.edit.min = precision.edit.min;
precisionOut.edit.max = precision.edit.max;
}
if (precision.liveview.count > 0) {
precisionOut.liveview.avr = Math.round(precision.liveview.sum / precision.liveview.intervalsInPresision);
precisionOut.liveview.min = precision.liveview.min;
precisionOut.liveview.max = precision.liveview.max;
}
if (precision.view.count > 0) {
precisionOut.view.avr = Math.round(precision.view.sum / precision.view.intervalsInPresision);
precisionOut.view.min = precision.view.min;
precisionOut.view.max = precision.view.max;
}
}
}
const nowUTC = getLicenseNowUtc();
let execRes;
execRes = await editorStat.getPresenceUniqueUser(ctx, nowUTC);
output.quota.edit.connectionsCount = await editorStat.getEditorConnectionsCount(ctx, {});
output.quota.edit.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
output.quota.edit.usersCount.anonymous++;
}
});
execRes = await editorStat.getPresenceUniqueViewUser(ctx, nowUTC);
output.quota.view.connectionsCount = await editorStat.getLiveViewerConnectionsCount(ctx, {});
output.quota.view.usersCount.unique = execRes.length;
execRes.forEach(elem => {
if (elem.anonym) {
output.quota.view.usersCount.anonymous++;
}
});
const byMonth = await editorStat.getPresenceUniqueUsersOfMonth(ctx);
const byMonthView = await editorStat.getPresenceUniqueViewUsersOfMonth(ctx);
const byMonthMerged = [];
for (const i in byMonth) {
if (Object.hasOwn(byMonth, i)) {
byMonthMerged[i] = {date: i, users: byMonth[i], usersView: {}};
}
}
for (const i in byMonthView) {
if (Object.hasOwn(byMonthView, i)) {
if (Object.hasOwn(byMonthMerged, i)) {
byMonthMerged[i].usersView = byMonthView[i];
} else {
byMonthMerged[i] = {date: i, users: {}, usersView: byMonthView[i]};
}
}
}
output.quota.byMonth = Object.values(byMonthMerged);
output.quota.byMonth.sort((a, b) => {
return a.date.localeCompare(b.date);
});
ctx.logger.debug('licenseInfo end');
} catch (err) {
isError = true;
ctx.logger.error('licenseInfo error %s', err.stack);
} finally {
if (!isError) {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(output));
} else {
res.sendStatus(400);
}
}
}
/**
* Create shared Info router
* @returns {import('express').Router} Router instance
*/
function createInfoRouter() {
const router = express.Router();
// License info endpoint with CORS and client IP check
router.get('/info.json', cors(), utils.checkClientIp, licenseInfo);
return router;
}
module.exports = createInfoRouter;

View File

@ -58,8 +58,11 @@ const operationContext = require('./../../Common/sources/operationContext');
const tenantManager = require('./../../Common/sources/tenantManager'); const tenantManager = require('./../../Common/sources/tenantManager');
const staticRouter = require('./routes/static'); const staticRouter = require('./routes/static');
const configRouter = require('./routes/config'); const configRouter = require('./routes/config');
const adminpanelRouter = require('./routes/adminpanel/router');
const infoRouter = require('./routes/info');
const ms = require('ms'); const ms = require('ms');
const aiProxyHandler = require('./ai/aiProxyHandler'); const aiProxyHandler = require('./ai/aiProxyHandler');
const cors = require('cors');
const cfgWopiEnable = config.get('wopi.enable'); const cfgWopiEnable = config.get('wopi.enable');
const cfgWopiDummyEnable = config.get('wopi.dummy.enable'); const cfgWopiDummyEnable = config.get('wopi.dummy.enable');
@ -115,6 +118,12 @@ const updateLicense = async () => {
operationContext.global.logger.error('updateLicense error: %s', err.stack); operationContext.global.logger.error('updateLicense error: %s', err.stack);
} }
}; };
const corsWithCredentials = cors({
origin: true,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization']
});
operationContext.global.logger.warn('Express server starting...'); operationContext.global.logger.warn('Express server starting...');
@ -255,10 +264,12 @@ docsCoServer.install(server, app, () => {
app.post('/docbuilder', utils.checkClientIp, rawFileParser, (req, res) => { app.post('/docbuilder', utils.checkClientIp, rawFileParser, (req, res) => {
converterService.builder(req, res); converterService.builder(req, res);
}); });
app.get('/info/info.json', utils.checkClientIp, docsCoServer.licenseInfo); app.use('/info/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/info/config', utils.checkClientIp, configRouter); app.use('/info/adminpanel', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.get('/info/plugin/settings', utils.checkClientIp, aiProxyHandler.requestSettings); app.get('/info/plugin/settings', utils.checkClientIp, aiProxyHandler.requestSettings);
app.post('/info/plugin/models', utils.checkClientIp, rawFileParser, aiProxyHandler.requestModels); app.post('/info/plugin/models', utils.checkClientIp, rawFileParser, aiProxyHandler.requestModels);
// Shared Info router (provides /info.json)
app.use('/info', infoRouter());
app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown); app.put('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown); app.delete('/internal/cluster/inactive', utils.checkClientIp, docsCoServer.shutdown);
app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount); app.get('/internal/connections/edit', docsCoServer.getEditorConnectionsCount);

View File

@ -3,6 +3,8 @@ const globals = require('globals');
const prettier = require('eslint-config-prettier'); const prettier = require('eslint-config-prettier');
const {includeIgnoreFile} = require('@eslint/compat'); const {includeIgnoreFile} = require('@eslint/compat');
const path = require('node:path'); const path = require('node:path');
const react = require('eslint-plugin-react');
const reactHooks = require('eslint-plugin-react-hooks');
const gitignorePath = path.resolve(__dirname, '.gitignore'); const gitignorePath = path.resolve(__dirname, '.gitignore');
@ -65,5 +67,22 @@ module.exports = [
'max-lines': ['warn', 5000] 'max-lines': ['warn', 5000]
} }
}, },
{
files: ['AdminPanel/client/**/*.{js,jsx}'],
plugins: {react, 'react-hooks': reactHooks},
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parserOptions: {ecmaFeatures: {jsx: true}},
globals: {...globals.browser, ...globals.es2022}
},
settings: {react: {version: 'detect'}},
rules: {
'react/react-in-jsx-scope': 'off',
'react/jsx-uses-vars': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn'
}
},
prettier prettier
]; ];

287
npm-shrinkwrap.json generated
View File

@ -1318,6 +1318,73 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"dev": true "dev": true
}, },
"array-includes": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-abstract": "^1.24.0",
"es-object-atoms": "^1.1.1",
"get-intrinsic": "^1.3.0",
"is-string": "^1.1.1",
"math-intrinsics": "^1.1.0"
}
},
"array.prototype.findlast": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz",
"integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==",
"dev": true,
"requires": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-shim-unscopables": "^1.0.2"
}
},
"array.prototype.flat": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz",
"integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.5",
"es-shim-unscopables": "^1.0.2"
}
},
"array.prototype.flatmap": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz",
"integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.5",
"es-shim-unscopables": "^1.0.2"
}
},
"array.prototype.tosorted": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz",
"integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==",
"dev": true,
"requires": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.3",
"es-errors": "^1.3.0",
"es-shim-unscopables": "^1.0.2"
}
},
"arraybuffer.prototype.slice": { "arraybuffer.prototype.slice": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz",
@ -2057,6 +2124,15 @@
"integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
"dev": true "dev": true
}, },
"doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
"integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
"dev": true,
"requires": {
"esutils": "^2.0.2"
}
},
"dunder-proto": { "dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -2187,6 +2263,30 @@
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
}, },
"es-iterator-helpers": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz",
"integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.6",
"es-errors": "^1.3.0",
"es-set-tostringtag": "^2.0.3",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.6",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
"has-property-descriptors": "^1.0.2",
"has-proto": "^1.2.0",
"has-symbols": "^1.1.0",
"internal-slot": "^1.1.0",
"iterator.prototype": "^1.1.4",
"safe-array-concat": "^1.1.3"
}
},
"es-object-atoms": { "es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@ -2206,6 +2306,15 @@
"hasown": "^2.0.2" "hasown": "^2.0.2"
} }
}, },
"es-shim-unscopables": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz",
"integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==",
"dev": true,
"requires": {
"hasown": "^2.0.2"
}
},
"es-to-primitive": { "es-to-primitive": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz",
@ -2416,6 +2525,57 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true "dev": true
}, },
"eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
"integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==",
"dev": true,
"requires": {
"array-includes": "^3.1.8",
"array.prototype.findlast": "^1.2.5",
"array.prototype.flatmap": "^1.3.3",
"array.prototype.tosorted": "^1.1.4",
"doctrine": "^2.1.0",
"es-iterator-helpers": "^1.2.1",
"estraverse": "^5.3.0",
"hasown": "^2.0.2",
"jsx-ast-utils": "^2.4.1 || ^3.0.0",
"minimatch": "^3.1.2",
"object.entries": "^1.1.9",
"object.fromentries": "^2.0.8",
"object.values": "^1.2.1",
"prop-types": "^15.8.1",
"resolve": "^2.0.0-next.5",
"semver": "^6.3.1",
"string.prototype.matchall": "^4.0.12",
"string.prototype.repeat": "^1.0.0"
},
"dependencies": {
"resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
"integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==",
"dev": true,
"requires": {
"is-core-module": "^2.13.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
}
},
"semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true
}
}
},
"eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"dev": true
},
"eslint-scope": { "eslint-scope": {
"version": "8.4.0", "version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@ -3421,6 +3581,20 @@
"istanbul-lib-report": "^3.0.0" "istanbul-lib-report": "^3.0.0"
} }
}, },
"iterator.prototype": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz",
"integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==",
"dev": true,
"requires": {
"define-data-property": "^1.1.4",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.6",
"get-proto": "^1.0.0",
"has-symbols": "^1.1.0",
"set-function-name": "^2.0.2"
}
},
"jest": { "jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@ -4682,6 +4856,18 @@
"universalify": "^2.0.0" "universalify": "^2.0.0"
} }
}, },
"jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
"integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==",
"dev": true,
"requires": {
"array-includes": "^3.1.6",
"array.prototype.flat": "^1.3.1",
"object.assign": "^4.1.4",
"object.values": "^1.1.6"
}
},
"keyv": { "keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -5115,6 +5301,15 @@
} }
} }
}, },
"loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"requires": {
"js-tokens": "^3.0.0 || ^4.0.0"
}
},
"lower-case": { "lower-case": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
@ -5355,6 +5550,12 @@
} }
} }
}, },
"object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true
},
"object-inspect": { "object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -5378,6 +5579,42 @@
"object-keys": "^1.1.1" "object-keys": "^1.1.1"
} }
}, },
"object.entries": {
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz",
"integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.1.1"
}
},
"object.fromentries": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz",
"integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==",
"dev": true,
"requires": {
"call-bind": "^1.0.7",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-object-atoms": "^1.0.0"
}
},
"object.values": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz",
"integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-object-atoms": "^1.0.0"
}
},
"on-finished": { "on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -5601,6 +5838,25 @@
"sisteransi": "^1.0.5" "sisteransi": "^1.0.5"
} }
}, },
"prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"requires": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
},
"dependencies": {
"react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
}
}
},
"proxy-addr": { "proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@ -6171,6 +6427,27 @@
"strip-ansi": "^6.0.1" "strip-ansi": "^6.0.1"
} }
}, },
"string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
"integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==",
"dev": true,
"requires": {
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.6",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.6",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"internal-slot": "^1.1.0",
"regexp.prototype.flags": "^1.5.3",
"set-function-name": "^2.0.2",
"side-channel": "^1.1.0"
}
},
"string.prototype.padend": { "string.prototype.padend": {
"version": "3.1.6", "version": "3.1.6",
"resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz", "resolved": "https://registry.npmjs.org/string.prototype.padend/-/string.prototype.padend-3.1.6.tgz",
@ -6182,6 +6459,16 @@
"es-object-atoms": "^1.0.0" "es-object-atoms": "^1.0.0"
} }
}, },
"string.prototype.repeat": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz",
"integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==",
"dev": true,
"requires": {
"define-properties": "^1.1.3",
"es-abstract": "^1.17.5"
}
},
"string.prototype.trim": { "string.prototype.trim": {
"version": "1.2.10", "version": "1.2.10",
"resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz",

View File

@ -15,6 +15,8 @@
"cross-env": "7.0.3", "cross-env": "7.0.3",
"eslint": "9.16.0", "eslint": "9.16.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.2.0",
"express": "4.21.2", "express": "4.21.2",
"globals": "15.12.0", "globals": "15.12.0",
"husky": "8.0.3", "husky": "8.0.3",
@ -42,6 +44,8 @@
"install:DocService": "npm ci --prefix ./DocService", "install:DocService": "npm ci --prefix ./DocService",
"install:FileConverter": "npm ci --prefix ./FileConverter", "install:FileConverter": "npm ci --prefix ./FileConverter",
"install:Metrics": "npm ci --prefix ./Metrics", "install:Metrics": "npm ci --prefix ./Metrics",
"install:AdminPanel/server": "npm ci --prefix ./AdminPanel/server",
"install:AdminPanel/client": "npm ci --prefix ./AdminPanel/client && npm --prefix ./AdminPanel/client run build",
"3d-party-lic-json:Common": "license-report --output=json --package=./Common/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json", "3d-party-lic-json:Common": "license-report --output=json --package=./Common/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json",
"3d-party-lic-json:DocService": "license-report --output=json --package=./DocService/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json", "3d-party-lic-json:DocService": "license-report --output=json --package=./DocService/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json",
"3d-party-lic-json:FileConverter": "license-report --output=json --package=./FileConverter/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json", "3d-party-lic-json:FileConverter": "license-report --output=json --package=./FileConverter/package.json --config ./3d-party-lic-report/license-report-config.json > ./3d-party-lic-report/license-report.json",