[feature] Add server for admin panel
@ -1 +0,0 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8080
|
||||
@ -1 +0,0 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8080
|
||||
1
AdminPanel/client/.env
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:9000
|
||||
1
AdminPanel/client/.env.example
Normal file
@ -0,0 +1 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:9000
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@ -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`);
|
||||
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 719 B After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 243 B |
4
AdminPanel/server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
runtime/config.json
|
||||
node_modules/
|
||||
|
||||
|
||||
1077
AdminPanel/server/package-lock.json
generated
Normal file
22
AdminPanel/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
103
AdminPanel/server/sources/routes/adminpanel/router.js
Normal 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;
|
||||
|
||||
|
||||
86
AdminPanel/server/sources/routes/config/config.service.js
Normal 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 };
|
||||
|
||||
|
||||
106
AdminPanel/server/sources/routes/config/router.js
Normal 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;
|
||||
|
||||
|
||||
11
AdminPanel/server/sources/routes/config/validation.js
Normal 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 };
|
||||
|
||||
|
||||
93
AdminPanel/server/sources/server.js
Normal 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);
|
||||
});
|
||||
|
||||
|
||||