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:
parent
056f73ca33
commit
1837f42a91
180
lib/admin.js
Normal file
180
lib/admin.js
Normal 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 };
|
||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const PUBLIC_PATHS = ['/', '/health', '/favicon.ico'];
|
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'];
|
const PUBLIC_FILES = ['/shared.css', '/shared.js', '/landing.html'];
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
function authMiddleware(req, res, next) {
|
||||||
|
|||||||
6816
package-lock.json
generated
6816
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,21 +12,28 @@
|
|||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@adminjs/express": "^6.1.1",
|
||||||
|
"@adminjs/sequelize": "^4.1.1",
|
||||||
|
"@adminjs/sql": "^2.2.6",
|
||||||
"@mozilla/readability": "^0.6.0",
|
"@mozilla/readability": "^0.6.0",
|
||||||
|
"adminjs": "^7.8.17",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"express-formidable": "^1.2.0",
|
||||||
"express-rate-limit": "^8.3.0",
|
"express-rate-limit": "^8.3.0",
|
||||||
"express-session": "^1.18.1",
|
"express-session": "^1.18.1",
|
||||||
"geoip-lite": "^1.4.10",
|
"geoip-lite": "^1.4.10",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"jsdom": "^28.1.0",
|
"jsdom": "^28.1.0",
|
||||||
|
"mariadb": "^3.5.2",
|
||||||
"multer": "^1.4.5-lts.2",
|
"multer": "^1.4.5-lts.2",
|
||||||
"mysql2": "^3.20.0",
|
"mysql2": "^3.20.0",
|
||||||
"openid-client": "^6.4.2",
|
"openid-client": "^6.4.2",
|
||||||
"opentype.js": "^1.3.4",
|
"opentype.js": "^1.3.4",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"sequelize": "^6.37.8",
|
||||||
"sharp": "^0.34.1"
|
"sharp": "^0.34.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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: '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"/>' },
|
{ 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
|
// Admin (only visible in sidebar for admins — hidden by default, shown via JS)
|
||||||
{ 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"/>' },
|
{ 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 */
|
/* Build sidebar SVG icon */
|
||||||
@ -78,7 +78,9 @@ function initSidebar(activeId) {
|
|||||||
lastCategory = t.category;
|
lastCategory = t.category;
|
||||||
|
|
||||||
const isActive = t.id === activeId ? ' active' : '';
|
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');
|
const nav = document.createElement('nav');
|
||||||
@ -125,6 +127,13 @@ async function _loadUser() {
|
|||||||
window.location.href = '/auth/logout';
|
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 {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
49
routes/api.js
Normal file
49
routes/api.js
Normal 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;
|
||||||
@ -43,6 +43,7 @@ router.post('/login', express.json(), express.urlencoded({ extended: true }), as
|
|||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.display_name || user.email.split('@')[0],
|
name: user.display_name || user.email.split('@')[0],
|
||||||
|
role: user.role || 'user',
|
||||||
};
|
};
|
||||||
|
|
||||||
log.info(`Login: ${user.email}`);
|
log.info(`Login: ${user.email}`);
|
||||||
|
|||||||
@ -152,23 +152,23 @@ router.post('/convert', express.json(), async (req, res) => {
|
|||||||
|
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case 'convert': {
|
case 'convert': {
|
||||||
// Format conversion
|
// Format conversion (RPi5: h264_v4l2m2m hw encoder, mpeg4 sw fallback)
|
||||||
if (format === 'mp4') args.push('-c:v', 'libx264', '-c:a', 'aac', '-movflags', '+faststart');
|
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', 'libvpx-vp9', '-c:a', 'libopus', '-b:v', '1M');
|
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', '-c:a', 'mp3');
|
else if (format === 'avi') args.push('-c:v', 'mpeg4', '-b:v', '2M', '-c:a', 'aac');
|
||||||
else if (format === 'mkv') args.push('-c:v', 'libx264', '-c:a', 'aac');
|
else if (format === 'mkv') args.push('-c:v', 'h264_v4l2m2m', '-b:v', '2M', '-c:a', 'aac');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'compress': {
|
case 'compress': {
|
||||||
// Quality: 18 (high) to 35 (low)
|
// Quality via bitrate (h264_v4l2m2m doesn't support CRF)
|
||||||
const crf = quality === 'high' ? 20 : quality === 'low' ? 32 : 26;
|
const bv = quality === 'high' ? '1500k' : quality === 'low' ? '400k' : '800k';
|
||||||
args.push('-c:v', 'libx264', '-crf', String(crf), '-preset', 'medium', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
|
args.push('-c:v', 'h264_v4l2m2m', '-b:v', bv, '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'audio': {
|
case 'audio': {
|
||||||
// Extract audio
|
// Extract audio (opus encoder available, aac encoder available)
|
||||||
args.push('-vn');
|
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');
|
else args.push('-c:a', 'aac', '-b:a', '192k');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
52
server.js
52
server.js
@ -22,6 +22,9 @@ app.use(express.static('public'));
|
|||||||
// Auth routes (before auth middleware — public)
|
// Auth routes (before auth middleware — public)
|
||||||
app.use('/auth', require('./routes/auth'));
|
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)
|
// Auth middleware (protects everything below)
|
||||||
app.use(authMiddleware);
|
app.use(authMiddleware);
|
||||||
|
|
||||||
@ -62,7 +65,6 @@ app.use(require('./routes/httpclient'));
|
|||||||
app.use(require('./routes/redirects'));
|
app.use(require('./routes/redirects'));
|
||||||
app.use(require('./routes/svgeditor'));
|
app.use(require('./routes/svgeditor'));
|
||||||
app.use('/video', require('./routes/video'));
|
app.use('/video', require('./routes/video'));
|
||||||
app.use(require('./routes/logs'));
|
|
||||||
app.use(require('./routes/pages'));
|
app.use(require('./routes/pages'));
|
||||||
|
|
||||||
// Global error handler
|
// Global error handler
|
||||||
@ -71,28 +73,40 @@ app.use((err, req, res, next) => {
|
|||||||
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start server
|
// Start server (async for AdminJS setup)
|
||||||
const PORT = parseInt(process.env.PORT) || 3000;
|
const PORT = parseInt(process.env.PORT) || 3000;
|
||||||
const server = app.listen(PORT, () => {
|
|
||||||
log.info(`Server started on http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
(async () => {
|
||||||
function shutdown(signal) {
|
// AdminJS — loaded async (ESM modules)
|
||||||
log.info(`${signal} received, shutting down gracefully...`);
|
try {
|
||||||
server.close(() => {
|
const { setupAdmin } = require('./lib/admin');
|
||||||
log.info('Server closed');
|
await setupAdmin(app);
|
||||||
process.exit(0);
|
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'));
|
// Graceful shutdown
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
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) => {
|
process.on('unhandledRejection', (err) => {
|
||||||
log.error('Unhandled rejection', { error: err?.message || String(err) });
|
log.error('Unhandled rejection', { error: err?.message || String(err) });
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user