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_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
6816
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
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,
|
||||
email: user.email,
|
||||
name: user.display_name || user.email.split('@')[0],
|
||||
role: user.role || 'user',
|
||||
};
|
||||
|
||||
log.info(`Login: ${user.email}`);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
52
server.js
52
server.js
@ -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) });
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user