[feature] Add server for admin panel

This commit is contained in:
PauI Ostrovckij
2025-08-22 19:02:17 +03:00
parent 5afa8ec425
commit 3739aa7e26
58 changed files with 5733 additions and 2257 deletions

View File

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

View File

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

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

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,4 +1,4 @@
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://localhost:8000';
const BACKEND_URL = process.env.REACT_APP_BACKEND_URL || 'http://localhost:9000';
export const fetchStatistics = async () => {
const response = await fetch(`${BACKEND_URL}/info/info.json`);

View File

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

Before

Width:  |  Height:  |  Size: 719 B

After

Width:  |  Height:  |  Size: 719 B

View File

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

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

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

1077
AdminPanel/server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,22 @@
{
"name": "onlyoffice-adminpanel-server",
"version": "1.0.0",
"private": true,
"main": "sources/server.js",
"type": "commonjs",
"scripts": {
"start": "set \"NODE_CONFIG_DIR=%cd%\\..\\..\\Common\\config\" && set \"NODE_ENV=development-windows\" && node 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",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"ms": "^2.1.3"
}
}

View File

@ -0,0 +1,103 @@
'use strict';
const config = require('config');
const express = require('express');
const jwt = require('jsonwebtoken');
const fs = require('fs');
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 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' });
}
try {
const decoded = jwt.verify(token, defaultTenantSecret);
res.json(decoded);
return;
} catch (defaultError) {
if (tenantBaseDir && fs.existsSync(tenantBaseDir)) {
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 (tenantError) {
continue;
}
}
}
return res.status(401).json({ error: 'Invalid token' });
}
} catch (error) {
res.status(401).json({ error: 'Unauthorized' });
}
});
router.post('/login', async (req, res) => {
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) {
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 (error) {
res.status(500).json({ error: 'Internal server error' });
}
});
function findTenantBySecret(secret) {
if (secret === defaultTenantSecret) {
return { tenant: config.get('tenants.defaultTenant'), isAdmin: true };
}
if (tenantBaseDir && fs.existsSync(tenantBaseDir)) {
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,86 @@
'use strict';
const Joi = require('joi');
const validation = require('./validation');
const tenantManager = require('../../../../../Common/sources/tenantManager');
const utils = require('../../../../../Common/sources/utils');
const tenantReadableFields = [
'services.CoAuthoring.expire',
'FileConverter.converter.maxDownloadBytes',
];
const adminReadableFields = [
'services.CoAuthoring.expire',
'FileConverter.converter.maxDownloadBytes',
];
function createSchema(isAdmin) {
const baseSchema = {
services: Joi.object({
CoAuthoring: Joi.object({
expire: Joi.object({
...(isAdmin && { filesCron: validation.cronSchema }),
...(isAdmin && { documentsCron: validation.cronSchema }),
...(isAdmin && { files: Joi.number().min(0) }),
...(isAdmin && { filesremovedatonce: Joi.number().min(0) }),
}).unknown(false),
autoAssembly: Joi.object({
step: Joi.any().valid('1m', '5m', '10m', '15m', '30m'),
}).unknown(false),
}).unknown(false)
}).unknown(false),
FileConverter: Joi.object({
converter: Joi.object({
maxDownloadBytes: Joi.number().min(0).max(104857600),
}).unknown(false)
}).unknown(false)
};
return baseSchema;
}
function getReadableFields(ctx) {
return tenantManager.isMultitenantMode(ctx) && !tenantManager.isDefaultTenant(ctx)
? tenantReadableFields
: adminReadableFields;
}
function getValidationSchema(ctx) {
const isAdmin = !tenantManager.isMultitenantMode(ctx) || tenantManager.isDefaultTenant(ctx);
return createSchema(isAdmin);
}
function validate(updateData, ctx) {
const schema = getValidationSchema(ctx);
return Joi.object(schema).validate(updateData, { abortEarly: false });
}
function getFilteredConfig(ctx) {
const cfg = ctx.getFullCfg();
const readableFields = getReadableFields(ctx);
const filteredConfig = {};
readableFields.forEach(field => {
const value = utils.getImpl(cfg, field);
if (value !== undefined) {
set(filteredConfig, field, value);
}
});
return filteredConfig;
}
function set(obj, path, value) {
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!(key in current) || typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]] = value;
return obj;
}
module.exports = { validate, getFilteredConfig };

View File

@ -0,0 +1,106 @@
'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 { getFilteredConfig, validate } = require('./config.service');
const jwt = require('jsonwebtoken');
const fs = require('fs');
const path = require('path');
const cookieParser = require('cookie-parser');
const router = express.Router();
router.use(cookieParser());
const rawFileParser = bodyParser.raw({ inflate: true, limit: config.get('services.CoAuthoring.server.limits_tempfile_upload'), type: function () { return true; } });
const validateJWT = async (req, res, next) => {
let ctx = new operationContext.Context();
try {
ctx.initFromRequest(req);
await ctx.initTenantCache();
const token = req.cookies.accessToken;
if (!token) {
return res.status(401).json({ error: 'Unauthorized - No token provided' });
}
const defaultTenantSecret = config.get('services.CoAuthoring.secret.browser.string');
const tenantBaseDir = config.get('tenants.baseDir');
const filenameSecret = config.get('tenants.filenameSecret');
try {
const decoded = jwt.verify(token, defaultTenantSecret);
if (ctx.tenant !== decoded.tenant) {
return res.status(401).json({ error: 'Unauthorized - Invalid tenant' });
}
req.user = decoded;
req.ctx = ctx;
return next();
} catch (defaultError) {
if (tenantBaseDir && fs.existsSync(tenantBaseDir)) {
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);
if (ctx.tenant !== decoded.tenant) {
return res.status(401).json({ error: 'Unauthorized - Invalid tenant' });
}
req.user = decoded;
req.ctx = ctx;
return next();
} catch (tenantError) {
continue;
}
}
}
return res.status(401).json({ error: 'Unauthorized - Invalid token' });
}
} catch (error) {
return res.status(401).json({ error: 'Unauthorized' });
}
};
router.get('/', validateJWT, async (req, res) => {
let ctx = req.ctx;
try {
ctx.logger.debug('config get start');
const filteredConfig = getFilteredConfig(ctx);
res.setHeader('Content-Type', 'application/json');
res.json(filteredConfig);
ctx.logger.debug('Config get success');
} catch (error) {
ctx.logger.error('Config get error: %s', error.stack);
res.status(500).json({ error: 'Internal server error' });
}
});
router.patch('/', validateJWT, rawFileParser, async (req, res) => {
let ctx = req.ctx;
try {
const currentConfig = ctx.getFullCfg();
const updateData = JSON.parse(req.body);
const validationResult = validate(updateData, ctx);
if (validationResult.error) {
ctx.logger.error('Config save error: %s', validationResult.error);
return res.status(400).json({
error: validationResult.error
});
}
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 });
}
});
module.exports = router;

View File

@ -0,0 +1,11 @@
'use strict';
const Joi = require('joi');
const cronSchema = Joi.string().pattern(
/^(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)\s+(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)\s+(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)\s+(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)\s+(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)\s+(\*|(\d+|\*\/\d+)(,(\d+|\*\/\d+))*)$/,
'cron expression'
).message('Invalid cron expression format. Must have 6 space-separated components.');
module.exports = { cronSchema };

View File

@ -0,0 +1,93 @@
'use strict';
const path = require('path');
const moduleReloader = require('../../../Common/sources/moduleReloader');
const logProfile = (process.env.NODE_ENV && process.env.NODE_ENV.startsWith('production')) ? 'production' : 'development';
const absLogCfgPath = path.resolve(__dirname, '../../../Common/config/log4js', `${logProfile}.json`);
try {
const existingOverlay = process.env.NODE_CONFIG ? JSON.parse(process.env.NODE_CONFIG) : {};
existingOverlay.log = Object.assign({}, existingOverlay.log, { filePath: absLogCfgPath });
process.env.NODE_CONFIG = JSON.stringify(existingOverlay);
} catch (_) {
process.env.NODE_CONFIG = JSON.stringify({ log: { filePath: absLogCfgPath } });
}
const config = moduleReloader.requireConfigWithRuntime();
const logger = require('../../../Common/sources/logger');
const operationContext = require('../../../Common/sources/operationContext');
const tenantManager = require('../../../Common/sources/tenantManager');
const utils = require('../../../Common/sources/utils');
const commonDefines = require('../../../Common/sources/commondefines');
const express = require('express');
const http = require('http');
const bodyParser = require('body-parser');
const cors = require('cors');
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);
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...');
const rawFileParser = bodyParser.raw(
{ inflate: true, limit: config.get('services.CoAuthoring.server.limits_tempfile_upload'), type: function () { return true; } }
);
app.get('/info/info.json', cors(), utils.checkClientIp, async (req, res) => {
const serverDate = new Date();
serverDate.setMilliseconds(0);
let 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();
const [licenseInfo] = await tenantManager.getTenantLicense(ctx);
output.licenseInfo = licenseInfo || {};
} catch (e) {
ctx.logger && ctx.logger.warn('info.json error: %s', e.stack);
} finally {
res.setHeader('Content-Type', 'application/json');
res.send(JSON.stringify(output));
}
});
app.use('/info/config', corsWithCredentials, utils.checkClientIp, configRouter);
app.use('/info/adminpanel', corsWithCredentials, utils.checkClientIp, adminpanelRouter);
app.use((err, req, res, next) => {
let 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);
});

6482
npm-shrinkwrap.json generated

File diff suppressed because it is too large Load Diff