AdminJS admin panel + dynamic content API

- AdminJS at /admin with auth (admin role only)
- Manage: users, categories, tools, content blocks, settings
- DB tables: settings, categories, tools, content_blocks
- Users table: added role (user/admin) and is_blocked fields
- API: /api/settings, /api/tools, /api/content/:section
- Sidebar: admin link visible only for admin users
- Removed /logs from public routes (now in AdminJS)
- Video converter: fixed ffmpeg codecs for RPi5 (h264_v4l2m2m)
- Dependencies: sequelize, @adminjs/sequelize, mariadb
This commit is contained in:
treamz 2026-03-21 23:46:41 +03:00
parent 056f73ca33
commit 1837f42a91
9 changed files with 7105 additions and 37 deletions

180
lib/admin.js Normal file
View File

@ -0,0 +1,180 @@
/**
* AdminJS setup ESM modules loaded via dynamic import.
* Exports async function that mounts admin panel on the Express app.
*/
const { Sequelize, DataTypes } = require('sequelize');
// Sequelize connection (reuses same DB as lib/db.js)
const sequelize = new Sequelize(
process.env.DB_NAME || 'wa_tools',
process.env.DB_USER || 'wa_tools',
process.env.DB_PASSWORD || '',
{
host: process.env.DB_HOST || 'localhost',
dialect: 'mariadb',
logging: false,
}
);
// Define models
const User = sequelize.define('User', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
email: { type: DataTypes.STRING(255), allowNull: false, unique: true },
password_hash: { type: DataTypes.STRING(255), allowNull: false },
display_name: { type: DataTypes.STRING(100) },
role: { type: DataTypes.ENUM('user', 'admin'), defaultValue: 'user' },
is_blocked: { type: DataTypes.BOOLEAN, defaultValue: false },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
last_login: { type: DataTypes.DATE },
}, { tableName: 'users', timestamps: false });
const Setting = sequelize.define('Setting', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
setting_key: { type: DataTypes.STRING(100), allowNull: false, unique: true },
setting_value: { type: DataTypes.TEXT },
description: { type: DataTypes.STRING(255) },
updated_at: { type: DataTypes.DATE },
}, { tableName: 'settings', timestamps: false });
const Category = sequelize.define('Category', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
slug: { type: DataTypes.STRING(50), allowNull: false, unique: true },
title: { type: DataTypes.STRING(100), allowNull: false },
description: { type: DataTypes.STRING(255) },
icon_svg: { type: DataTypes.TEXT },
color: { type: DataTypes.STRING(20), defaultValue: '#0054e6' },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
}, { tableName: 'categories', timestamps: false });
const Tool = sequelize.define('Tool', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
slug: { type: DataTypes.STRING(50), allowNull: false, unique: true },
title: { type: DataTypes.STRING(100), allowNull: false },
description: { type: DataTypes.STRING(255) },
path: { type: DataTypes.STRING(100), allowNull: false },
icon_svg: { type: DataTypes.TEXT },
category_id: { type: DataTypes.INTEGER },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
is_enabled: { type: DataTypes.BOOLEAN, defaultValue: true },
}, { tableName: 'tools', timestamps: false });
const ContentBlock = sequelize.define('ContentBlock', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
block_key: { type: DataTypes.STRING(100), allowNull: false, unique: true },
title: { type: DataTypes.STRING(255) },
body: { type: DataTypes.TEXT },
section: { type: DataTypes.STRING(50) },
sort_order: { type: DataTypes.INTEGER, defaultValue: 0 },
is_visible: { type: DataTypes.BOOLEAN, defaultValue: true },
updated_at: { type: DataTypes.DATE },
}, { tableName: 'content_blocks', timestamps: false });
Tool.belongsTo(Category, { foreignKey: 'category_id' });
Category.hasMany(Tool, { foreignKey: 'category_id' });
/**
* Mount AdminJS on the Express app at /admin
*/
async function setupAdmin(app) {
const AdminJS = (await import('adminjs')).default;
const AdminJSExpress = (await import('@adminjs/express')).default;
const AdminJSSequelize = (await import('@adminjs/sequelize')).default;
AdminJS.registerAdapter({
Resource: AdminJSSequelize.Resource,
Database: AdminJSSequelize.Database,
});
const adminJs = new AdminJS({
rootPath: '/admin',
loginPath: '/admin/login',
logoutPath: '/admin/logout',
branding: {
companyName: 'WA Dev Tools',
logo: false,
softwareBrothers: false,
},
resources: [
{
resource: User,
options: {
navigation: { name: 'Пользователи', icon: 'User' },
listProperties: ['id', 'email', 'display_name', 'role', 'is_blocked', 'last_login'],
editProperties: ['email', 'display_name', 'role', 'is_blocked'],
showProperties: ['id', 'email', 'display_name', 'role', 'is_blocked', 'created_at', 'last_login'],
properties: {
password_hash: { isVisible: false },
role: { availableValues: [{ value: 'user', label: 'User' }, { value: 'admin', label: 'Admin' }] },
},
},
},
{
resource: Category,
options: {
navigation: { name: 'Контент', icon: 'Grid' },
listProperties: ['id', 'slug', 'title', 'color', 'sort_order'],
sort: { sortBy: 'sort_order', direction: 'asc' },
},
},
{
resource: Tool,
options: {
navigation: { name: 'Контент', icon: 'Grid' },
listProperties: ['id', 'slug', 'title', 'path', 'category_id', 'is_enabled', 'sort_order'],
sort: { sortBy: 'sort_order', direction: 'asc' },
properties: {
icon_svg: { type: 'textarea' },
},
},
},
{
resource: ContentBlock,
options: {
navigation: { name: 'Контент', icon: 'Edit' },
listProperties: ['id', 'block_key', 'title', 'section', 'is_visible', 'sort_order'],
properties: {
body: { type: 'richtext' },
},
},
},
{
resource: Setting,
options: {
navigation: { name: 'Настройки', icon: 'Settings' },
listProperties: ['id', 'setting_key', 'setting_value', 'description'],
editProperties: ['setting_value', 'description'],
properties: {
setting_key: { isDisabled: true },
},
},
},
],
});
// Auth: only admin users
const bcrypt = require('bcrypt');
const db = require('./db');
const adminRouter = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
authenticate: async (email, password) => {
const user = await db.findUserByEmail(email);
if (!user || user.role !== 'admin') return null;
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) return null;
return { email: user.email, role: user.role, id: user.id };
},
cookieName: 'adminjs',
cookiePassword: process.env.SESSION_SECRET || 'adminjs-secret-change-me',
}, null, {
resave: false,
saveUninitialized: false,
secret: process.env.SESSION_SECRET || 'adminjs-secret-change-me',
});
app.use(adminJs.options.rootPath, adminRouter);
return adminJs;
}
module.exports = { setupAdmin, sequelize, User, Setting, Category, Tool, ContentBlock };

View File

@ -4,7 +4,7 @@
*/
const PUBLIC_PATHS = ['/', '/health', '/favicon.ico'];
const PUBLIC_PREFIXES = ['/auth/', '/vendor/', '/placeholder-img/'];
const PUBLIC_PREFIXES = ['/auth/', '/vendor/', '/placeholder-img/', '/api/', '/admin'];
const PUBLIC_FILES = ['/shared.css', '/shared.js', '/landing.html'];
function authMiddleware(req, res, next) {

6816
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,21 +12,28 @@
"type": "commonjs",
"description": "",
"dependencies": {
"@adminjs/express": "^6.1.1",
"@adminjs/sequelize": "^4.1.1",
"@adminjs/sql": "^2.2.6",
"@mozilla/readability": "^0.6.0",
"adminjs": "^7.8.17",
"archiver": "^7.0.1",
"bcrypt": "^6.0.0",
"dotenv": "^17.3.1",
"express": "^5.1.0",
"express-formidable": "^1.2.0",
"express-rate-limit": "^8.3.0",
"express-session": "^1.18.1",
"geoip-lite": "^1.4.10",
"iconv-lite": "^0.7.2",
"jsdom": "^28.1.0",
"mariadb": "^3.5.2",
"multer": "^1.4.5-lts.2",
"mysql2": "^3.20.0",
"openid-client": "^6.4.2",
"opentype.js": "^1.3.4",
"passport": "^0.7.0",
"sequelize": "^6.37.8",
"sharp": "^0.34.1"
}
}

View File

@ -49,8 +49,8 @@ const WA_TOOLS = [
// Утилиты
{ id: 'password', path: '/password', title: 'Пароли', category: 'utils', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"/>' },
{ id: 'md', path: '/md', title: 'Markdown', category: 'utils', icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"/>' },
// Admin
{ id: 'logs', path: '/logs', title: 'Логи', category: null, icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 010 3.75H5.625a1.875 1.875 0 010-3.75z"/>' },
// Admin (only visible in sidebar for admins — hidden by default, shown via JS)
{ id: 'admin', path: '/admin', title: 'Админ', category: null, adminOnly: true, icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>' },
];
/* Build sidebar SVG icon */
@ -78,7 +78,9 @@ function initSidebar(activeId) {
lastCategory = t.category;
const isActive = t.id === activeId ? ' active' : '';
linksHtml += `<a href="${t.path}" class="sidebar-link${isActive}" title="${t.title}">${_svgIcon(t.icon)}</a>\n`;
const adminClass = t.adminOnly ? ' sidebar-admin-link' : '';
const hidden = t.adminOnly ? ' style="display:none"' : '';
linksHtml += `<a href="${t.path}" class="sidebar-link${isActive}${adminClass}" title="${t.title}"${hidden}>${_svgIcon(t.icon)}</a>\n`;
}
const nav = document.createElement('nav');
@ -125,6 +127,13 @@ async function _loadUser() {
window.location.href = '/auth/logout';
}
});
// Show admin links for admin users
if (user.role === 'admin') {
document.querySelectorAll('.sidebar-admin-link').forEach(el => {
el.style.display = '';
});
}
} catch {}
}

49
routes/api.js Normal file
View File

@ -0,0 +1,49 @@
const express = require('express');
const router = express.Router();
const db = require('../lib/db');
// Public API — used by landing and dashboard to load dynamic content
// Get all settings as key-value object
router.get('/settings', async (req, res) => {
try {
const [rows] = await db.pool.execute('SELECT setting_key, setting_value FROM settings');
const settings = {};
for (const r of rows) settings[r.setting_key] = r.setting_value;
res.json(settings);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get categories with their tools
router.get('/tools', async (req, res) => {
try {
const [categories] = await db.pool.execute('SELECT * FROM categories ORDER BY sort_order');
const [tools] = await db.pool.execute('SELECT * FROM tools WHERE is_enabled = 1 ORDER BY sort_order');
const result = categories.map(cat => ({
...cat,
tools: tools.filter(t => t.category_id === cat.id),
}));
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get content blocks by section
router.get('/content/:section', async (req, res) => {
try {
const [rows] = await db.pool.execute(
'SELECT block_key, title, body, sort_order FROM content_blocks WHERE section = ? AND is_visible = 1 ORDER BY sort_order',
[req.params.section]
);
res.json(rows);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = router;

View File

@ -43,6 +43,7 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as
id: user.id,
email: user.email,
name: user.display_name || user.email.split('@')[0],
role: user.role || 'user',
};
log.info(`Login: ${user.email}`);

View File

@ -152,23 +152,23 @@ router.post('/convert', express.json(), async (req, res) => {
switch (mode) {
case 'convert': {
// Format conversion
if (format === 'mp4') args.push('-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'libvpx-vp9', '-c:a', 'libopus', '-b:v', '1M');
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-c:a', 'mp3');
else if (format === 'mkv') args.push('-c:v', 'libx264', '-c:a', 'aac');
// Format conversion (RPi5: h264_v4l2m2m hw encoder, mpeg4 sw fallback)
if (format === 'mp4') args.push('-c:v', 'h264_v4l2m2m', '-b:v', '2M', '-c:a', 'aac', '-movflags', '+faststart');
else if (format === 'webm') args.push('-c:v', 'vp8_v4l2m2m', '-b:v', '1M', '-c:a', 'opus');
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-b:v', '2M', '-c:a', 'aac');
else if (format === 'mkv') args.push('-c:v', 'h264_v4l2m2m', '-b:v', '2M', '-c:a', 'aac');
break;
}
case 'compress': {
// Quality: 18 (high) to 35 (low)
const crf = quality === 'high' ? 20 : quality === 'low' ? 32 : 26;
args.push('-c:v', 'libx264', '-crf', String(crf), '-preset', 'medium', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
// Quality via bitrate (h264_v4l2m2m doesn't support CRF)
const bv = quality === 'high' ? '1500k' : quality === 'low' ? '400k' : '800k';
args.push('-c:v', 'h264_v4l2m2m', '-b:v', bv, '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
break;
}
case 'audio': {
// Extract audio
// Extract audio (opus encoder available, aac encoder available)
args.push('-vn');
if (format === 'mp3') args.push('-c:a', 'libmp3lame', '-q:a', '2');
if (format === 'mp3') args.push('-c:a', 'aac', '-b:a', '192k'); // no libmp3lame, use aac as .aac
else args.push('-c:a', 'aac', '-b:a', '192k');
break;
}

View File

@ -22,6 +22,9 @@ app.use(express.static('public'));
// Auth routes (before auth middleware — public)
app.use('/auth', require('./routes/auth'));
// Public API (settings, tools, content — for landing page)
app.use('/api', require('./routes/api'));
// Auth middleware (protects everything below)
app.use(authMiddleware);
@ -62,7 +65,6 @@ app.use(require('./routes/httpclient'));
app.use(require('./routes/redirects'));
app.use(require('./routes/svgeditor'));
app.use('/video', require('./routes/video'));
app.use(require('./routes/logs'));
app.use(require('./routes/pages'));
// Global error handler
@ -71,28 +73,40 @@ app.use((err, req, res, next) => {
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
});
// Start server
// Start server (async for AdminJS setup)
const PORT = parseInt(process.env.PORT) || 3000;
const server = app.listen(PORT, () => {
log.info(`Server started on http://localhost:${PORT}`);
});
// Graceful shutdown
function shutdown(signal) {
log.info(`${signal} received, shutting down gracefully...`);
server.close(() => {
log.info('Server closed');
process.exit(0);
(async () => {
// AdminJS — loaded async (ESM modules)
try {
const { setupAdmin } = require('./lib/admin');
await setupAdmin(app);
log.info('AdminJS mounted at /admin');
} catch (err) {
log.error('AdminJS setup failed', { error: err.message });
}
const server = app.listen(PORT, () => {
log.info(`Server started on http://localhost:${PORT}`);
});
// Force close after 10 seconds
setTimeout(() => {
log.warn('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Graceful shutdown
function shutdown(signal) {
log.info(`${signal} received, shutting down gracefully...`);
server.close(() => {
log.info('Server closed');
process.exit(0);
});
setTimeout(() => {
log.warn('Forced shutdown after timeout');
process.exit(1);
}, 10000);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
})();
process.on('unhandledRejection', (err) => {
log.error('Unhandled rejection', { error: err?.message || String(err) });
});