Update files

This commit is contained in:
glpshchn 2025-11-10 23:13:22 +03:00
parent 138eba28e8
commit ce159a3fcd
32 changed files with 2909 additions and 401 deletions

View File

@ -0,0 +1,398 @@
const axios = require('axios');
const os = require('os');
const { exec } = require('child_process');
const FormData = require('form-data');
const fs = require('fs');
const config = require('../config');
const { log } = require('../middleware/logger');
const { listAdmins, addAdmin, removeAdmin, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const BOT_TOKEN = config.moderationBotToken;
const TELEGRAM_API = BOT_TOKEN ? `https://api.telegram.org/bot${BOT_TOKEN}` : null;
const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot';
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
let isPolling = false;
let offset = 0;
const execAsync = (command) =>
new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
return reject(new Error(stderr || error.message));
}
resolve(stdout);
});
});
const formatBytes = (bytes) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, i);
return `${value.toFixed(value >= 10 ? 0 : 1)} ${sizes[i]}`;
};
const formatDuration = (seconds) => {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const segments = [];
if (days) segments.push(`${days}д`);
if (hours) segments.push(`${hours}ч`);
if (minutes || segments.length === 0) segments.push(`${minutes}м`);
return segments.join(' ');
};
const getDiskUsage = async () => {
try {
const output = await execAsync('df -h /');
const lines = output.trim().split('\n');
if (lines.length < 2) return null;
const parts = lines[1].split(/\s+/);
return {
filesystem: parts[0],
size: parts[1],
used: parts[2],
available: parts[3],
percent: parseInt(parts[4], 10)
};
} catch (error) {
log('error', 'Не удалось получить информацию о диске', { error: error.message });
return null;
}
};
const buildStatus = ({ loadPerCore, memUsage, diskUsage }) => {
const issues = [];
let severity = 0;
if (loadPerCore > 1.5) {
issues.push('Высокая загрузка CPU (>150% на ядро)');
severity = Math.max(severity, 2);
} else if (loadPerCore > 1.0) {
issues.push('Нагрузка CPU растёт (>100% на ядро)');
severity = Math.max(severity, 1);
}
if (memUsage > 90) {
issues.push('Критический уровень памяти (>90%)');
severity = Math.max(severity, 2);
} else if (memUsage > 75) {
issues.push('Память почти заполнена (>75%)');
severity = Math.max(severity, 1);
}
if (diskUsage && diskUsage.percent) {
if (diskUsage.percent > 90) {
issues.push('Заканчивается место на диске (>90%)');
severity = Math.max(severity, 2);
} else if (diskUsage.percent > 80) {
issues.push('Мало свободного места на диске (>80%)');
severity = Math.max(severity, 1);
}
}
if (severity === 0) {
return { icon: '✅', text: 'Нагрузка в норме' };
}
if (severity === 1) {
return { icon: '⚠️', text: `Есть предупреждения:\n${issues.map((i) => `${i}`).join('\n')}` };
}
return { icon: '🔥', text: `Критические метрики:\n${issues.map((i) => `${i}`).join('\n')}` };
};
const buildStatsMessage = async () => {
const load = os.loadavg();
const cpuCount = os.cpus().length || 1;
const loadPerCore = load[0] / cpuCount;
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const memUsage = (usedMem / totalMem) * 100;
const diskUsage = await getDiskUsage();
const uptime = formatDuration(os.uptime());
const processUptime = formatDuration(process.uptime());
const status = buildStatus({ loadPerCore, memUsage, diskUsage });
const now = new Date();
const formatter = new Intl.DateTimeFormat('ru-RU', {
dateStyle: 'long',
timeStyle: 'medium'
});
const lines = [
`<b>Статистика сервера</b> ${status.icon}`,
`⏰ <b>Время:</b> ${formatter.format(now)}`,
`🆙 <b>Аптайм системы:</b> ${uptime}`,
`🔁 <b>Аптайм процесса:</b> ${processUptime}`,
'',
`🧠 <b>Загрузка CPU:</b> ${(load[0] || 0).toFixed(2)} / ${(load[1] || 0).toFixed(2)} / ${(load[2] || 0).toFixed(2)}`,
`⚙️ <b>На ядро:</b> ${(loadPerCore * 100).toFixed(0)}% (ядер: ${cpuCount})`,
'',
`💾 <b>Память:</b> ${formatBytes(usedMem)} / ${formatBytes(totalMem)} (${memUsage.toFixed(1)}%)`,
`🟢 <b>Свободно:</b> ${formatBytes(freeMem)}`,
''
];
if (diskUsage) {
lines.push(
`💽 <b>Диск:</b> ${diskUsage.used} / ${diskUsage.size} (${diskUsage.percent}% занято)`,
`📂 <b>Свободно:</b> ${diskUsage.available}`
);
} else {
lines.push('💽 <b>Диск:</b> не удалось получить информацию');
}
lines.push('', `🏷️ <b>Платформа:</b> ${os.type()} ${os.release()}`, `🔧 <b>Node.js:</b> ${process.version}`);
if (status.text) {
lines.push('', status.text);
}
return lines.join('\n');
};
const sendMessage = async (chatId, text) => {
if (!TELEGRAM_API) return;
try {
await axios.post(`${TELEGRAM_API}/sendMessage`, {
chat_id: chatId,
text: `${text}${ERROR_SUPPORT_SUFFIX}`,
parse_mode: 'HTML',
disable_web_page_preview: true
});
} catch (error) {
log('error', 'Не удалось отправить сообщение модераторским ботом', {
error: error.response?.data || error.message
});
}
};
const requireOwner = (message) => {
const username = normalizeUsername(message.from?.username || '');
return OWNER_USERNAMES.has(username);
};
const handleListAdmins = async (chatId) => {
const admins = await listAdmins();
if (!admins.length) {
await sendMessage(chatId, 'Список модераторов пуст.');
return;
}
const lines = admins.map((admin, index) => {
const name = [admin.firstName, admin.lastName].filter(Boolean).join(' ') || '-';
return `${index + 1}. @${admin.username} (${name || 'нет имени'})`;
});
await sendMessage(chatId, `<b>Модераторы MiniApp</b>\n${lines.join('\n')}`);
};
const handleAddAdmin = async (chatId, message, args) => {
if (!requireOwner(message)) {
await sendMessage(chatId, 'У вас нет прав добавлять модераторов.');
return;
}
const username = normalizeUsername(args[1] || '');
if (!username) {
await sendMessage(chatId, 'Использование: /addadmin @username');
return;
}
try {
const admin = await addAdmin({ username, addedBy: message.from?.username });
await sendMessage(
chatId,
`✅ @${admin.username} добавлен в список модераторов MiniApp. Теперь этому пользователю доступен модераторский интерфейс.`
);
} catch (error) {
await sendMessage(chatId, `${error.message}`);
}
};
const handleRemoveAdmin = async (chatId, message, args) => {
if (!requireOwner(message)) {
await sendMessage(chatId, 'У вас нет прав удалять модераторов.');
return;
}
const username = normalizeUsername(args[1] || '');
if (!username) {
await sendMessage(chatId, 'Использование: /removeadmin @username');
return;
}
try {
await removeAdmin(username);
await sendMessage(chatId, `✅ @${username} удалён из списка модераторов MiniApp.`);
} catch (error) {
await sendMessage(chatId, `${error.message}`);
}
};
const handleCommand = async (message) => {
const chatId = message.chat.id;
const text = (message.text || '').trim();
const args = text.split(/\s+/);
const command = args[0].toLowerCase();
if (command === '/start') {
await sendMessage(
chatId,
'<b>NakamaHost Moderation</b>\nКоманды:\n• /load — состояние сервера\n• /admins — список админов\n• /addadmin @username — добавить админа (только владельцы)\n• /removeadmin @username — убрать админа (только владельцы)'
);
return;
}
if (command === '/load') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
const reply = await buildStatsMessage();
await sendMessage(chatId, reply);
return;
}
if (command === '/admins') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleListAdmins(chatId);
return;
}
if (command === '/addadmin') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleAddAdmin(chatId, message, args);
return;
}
if (command === '/removeadmin') {
if (!requireOwner(message)) {
await sendMessage(chatId, 'Команда доступна только владельцу.');
return;
}
await handleRemoveAdmin(chatId, message, args);
return;
}
if (command.startsWith('/')) {
await sendMessage(chatId, 'Неизвестная команда. Используйте /start, /load, /admins.');
}
};
const processUpdate = async (update) => {
const message = update.message || update.edited_message;
if (!message || !message.text) {
return;
}
try {
await handleCommand(message);
} catch (error) {
log('error', 'Ошибка обработки команды модераторского бота', { error: error.message });
await sendMessage(message.chat.id, `Не удалось обработать команду: ${error.message}`);
}
};
const pollUpdates = async () => {
if (!TELEGRAM_API) return;
while (isPolling) {
try {
const response = await axios.get(`${TELEGRAM_API}/getUpdates`, {
params: {
timeout: 25,
offset
}
});
const updates = response.data?.result || [];
for (const update of updates) {
offset = update.update_id + 1;
await processUpdate(update);
}
} catch (error) {
log('error', 'Ошибка опроса Telegram для модераторского бота', {
error: error.response?.data || error.message
});
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
};
const startServerMonitorBot = () => {
if (!TELEGRAM_API) {
log('warn', 'MODERATION_BOT_TOKEN не установлен, модераторский бот не запущен');
return;
}
if (isPolling) {
return;
}
isPolling = true;
log('info', 'Модераторский Telegram бот запущен');
pollUpdates().catch((error) => {
log('error', 'Не удалось запустить модераторский бот', { error: error.message });
});
};
const sendChannelMediaGroup = async (files, caption) => {
if (!TELEGRAM_API) throw new Error('Модераторский бот не настроен');
const chatId = config.moderationChannelUsername || '@reichenbfurry';
const form = new FormData();
const media = files.map((file, index) => ({
type: 'photo',
media: `attach://file${index}`,
...(index === 0 ? { caption: `${caption}${ERROR_SUPPORT_SUFFIX}`, parse_mode: 'HTML' } : {})
}));
form.append('chat_id', chatId);
form.append('media', JSON.stringify(media));
files.forEach((file, index) => {
form.append(`file${index}`, fs.createReadStream(file.path), {
filename: file.filename || `image${index}.jpg`
});
});
try {
await axios.post(`${TELEGRAM_API}/sendMediaGroup`, form, {
headers: form.getHeaders()
});
} catch (error) {
log('error', 'Не удалось отправить медиа-группу в канал', { error: error.response?.data || error.message });
throw error;
} finally {
files.forEach((file) => {
fs.unlink(file.path, () => {});
});
}
};
module.exports = {
startServerMonitorBot,
sendChannelMediaGroup,
isModerationAdmin
};

View File

@ -17,6 +17,12 @@ module.exports = {
// Telegram
telegramBotToken: process.env.TELEGRAM_BOT_TOKEN,
moderationBotToken: process.env.MODERATION_BOT_TOKEN || process.env.SERVER_MONITOR_BOT_TOKEN || '7604181694:AAGmnpWtR2rknbZreWNoU3PtVWMFJdlwVmc',
moderationOwnerUsernames: (process.env.MODERATION_OWNER_USERNAMES || 'glpshchn00')
.split(',')
.map((name) => name.trim().toLowerCase())
.filter(Boolean),
moderationChannelUsername: process.env.MODERATION_CHANNEL_USERNAME || '@reichenbfurry',
// Gelbooru API
gelbooruApiKey: process.env.GELBOORU_API_KEY || '638e2433d451fc02e848811acdafdce08317073c01ed78e38139115c19fe04afa367f736726514ef1337565d4c05b3cbe2c81125c424301e90d29d1f7f4cceff',

View File

@ -0,0 +1,124 @@
const axios = require('axios');
const User = require('../models/User');
const config = require('../config');
const { log } = require('../middleware/logger');
const DAY_MS = 24 * 60 * 60 * 1000;
async function fetchLatestAvatar(telegramId) {
if (!config.telegramBotToken) {
return null;
}
try {
const apiBase = `https://api.telegram.org/bot${config.telegramBotToken}`;
const photosResponse = await axios.get(`${apiBase}/getUserProfilePhotos`, {
params: {
user_id: telegramId,
limit: 1
},
timeout: 15000
});
if (!photosResponse.data?.ok || photosResponse.data.result.total_count === 0) {
return null;
}
const photoSizes = photosResponse.data.result.photos?.[0];
if (!Array.isArray(photoSizes) || photoSizes.length === 0) {
return null;
}
const highestQualityPhoto = photoSizes[photoSizes.length - 1];
if (!highestQualityPhoto?.file_id) {
return null;
}
const fileResponse = await axios.get(`${apiBase}/getFile`, {
params: {
file_id: highestQualityPhoto.file_id
},
timeout: 15000
});
if (!fileResponse.data?.ok || !fileResponse.data.result?.file_path) {
return null;
}
const filePath = fileResponse.data.result.file_path;
return `https://api.telegram.org/file/bot${config.telegramBotToken}/${filePath}`;
} catch (error) {
log('error', 'Ошибка получения аватарки из Telegram', {
telegramId,
error: error.message
});
return null;
}
}
async function updateAllUserAvatars() {
if (!config.telegramBotToken) {
log('warn', 'Обновление аватарок отключено: TELEGRAM_BOT_TOKEN не установлен');
return;
}
const users = await User.find({ telegramId: { $exists: true } });
log('info', 'Начато обновление аватарок пользователей', { count: users.length });
for (const user of users) {
try {
const latestAvatar = await fetchLatestAvatar(user.telegramId);
if (latestAvatar && latestAvatar !== user.photoUrl) {
user.photoUrl = latestAvatar;
await user.save();
log('info', 'Аватарка обновлена', { userId: user._id, telegramId: user.telegramId });
}
} catch (error) {
log('error', 'Не удалось обновить аватарку пользователя', {
userId: user._id,
telegramId: user.telegramId,
error: error.message
});
}
}
log('info', 'Обновление аватарок завершено');
}
function msUntilNextRun(hour = 3) {
const now = new Date();
const nextRun = new Date(now);
nextRun.setHours(hour, 0, 0, 0);
if (nextRun <= now) {
nextRun.setDate(nextRun.getDate() + 1);
}
return nextRun.getTime() - now.getTime();
}
function scheduleAvatarUpdates() {
if (!config.telegramBotToken) {
log('warn', 'Расписание обновления аватарок отключено: TELEGRAM_BOT_TOKEN не установлен');
return;
}
const initialDelay = msUntilNextRun();
setTimeout(() => {
updateAllUserAvatars().catch((error) => {
log('error', 'Ошибка при запуске обновления аватарок', { error: error.message });
});
setInterval(() => {
updateAllUserAvatars().catch((error) => {
log('error', 'Ошибка при плановом обновлении аватарок', { error: error.message });
});
}, DAY_MS);
}, initialDelay);
}
module.exports = {
scheduleAvatarUpdates,
updateAllUserAvatars
};

View File

@ -4,6 +4,50 @@ const { validateTelegramId } = require('./validator');
const { logSecurityEvent } = require('./logger');
const config = require('../config');
const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot';
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
const touchUserActivity = async (user) => {
if (!user) return;
const now = Date.now();
const shouldUpdate =
!user.lastActiveAt ||
Math.abs(now - new Date(user.lastActiveAt).getTime()) > 5 * 60 * 1000;
if (shouldUpdate) {
user.lastActiveAt = new Date(now);
await user.save();
}
};
const ensureUserSettings = async (user) => {
if (!user) return;
let updated = false;
if (!user.settings) {
user.settings = {};
updated = true;
}
if (!ALLOWED_SEARCH_PREFERENCES.includes(user.settings.searchPreference)) {
user.settings.searchPreference = 'furry';
updated = true;
}
if (!user.settings.whitelist) {
user.settings.whitelist = { noNSFW: true };
updated = true;
} else if (user.settings.whitelist.noNSFW === undefined) {
user.settings.whitelist.noNSFW = true;
updated = true;
}
if (updated) {
user.markModified('settings');
await user.save();
}
};
// Проверка Telegram Init Data
function validateTelegramWebAppData(initData, botToken) {
const urlParams = new URLSearchParams(initData);
@ -41,7 +85,7 @@ const authenticate = async (req, res, next) => {
const user = await User.findOne({ telegramId: telegramUserId.toString() });
if (!user) {
return res.status(401).json({ error: 'Пользователь не найден' });
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
if (user.banned) {
@ -59,7 +103,7 @@ const authenticate = async (req, res, next) => {
if (!initData) {
console.warn('⚠️ Нет x-telegram-init-data заголовка');
return res.status(401).json({ error: 'Не авторизован' });
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
// Получаем user из initData
@ -84,13 +128,24 @@ const authenticate = async (req, res, next) => {
});
await user.save();
console.log(`✅ Создан новый пользователь: ${user.username}`);
} else {
user.username = parsed.user.username || parsed.user.first_name;
user.firstName = parsed.user.first_name;
user.lastName = parsed.user.last_name;
if (parsed.user.photo_url) {
user.photoUrl = parsed.user.photo_url;
}
await user.save();
}
await ensureUserSettings(user);
await touchUserActivity(user);
req.user = user;
return next();
}
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
} catch (e2) {
console.error('❌ Ошибка парсинга initData:', e2.message);
return res.status(401).json({ error: 'Неверный формат данных авторизации' });
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
}
@ -98,7 +153,7 @@ const authenticate = async (req, res, next) => {
if (!userParam) {
console.warn('⚠️ Нет user параметра в initData');
return res.status(401).json({ error: 'Данные пользователя не найдены' });
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
let telegramUser;
@ -106,7 +161,7 @@ const authenticate = async (req, res, next) => {
telegramUser = JSON.parse(userParam);
} catch (e) {
console.error('❌ Ошибка парсинга user:', e.message);
return res.status(401).json({ error: 'Ошибка парсинга данных пользователя' });
return res.status(401).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
req.telegramUser = telegramUser;
@ -149,8 +204,18 @@ const authenticate = async (req, res, next) => {
});
await user.save();
console.log(`✅ Создан новый пользователь: ${user.username}`);
} else {
user.username = telegramUser.username || telegramUser.first_name;
user.firstName = telegramUser.first_name;
user.lastName = telegramUser.last_name;
if (telegramUser.photo_url) {
user.photoUrl = telegramUser.photo_url;
}
await user.save();
}
await ensureUserSettings(user);
await touchUserActivity(user);
req.user = user;
next();
} catch (error) {

View File

@ -1,9 +1,10 @@
const config = require('../config');
const { log } = require('./logger');
// Централизованная обработка ошибок
const errorHandler = (err, req, res, next) => {
// Логирование ошибки
console.error('❌ Ошибка:', {
log('error', 'Ошибка обработчика', {
message: err.message,
stack: config.isDevelopment() ? err.stack : undefined,
path: req.path,
@ -58,13 +59,13 @@ const notFoundHandler = (req, res, next) => {
// Обработка необработанных промисов
process.on('unhandledRejection', (reason, promise) => {
console.error('❌ Unhandled Rejection:', reason);
log('error', 'Unhandled Rejection', { reason: reason instanceof Error ? reason.message : reason });
// В production можно отправить уведомление
});
// Обработка необработанных исключений
process.on('uncaughtException', (error) => {
console.error('❌ Uncaught Exception:', error);
log('error', 'Uncaught Exception', { message: error.message, stack: error.stack });
// Graceful shutdown
process.exit(1);
});

View File

@ -1,17 +1,30 @@
const fs = require('fs');
const path = require('path');
const config = require('../config');
// Создать директорию для логов если её нет
const logsDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
const getDatePrefix = () => {
return new Date().toISOString().slice(0, 10);
};
const appendLog = (fileName, message) => {
const filePath = path.join(logsDir, fileName);
fs.appendFile(filePath, `${message}\n`, (err) => {
if (err) {
console.error('Ошибка записи в лог файл:', err);
}
});
};
// Функция для логирования
const log = (level, message, data = {}) => {
const timestamp = new Date().toISOString();
const logMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
const serializedData = Object.keys(data).length ? ` ${JSON.stringify(data)}` : '';
const fullMessage = `${logMessage}${serializedData}`;
// Логирование в консоль
if (level === 'error') {
@ -22,17 +35,8 @@ const log = (level, message, data = {}) => {
console.log(logMessage, data);
}
// Логирование в файл (только в production)
if (config.isProduction()) {
const logFile = path.join(logsDir, `${level}.log`);
const fileMessage = `${logMessage} ${JSON.stringify(data)}\n`;
fs.appendFile(logFile, fileMessage, (err) => {
if (err) {
console.error('Ошибка записи в лог файл:', err);
}
});
}
const fileName = `${level}-${getDatePrefix()}.log`;
appendLog(fileName, fullMessage);
};
// Middleware для логирования запросов
@ -89,16 +93,8 @@ const logSecurityEvent = (type, req, details = {}) => {
log('warn', 'Security event', securityData);
// В production можно отправить уведомление
if (config.isProduction()) {
const securityLogFile = path.join(logsDir, 'security.log');
const message = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}\n`;
fs.appendFile(securityLogFile, message, (err) => {
if (err) {
console.error('Ошибка записи в security лог:', err);
}
});
}
const securityMessage = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`;
appendLog(`security-${getDatePrefix()}.log`, securityMessage);
};
module.exports = {

View File

@ -0,0 +1,30 @@
const mongoose = require('mongoose');
const ModerationAdminSchema = new mongoose.Schema({
telegramId: {
type: String,
required: true,
unique: true
},
username: {
type: String,
required: true,
lowercase: true,
trim: true,
unique: true
},
firstName: String,
lastName: String,
addedBy: {
type: String,
lowercase: true,
trim: true
},
createdAt: {
type: Date,
default: Date.now
}
});
module.exports = mongoose.model('ModerationAdmin', ModerationAdminSchema);

View File

@ -39,10 +39,14 @@ const UserSchema = new mongoose.Schema({
},
searchPreference: {
type: String,
enum: ['furry', 'anime', 'mixed'],
default: 'mixed'
enum: ['furry', 'anime'],
default: 'furry'
}
},
lastActiveAt: {
type: Date,
default: Date.now
},
banned: {
type: Boolean,
default: false

View File

@ -7,6 +7,30 @@ const { validateTelegramId } = require('../middleware/validator');
const { logSecurityEvent } = require('../middleware/logger');
const { strictAuthLimiter } = require('../middleware/security');
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
const normalizeUserSettings = (settings = {}) => {
const plainSettings = typeof settings.toObject === 'function' ? settings.toObject() : { ...settings };
const whitelistSource = plainSettings.whitelist;
const whitelist =
whitelistSource && typeof whitelistSource.toObject === 'function'
? whitelistSource.toObject()
: { ...(whitelistSource || {}) };
return {
...plainSettings,
whitelist: {
noNSFW: whitelist?.noNSFW ?? true,
...whitelist
},
searchPreference: ALLOWED_SEARCH_PREFERENCES.includes(plainSettings.searchPreference)
? plainSettings.searchPreference
: 'furry'
};
};
const OFFICIAL_CLIENT_MESSAGE = 'Используйте официальный клиент. Сообщите об ошибке в https://t.me/NakamaReportbot';
// Проверка подписи Telegram OAuth (Login Widget)
function validateTelegramOAuth(authData, botToken) {
if (!authData || !authData.hash) {
@ -129,6 +153,8 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => {
{ path: 'following', select: 'username firstName lastName photoUrl' }
]);
const settings = normalizeUserSettings(populatedUser.settings);
res.json({
success: true,
user: {
@ -142,7 +168,7 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => {
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
settings: populatedUser.settings,
settings,
banned: populatedUser.banned
}
});
@ -162,6 +188,8 @@ router.post('/verify', authenticate, async (req, res) => {
{ path: 'following', select: 'username firstName lastName photoUrl' }
]);
const settings = normalizeUserSettings(user.settings);
res.json({
success: true,
user: {
@ -175,7 +203,7 @@ router.post('/verify', authenticate, async (req, res) => {
role: user.role,
followersCount: user.followers.length,
followingCount: user.following.length,
settings: user.settings,
settings,
banned: user.banned
}
});
@ -198,7 +226,7 @@ router.post('/session', async (req, res) => {
const user = await User.findOne({ telegramId: telegramId.toString() });
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
return res.status(404).json({ error: OFFICIAL_CLIENT_MESSAGE });
}
if (user.banned) {
@ -224,7 +252,7 @@ router.post('/session', async (req, res) => {
role: populatedUser.role,
followersCount: populatedUser.followers.length,
followingCount: populatedUser.following.length,
settings: populatedUser.settings,
settings,
banned: populatedUser.banned
}
});

419
backend/routes/modApp.js Normal file
View File

@ -0,0 +1,419 @@
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const multer = require('multer');
const { authenticate } = require('../middleware/auth');
const { logSecurityEvent } = require('../middleware/logger');
const User = require('../models/User');
const Post = require('../models/Post');
const Report = require('../models/Report');
const { listAdmins, isModerationAdmin, normalizeUsername } = require('../services/moderationAdmin');
const { sendChannelMediaGroup } = require('../bots/serverMonitor');
const config = require('../config');
const TEMP_DIR = path.join(__dirname, '../uploads/mod-channel');
if (!fs.existsSync(TEMP_DIR)) {
fs.mkdirSync(TEMP_DIR, { recursive: true });
}
const upload = multer({
storage: multer.diskStorage({
destination: TEMP_DIR,
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
const ext = path.extname(file.originalname || '');
cb(null, `${unique}${ext || '.jpg'}`);
}
}),
limits: {
files: 10,
fileSize: 15 * 1024 * 1024 // 15MB
}
});
const OWNER_USERNAMES = new Set(config.moderationOwnerUsernames || []);
const requireModerationAccess = async (req, res, next) => {
const username = normalizeUsername(req.user?.username);
const telegramId = req.user?.telegramId;
if (!username || !telegramId) {
return res.status(401).json({ error: 'Требуется авторизация' });
}
if (OWNER_USERNAMES.has(username) || req.user.role === 'admin') {
req.isModerationAdmin = true;
return next();
}
const allowed = await isModerationAdmin({ telegramId, username });
if (!allowed) {
return res.status(403).json({ error: 'Недостаточно прав для модерации' });
}
req.isModerationAdmin = true;
return next();
};
const serializeUser = (user) => ({
id: user._id,
username: user.username,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
banned: user.banned,
bannedUntil: user.bannedUntil,
lastActiveAt: user.lastActiveAt,
createdAt: user.createdAt
});
router.post('/auth/verify', authenticate, requireModerationAccess, async (req, res) => {
const admins = await listAdmins();
res.json({
success: true,
user: {
id: req.user._id,
username: req.user.username,
firstName: req.user.firstName,
lastName: req.user.lastName,
role: req.user.role,
telegramId: req.user.telegramId
},
admins
});
});
router.get('/users', authenticate, requireModerationAccess, async (req, res) => {
const { filter = 'active', page = 1, limit = 50 } = req.query;
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 50, 1), 200);
const skip = (pageNum - 1) * limitNum;
const threshold = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
let query = {};
if (filter === 'active') {
query = { lastActiveAt: { $gte: threshold } };
} else if (filter === 'inactive') {
query = {
$or: [
{ lastActiveAt: { $lt: threshold } },
{ lastActiveAt: { $exists: false } }
],
banned: { $ne: true }
};
} else if (filter === 'banned') {
query = { banned: true };
}
const [users, total] = await Promise.all([
User.find(query)
.sort({ lastActiveAt: -1 })
.skip(skip)
.limit(limitNum)
.lean(),
User.countDocuments(query)
]);
res.json({
users: users.map(serializeUser),
total,
totalPages: Math.ceil(total / limitNum),
currentPage: pageNum
});
});
router.put('/users/:id/ban', authenticate, requireModerationAccess, async (req, res) => {
const { banned, days } = req.body;
const user = await User.findById(req.params.id);
if (!user) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
user.banned = !!banned;
if (user.banned) {
const durationDays = Math.max(parseInt(days, 10) || 7, 1);
user.bannedUntil = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000);
} else {
user.bannedUntil = null;
}
await user.save();
res.json({ user: serializeUser(user) });
});
router.get('/posts', authenticate, requireModerationAccess, async (req, res) => {
const { page = 1, limit = 20, author, tag } = req.query;
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100);
const skip = (pageNum - 1) * limitNum;
const query = {};
if (author) {
query.author = author;
}
if (tag) {
query.tags = tag;
}
const [posts, total] = await Promise.all([
Post.find(query)
.populate('author', 'username firstName lastName role banned bannedUntil lastActiveAt')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitNum)
.lean(),
Post.countDocuments(query)
]);
const serialized = posts.map((post) => ({
id: post._id,
author: post.author ? serializeUser(post.author) : null,
content: post.content,
hashtags: post.hashtags,
tags: post.tags,
images: post.images || (post.imageUrl ? [post.imageUrl] : []),
commentsCount: post.comments?.length || 0,
likesCount: post.likes?.length || 0,
isNSFW: post.isNSFW,
createdAt: post.createdAt
}));
res.json({
posts: serialized,
total,
totalPages: Math.ceil(total / limitNum),
currentPage: pageNum
});
});
router.put('/posts/:id', authenticate, requireModerationAccess, async (req, res) => {
const { content, hashtags, tags, isNSFW } = req.body;
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
if (content !== undefined) {
post.content = content;
post.hashtags = Array.isArray(hashtags)
? hashtags.map((tag) => tag.toLowerCase())
: post.hashtags;
}
if (tags !== undefined) {
post.tags = Array.isArray(tags) ? tags : post.tags;
}
if (isNSFW !== undefined) {
post.isNSFW = !!isNSFW;
}
post.editedAt = new Date();
await post.save();
await post.populate('author', 'username firstName lastName role banned bannedUntil');
res.json({
post: {
id: post._id,
author: post.author ? serializeUser(post.author) : null,
content: post.content,
hashtags: post.hashtags,
tags: post.tags,
images: post.images,
isNSFW: post.isNSFW,
editedAt: post.editedAt,
createdAt: post.createdAt
}
});
});
router.delete('/posts/:id', authenticate, requireModerationAccess, async (req, res) => {
const post = await Post.findById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
// Удалить локальные изображения
if (post.images && post.images.length) {
post.images.forEach((imagePath) => {
if (!imagePath.startsWith('/uploads')) return;
const fullPath = path.join(__dirname, '..', imagePath);
if (fs.existsSync(fullPath)) {
fs.unlink(fullPath, () => {});
}
});
}
await Post.deleteOne({ _id: post._id });
res.json({ success: true });
});
router.delete('/posts/:id/images/:index', authenticate, requireModerationAccess, async (req, res) => {
const { id, index } = req.params;
const idx = parseInt(index, 10);
const post = await Post.findById(id);
if (!post) {
return res.status(404).json({ error: 'Пост не найден' });
}
if (!Array.isArray(post.images) || idx < 0 || idx >= post.images.length) {
return res.status(400).json({ error: 'Неверный индекс изображения' });
}
const [removed] = post.images.splice(idx, 1);
post.imageUrl = post.images[0] || null;
await post.save();
if (removed && removed.startsWith('/uploads')) {
const fullPath = path.join(__dirname, '..', removed);
if (fs.existsSync(fullPath)) {
fs.unlink(fullPath, () => {});
}
}
res.json({ images: post.images });
});
router.post('/posts/:id/ban', authenticate, requireModerationAccess, async (req, res) => {
const { id } = req.params;
const { days = 7 } = req.body;
const post = await Post.findById(id).populate('author');
if (!post || !post.author) {
return res.status(404).json({ error: 'Пост или автор не найден' });
}
const durationDays = Math.max(parseInt(days, 10) || 7, 1);
post.author.banned = true;
post.author.bannedUntil = new Date(Date.now() + durationDays * 24 * 60 * 60 * 1000);
await post.author.save();
res.json({ user: serializeUser(post.author) });
});
router.get('/reports', authenticate, requireModerationAccess, async (req, res) => {
const { page = 1, limit = 30, status = 'pending' } = req.query;
const pageNum = Math.max(parseInt(page, 10) || 1, 1);
const limitNum = Math.min(Math.max(parseInt(limit, 10) || 30, 1), 100);
const skip = (pageNum - 1) * limitNum;
const query = status === 'all' ? {} : { status };
const [reports, total] = await Promise.all([
Report.find(query)
.populate('reporter', 'username firstName lastName telegramId')
.populate({
path: 'post',
populate: {
path: 'author',
select: 'username firstName lastName telegramId banned bannedUntil'
}
})
.sort({ createdAt: -1 })
.skip(skip)
.limit(limitNum)
.lean(),
Report.countDocuments(query)
]);
res.json({
reports: reports.map((report) => ({
id: report._id,
reporter: report.reporter ? serializeUser(report.reporter) : null,
status: report.status,
reason: report.reason || 'Не указана',
createdAt: report.createdAt,
post: report.post
? {
id: report.post._id,
content: report.post.content,
images: report.post.images || (report.post.imageUrl ? [report.post.imageUrl] : []),
author: report.post.author ? serializeUser(report.post.author) : null
}
: null
})),
total,
totalPages: Math.ceil(total / limitNum),
currentPage: pageNum
});
});
router.put('/reports/:id', authenticate, requireModerationAccess, async (req, res) => {
const { status = 'reviewed' } = req.body;
const report = await Report.findById(req.params.id);
if (!report) {
return res.status(404).json({ error: 'Репорт не найден' });
}
report.status = status;
report.reviewedBy = req.user._id;
await report.save();
res.json({ success: true });
});
router.post(
'/channel/publish',
authenticate,
requireModerationAccess,
upload.array('images', 10),
async (req, res) => {
const { description = '', tags, slot } = req.body;
const files = req.files || [];
if (!files.length) {
return res.status(400).json({ error: 'Загрузите хотя бы одно изображение' });
}
const slotNumber = Math.max(Math.min(parseInt(slot, 10) || 1, 10), 1);
let tagsArray = [];
if (typeof tags === 'string' && tags.trim()) {
try {
tagsArray = JSON.parse(tags);
} catch {
tagsArray = tags.split(/[,\s]+/).filter(Boolean);
}
} else if (Array.isArray(tags)) {
tagsArray = tags;
}
const formattedTags = tagsArray
.map((tag) => tag.trim())
.filter(Boolean)
.map((tag) => (tag.startsWith('#') ? tag : `#${tag}`));
if (!formattedTags.includes(`#a${slotNumber}`)) {
formattedTags.push(`#a${slotNumber}`);
}
const captionLines = [];
if (description) {
captionLines.push(description);
}
if (formattedTags.length) {
captionLines.push('', formattedTags.join(' '));
}
const caption = captionLines.join('\n');
try {
await sendChannelMediaGroup(files, caption);
res.json({ success: true });
} catch (error) {
logSecurityEvent('CHANNEL_PUBLISH_FAILED', req, { error: error.message });
res.status(500).json({ error: 'Не удалось опубликовать в канал' });
}
}
);
module.exports = router;

View File

@ -10,8 +10,8 @@ router.post('/report', authenticate, async (req, res) => {
try {
const { postId, reason } = req.body;
if (!postId || !reason) {
return res.status(400).json({ error: 'postId и reason обязательны' });
if (!postId) {
return res.status(400).json({ error: 'postId обязателен' });
}
const post = await Post.findById(postId);
@ -19,10 +19,12 @@ router.post('/report', authenticate, async (req, res) => {
return res.status(404).json({ error: 'Пост не найден' });
}
const finalReason = reason?.trim() || 'Без указания причины';
const report = new Report({
reporter: req.user._id,
post: postId,
reason
reason: finalReason
});
await report.save();

View File

@ -4,6 +4,46 @@ const axios = require('axios');
const { authenticate } = require('../middleware/auth');
const config = require('../config');
const E621_USER_AGENT = 'NakamaSpace/1.0 (by Reichenbach on e621)';
const CACHE_TTL_MS = 60 * 1000; // 1 минута
const searchCache = new Map();
function getCacheKey(source, params) {
return `${source}:${params.query}:${params.limit || ''}:${params.page || ''}`;
}
function getFromCache(key) {
const entry = searchCache.get(key);
if (!entry) return null;
if (entry.expires < Date.now()) {
searchCache.delete(key);
return null;
}
return entry.data;
}
function setCache(key, data) {
if (searchCache.size > 200) {
// Удалить устаревшие записи, если превышен лимит
for (const [cacheKey, entry] of searchCache.entries()) {
if (entry.expires < Date.now()) {
searchCache.delete(cacheKey);
}
}
if (searchCache.size > 200) {
const oldestKey = searchCache.keys().next().value;
if (oldestKey) {
searchCache.delete(oldestKey);
}
}
}
searchCache.set(key, {
data,
expires: Date.now() + CACHE_TTL_MS
});
}
// Функция для создания прокси URL
function createProxyUrl(originalUrl) {
if (!originalUrl) return null;
@ -42,7 +82,7 @@ router.get('/proxy/:encodedUrl', async (req, res) => {
const response = await axios.get(originalUrl, {
responseType: 'stream',
headers: {
'User-Agent': 'NakamaHost/1.0',
'User-Agent': E621_USER_AGENT,
'Referer': urlObj.origin
},
timeout: 30000 // 30 секунд таймаут
@ -73,6 +113,12 @@ router.get('/furry', authenticate, async (req, res) => {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
const cacheKey = getCacheKey('e621', { query: query.trim(), limit, page });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
// Поддержка множественных тегов через пробел
// e621 API автоматически обрабатывает теги через пробел в параметре tags
@ -84,7 +130,7 @@ router.get('/furry', authenticate, async (req, res) => {
page: parseInt(page) || 1
},
headers: {
'User-Agent': 'NakamaHost/1.0'
'User-Agent': E621_USER_AGENT
},
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
@ -112,7 +158,9 @@ router.get('/furry', authenticate, async (req, res) => {
source: 'e621'
}));
return res.json({ posts });
const payload = { posts };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
@ -138,9 +186,11 @@ router.get('/anime', authenticate, async (req, res) => {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
// Поддержка множественных тегов через пробел
// Gelbooru API автоматически обрабатывает теги через пробел в параметре tags
const cacheKey = getCacheKey('gelbooru', { query: query.trim(), limit, page });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
try {
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
@ -187,7 +237,9 @@ router.get('/anime', authenticate, async (req, res) => {
source: 'gelbooru'
}));
return res.json({ posts });
const payload = { posts };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
@ -216,6 +268,12 @@ router.get('/furry/tags', authenticate, async (req, res) => {
return res.json({ tags: [] });
}
const cacheKey = getCacheKey('e621-tags', { query: query.trim().toLowerCase() });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
try {
const response = await axios.get('https://e621.net/tags.json', {
params: {
@ -224,7 +282,7 @@ router.get('/furry/tags', authenticate, async (req, res) => {
limit: 10
},
headers: {
'User-Agent': 'NakamaHost/1.0'
'User-Agent': E621_USER_AGENT
},
timeout: 10000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
@ -247,7 +305,9 @@ router.get('/furry/tags', authenticate, async (req, res) => {
count: tag.post_count
}));
return res.json({ tags });
const payload = { tags };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
@ -320,7 +380,9 @@ router.get('/anime/tags', authenticate, async (req, res) => {
count: tag.count || tag.post_count || 0
})).filter(tag => tag.name);
return res.json({ tags });
const payload = { tags };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {

View File

@ -5,6 +5,8 @@ const User = require('../models/User');
const Post = require('../models/Post');
const Notification = require('../models/Notification');
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime'];
// Получить профиль пользователя
router.get('/:id', authenticate, async (req, res) => {
try {
@ -121,7 +123,37 @@ router.put('/profile', authenticate, async (req, res) => {
}
if (settings) {
req.user.settings = { ...req.user.settings, ...settings };
req.user.settings = req.user.settings || {};
if (settings.whitelist) {
const currentWhitelist =
req.user.settings.whitelist && typeof req.user.settings.whitelist.toObject === 'function'
? req.user.settings.whitelist.toObject()
: { ...(req.user.settings.whitelist || {}) };
req.user.settings.whitelist = {
...currentWhitelist,
...settings.whitelist
};
}
if (settings.searchPreference) {
req.user.settings.searchPreference = ALLOWED_SEARCH_PREFERENCES.includes(settings.searchPreference)
? settings.searchPreference
: 'furry';
}
if (!req.user.settings.searchPreference) {
req.user.settings.searchPreference = 'furry';
}
if (!req.user.settings.whitelist) {
req.user.settings.whitelist = { noNSFW: true };
} else if (req.user.settings.whitelist.noNSFW === undefined) {
req.user.settings.whitelist.noNSFW = true;
}
req.user.markModified('settings');
}
await req.user.save();

View File

@ -24,6 +24,10 @@ const {
const { sanitizeInput } = require('./middleware/validator');
const { requestLogger, logSecurityEvent } = require('./middleware/logger');
const { errorHandler, notFoundHandler } = require('./middleware/errorHandler');
const { scheduleAvatarUpdates } = require('./jobs/avatarUpdater');
const { startServerMonitorBot } = require('./bots/serverMonitor');
const ERROR_SUPPORT_SUFFIX = ' Сообщите об ошибке в https://t.me/NakamaReportbot';
const app = express();
const server = http.createServer(app);
@ -66,6 +70,49 @@ app.use(requestLogger);
// Static files
app.use('/uploads', express.static(path.join(__dirname, config.uploadsDir)));
// Дополнение ошибок сообщением о канале связи
app.use((req, res, next) => {
const originalJson = res.json.bind(res);
res.json = (body) => {
const appendSuffix = (obj) => {
if (!obj || typeof obj !== 'object') {
return;
}
if (typeof obj.error === 'string' && !obj.error.includes(ERROR_SUPPORT_SUFFIX)) {
obj.error += ERROR_SUPPORT_SUFFIX;
}
if (typeof obj.message === 'string' && res.statusCode >= 400 && !obj.message.includes(ERROR_SUPPORT_SUFFIX)) {
obj.message += ERROR_SUPPORT_SUFFIX;
}
if (Array.isArray(obj.errors)) {
obj.errors = obj.errors.map((item) => {
if (typeof item === 'string') {
return item.includes(ERROR_SUPPORT_SUFFIX) ? item : `${item}${ERROR_SUPPORT_SUFFIX}`;
}
if (item && typeof item === 'object' && typeof item.message === 'string' && !item.message.includes(ERROR_SUPPORT_SUFFIX)) {
return { ...item, message: `${item.message}${ERROR_SUPPORT_SUFFIX}` };
}
return item;
});
}
};
if (Array.isArray(body)) {
body.forEach((item) => appendSuffix(item));
} else {
appendSuffix(body);
}
return originalJson(body);
};
next();
});
// DDoS защита (применяется перед другими rate limiters)
app.use(ddosProtection);
@ -104,6 +151,7 @@ app.use('/api/search/posts', require('./routes/postSearch'));
app.use('/api/moderation', require('./routes/moderation'));
app.use('/api/statistics', require('./routes/statistics'));
app.use('/api/bot', require('./routes/bot'));
app.use('/api/mod-app', require('./routes/modApp'));
// Базовый роут
app.get('/', (req, res) => {
@ -118,6 +166,8 @@ app.use(errorHandler);
// Инициализировать WebSocket
initWebSocket(server);
scheduleAvatarUpdates();
startServerMonitorBot();
// Graceful shutdown
process.on('SIGTERM', () => {

View File

@ -0,0 +1,89 @@
const ModerationAdmin = require('../models/ModerationAdmin');
const User = require('../models/User');
const config = require('../config');
const normalizeUsername = (username = '') => username.replace(/^@/, '').trim().toLowerCase();
const ownerUsernames = new Set(config.moderationOwnerUsernames || []);
const listAdmins = async () => {
const admins = await ModerationAdmin.find().sort({ username: 1 }).lean();
return admins.map((admin) => ({
telegramId: admin.telegramId,
username: admin.username,
firstName: admin.firstName,
lastName: admin.lastName,
addedBy: admin.addedBy,
createdAt: admin.createdAt
}));
};
const isModerationAdmin = async ({ telegramId, username }) => {
const normalized = normalizeUsername(username);
if (ownerUsernames.has(normalized)) {
return true;
}
if (telegramId) {
const byId = await ModerationAdmin.findOne({ telegramId }).lean();
if (byId) {
return true;
}
}
if (normalized) {
const byUsername = await ModerationAdmin.findOne({ username: normalized }).lean();
if (byUsername) {
return true;
}
}
return false;
};
const addAdmin = async ({ username, addedBy }) => {
const normalized = normalizeUsername(username);
if (!normalized) {
throw new Error('Укажите username в формате @username');
}
const user = await User.findOne({ username: normalized }).lean();
if (!user) {
throw new Error(`Пользователь ${normalized} не найден в Nakama`);
}
const existing = await ModerationAdmin.findOne({ username: normalized });
if (existing) {
return existing;
}
const admin = new ModerationAdmin({
telegramId: user.telegramId,
username: normalized,
firstName: user.firstName,
lastName: user.lastName,
addedBy: normalizeUsername(addedBy)
});
await admin.save();
return admin;
};
const removeAdmin = async (username) => {
const normalized = normalizeUsername(username);
if (!normalized) {
throw new Error('Укажите username в формате @username');
}
if (ownerUsernames.has(normalized)) {
throw new Error('Нельзя удалить владельца');
}
await ModerationAdmin.deleteOne({ username: normalized });
};
module.exports = {
listAdmins,
isModerationAdmin,
addAdmin,
removeAdmin,
normalizeUsername
};

View File

@ -1,8 +1,12 @@
const { Server } = require('socket.io');
const config = require('./config');
const Notification = require('./models/Notification');
const { isModerationAdmin, normalizeUsername } = require('./services/moderationAdmin');
const { log } = require('./middleware/logger');
let io = null;
let moderationNamespace = null;
const connectedModerators = new Map();
// Инициализация WebSocket сервера
function initWebSocket(server) {
@ -34,10 +38,86 @@ function initWebSocket(server) {
});
});
registerModerationChat();
console.log('✅ WebSocket сервер инициализирован');
return io;
}
function registerModerationChat() {
if (!io || moderationNamespace) {
return;
}
moderationNamespace = io.of('/mod-chat');
const broadcastOnline = () => {
const unique = Array.from(
new Map(
Array.from(connectedModerators.values()).map((value) => [value.username, value])
).values()
);
moderationNamespace.emit('online', unique);
};
moderationNamespace.on('connection', (socket) => {
socket.data.authorized = false;
socket.on('auth', async (payload = {}) => {
const username = normalizeUsername(payload.username);
const telegramId = payload.telegramId;
if (!username || !telegramId) {
socket.emit('unauthorized');
return socket.disconnect(true);
}
const allowed = await isModerationAdmin({ username, telegramId });
if (!allowed) {
socket.emit('unauthorized');
return socket.disconnect(true);
}
socket.data.authorized = true;
socket.data.username = username;
socket.data.telegramId = telegramId;
connectedModerators.set(socket.id, {
username,
telegramId
});
socket.emit('ready');
broadcastOnline();
});
socket.on('message', (payload = {}) => {
if (!socket.data.authorized) {
return;
}
const text = (payload.text || '').trim();
if (!text) {
return;
}
const message = {
id: `${Date.now()}-${Math.round(Math.random() * 1e6)}`,
username: socket.data.username,
telegramId: socket.data.telegramId,
text,
createdAt: new Date().toISOString()
};
moderationNamespace.emit('message', message);
});
socket.on('disconnect', () => {
connectedModerators.delete(socket.id);
broadcastOnline();
});
});
}
// Отправить уведомление пользователю в real-time
function sendNotification(userId, notification) {
if (io) {

View File

@ -1,7 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from 'react-router-dom'
import { useState, useEffect, useRef } from 'react'
import { initTelegramApp, getTelegramUser, isThirdPartyClient } from './utils/telegram'
import { verifyAuth, authWithTelegramOAuth, verifySession } from './utils/api'
import { initTelegramApp } from './utils/telegram'
import { verifyAuth, verifySession } from './utils/api'
import { initTheme } from './utils/theme'
import Layout from './components/Layout'
import Feed from './pages/Feed'
@ -11,7 +11,6 @@ import Profile from './pages/Profile'
import UserProfile from './pages/UserProfile'
import CommentsPage from './pages/CommentsPage'
import PostMenuPage from './pages/PostMenuPage'
import TelegramLogin from './components/TelegramLogin'
import './styles/index.css'
function AppContent() {
@ -136,26 +135,6 @@ function AppContent() {
}
}
const handleTelegramAuth = async (telegramUser) => {
try {
setLoading(true)
// Отправить данные OAuth на backend
const userData = await authWithTelegramOAuth(telegramUser)
// Сохранить сессию для будущих загрузок
localStorage.setItem('nakama_user', JSON.stringify(userData))
localStorage.setItem('nakama_auth_type', 'oauth')
setUser(userData)
setShowLogin(false)
} catch (err) {
console.error('Ошибка авторизации:', err)
setError(err.message || 'Ошибка авторизации через Telegram')
} finally {
setLoading(false)
}
}
if (loading) {
return (
<div style={{
@ -174,9 +153,42 @@ function AppContent() {
// Показать Login Widget если нет авторизации
if (showLogin) {
// Получить имя бота из конфига или переменных окружения
const botName = import.meta.env.VITE_TELEGRAM_BOT_NAME || 'nakama_bot'
return <TelegramLogin botName={botName} onAuth={handleTelegramAuth} />
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
padding: '24px',
textAlign: 'center',
flexDirection: 'column',
gap: '16px'
}}>
<h2 style={{ color: 'var(--text-primary)', margin: 0 }}>Используйте официальный клиент Telegram</h2>
<p style={{ color: 'var(--text-secondary)', margin: 0, maxWidth: '360px', lineHeight: 1.5 }}>
Для доступа к NakamaHost откройте бота в официальном приложении Telegram.
Если вы уже используете официальный клиент и видите это сообщение,
пожалуйста сообщите об ошибке в&nbsp;
<a href="https://t.me/NakamaReportbot" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>
@NakamaReportbot
</a>.
</p>
<button
onClick={() => window.location.reload()}
style={{
padding: '12px 24px',
borderRadius: '12px',
border: 'none',
background: 'var(--bg-primary)',
color: 'var(--text-primary)',
fontSize: '16px',
cursor: 'pointer'
}}
>
Перезагрузить
</button>
</div>
)
}
if (error) {

View File

@ -1,35 +0,0 @@
.telegram-login-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
background: var(--bg-secondary);
gap: 24px;
}
.login-header {
text-align: center;
margin-bottom: 20px;
}
.login-header h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-header p {
font-size: 16px;
color: var(--text-secondary);
}
.telegram-widget-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 60px;
}

View File

@ -1,55 +0,0 @@
import { useEffect, useRef } from 'react'
import './TelegramLogin.css'
export default function TelegramLogin({ botName, onAuth }) {
const containerRef = useRef(null)
useEffect(() => {
// Загрузить Telegram Login Widget скрипт
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', botName)
script.setAttribute('data-size', 'large')
script.setAttribute('data-onauth', 'onTelegramAuth(user)')
script.setAttribute('data-request-access', 'write')
script.async = true
// Глобальная функция для обработки авторизации
// Telegram Login Widget передает объект с данными пользователя
window.onTelegramAuth = (userData) => {
if (onAuth && userData) {
// userData содержит: id, first_name, last_name, username, photo_url, auth_date, hash
onAuth(userData)
}
}
if (containerRef.current) {
containerRef.current.appendChild(script)
}
return () => {
// Очистка при размонтировании
if (containerRef.current && script.parentNode) {
try {
containerRef.current.removeChild(script)
} catch (e) {
// Игнорируем ошибки при удалении
}
}
if (window.onTelegramAuth) {
delete window.onTelegramAuth
}
}
}, [botName, onAuth])
return (
<div className="telegram-login-container">
<div className="login-header">
<h2>Вход через Telegram</h2>
<p>Авторизуйтесь через Telegram для доступа к приложению</p>
</div>
<div ref={containerRef} className="telegram-widget-container" />
</div>
)
}

View File

@ -119,6 +119,107 @@
border-top: 1px solid var(--divider-color);
}
.profile-powered {
margin: 12px auto 0;
text-align: center;
font-size: 12px;
color: var(--text-secondary);
opacity: 0.7;
}
.donation-card {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 12px;
}
.donation-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.donation-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(255, 59, 48, 0.12);
color: #ff3b30;
display: flex;
align-items: center;
justify-content: center;
}
.donation-text h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.donation-text p {
margin: 4px 0 0;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
.donation-button {
align-self: flex-start;
padding: 10px 18px;
border-radius: 12px;
background: var(--text-primary);
color: var(--bg-primary);
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.donation-button:hover {
opacity: 0.85;
}
.search-switch {
display: flex;
background: var(--bg-primary);
border-radius: 14px;
padding: 6px;
gap: 6px;
}
.search-switch-btn {
flex: 1;
border: none;
border-radius: 10px;
padding: 10px 12px;
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.search-switch-btn.active {
background: var(--bg-secondary);
color: var(--text-primary);
}
.search-switch-btn:focus-visible {
outline: 2px solid var(--button-accent);
outline-offset: 2px;
}
.profile-powered {
text-align: center;
font-size: 12px;
color: var(--text-secondary);
opacity: 0.7;
}
.stat-item {
display: flex;
flex-direction: column;
@ -304,33 +405,6 @@
border-bottom: 1px solid var(--divider-color);
}
.radio-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.radio-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border-radius: 12px;
background: var(--bg-primary);
cursor: pointer;
}
.radio-item input {
width: 20px;
height: 20px;
accent-color: var(--button-accent);
}
.radio-item span {
font-size: 15px;
color: var(--text-primary);
}
.char-count {
text-align: right;
font-size: 12px;

View File

@ -1,22 +1,42 @@
import { useState } from 'react'
import { Settings, Heart, Edit2, Star, Shield } from 'lucide-react'
import { Settings, Heart, Edit2, Shield } from 'lucide-react'
import { updateProfile } from '../utils/api'
import { hapticFeedback, openTelegramLink } from '../utils/telegram'
import { hapticFeedback } from '../utils/telegram'
import ThemeToggle from '../components/ThemeToggle'
import './Profile.css'
const DONATION_URL = 'https://donatepay.ru/don/1435720'
const ALLOWED_SEARCH_PREFERENCES = ['furry', 'anime']
const normalizeSearchPreference = (value) =>
ALLOWED_SEARCH_PREFERENCES.includes(value) ? value : 'furry'
const DEFAULT_SETTINGS = {
whitelist: {
noNSFW: true
},
searchPreference: 'furry'
}
const normalizeSettings = (rawSettings = {}) => {
const mergedWhitelist = {
...DEFAULT_SETTINGS.whitelist,
...(rawSettings.whitelist || {})
}
return {
...DEFAULT_SETTINGS,
...rawSettings,
whitelist: mergedWhitelist,
searchPreference: normalizeSearchPreference(rawSettings.searchPreference)
}
}
export default function Profile({ user, setUser }) {
const [showSettings, setShowSettings] = useState(false)
const [showEditBio, setShowEditBio] = useState(false)
const [bio, setBio] = useState(user.bio || '')
const [settings, setSettings] = useState(user.settings || {
whitelist: {
noFurry: false,
onlyAnime: false,
noNSFW: true
},
searchPreference: 'mixed'
})
const [settings, setSettings] = useState(normalizeSettings(user.settings))
const [saving, setSaving] = useState(false)
const handleSaveBio = async () => {
@ -41,8 +61,10 @@ export default function Profile({ user, setUser }) {
setSaving(true)
hapticFeedback('light')
const updatedUser = await updateProfile({ settings })
setUser({ ...user, settings })
const normalizedSettings = normalizeSettings(settings)
await updateProfile({ settings: normalizedSettings })
setUser({ ...user, settings: normalizedSettings })
setSettings(normalizedSettings)
setShowSettings(false)
hapticFeedback('success')
} catch (error) {
@ -55,23 +77,22 @@ export default function Profile({ user, setUser }) {
const handleDonate = () => {
hapticFeedback('light')
// В будущем здесь будет интеграция Telegram Stars
openTelegramLink('https://t.me/donate')
window.open(DONATION_URL, '_blank', 'noopener,noreferrer')
}
const updateWhitelistSetting = async (key, value) => {
const newSettings = {
const updatedSettings = normalizeSettings({
...settings,
whitelist: {
...settings.whitelist,
[key]: value
}
}
setSettings(newSettings)
})
setSettings(updatedSettings)
// Сохранить сразу на сервер
try {
await updateProfile({ settings: newSettings })
await updateProfile({ settings: updatedSettings })
hapticFeedback('success')
} catch (error) {
console.error('Ошибка сохранения настроек:', error)
@ -80,10 +101,11 @@ export default function Profile({ user, setUser }) {
}
const updateSearchPreference = (value) => {
setSettings({
const updatedSettings = normalizeSettings({
...settings,
searchPreference: value
})
setSettings(updatedSettings)
}
return (
@ -141,6 +163,24 @@ export default function Profile({ user, setUser }) {
</div>
</div>
<div className="donation-card card">
<div className="donation-content">
<div className="donation-icon">
<Heart size={20} />
</div>
<div className="donation-text">
<h3>Поддержите проект</h3>
<p>Каждый взнос помогает развивать NakamaHost и запускать новые функции.</p>
</div>
</div>
<button className="donation-button" onClick={handleDonate}>
Перейти к донату
</button>
</div>
<div className="profile-powered">
Powered by glpshcn \\ RBach \\ E621 \\ GelBooru
</div>
{/* Быстрые настройки */}
<div className="quick-settings">
@ -221,36 +261,6 @@ export default function Profile({ user, setUser }) {
<div className="settings-section">
<h3>Фильтры контента</h3>
<div className="setting-row">
<div>
<div className="setting-name">Без Furry</div>
<div className="setting-desc">Скрыть посты с тегом Furry</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={settings.whitelist.noFurry}
onChange={(e) => updateWhitelistSetting('noFurry', e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="setting-row">
<div>
<div className="setting-name">Только Anime</div>
<div className="setting-desc">Показывать только Anime</div>
</div>
<label className="toggle">
<input
type="checkbox"
checked={settings.whitelist.onlyAnime}
onChange={(e) => updateWhitelistSetting('onlyAnime', e.target.checked)}
/>
<span className="toggle-slider" />
</label>
</div>
<div className="setting-row">
<div>
<div className="setting-name">Без NSFW</div>
@ -270,36 +280,21 @@ export default function Profile({ user, setUser }) {
<div className="settings-section">
<h3>Настройки поиска</h3>
<div className="radio-group">
<label className="radio-item">
<input
type="radio"
name="searchPref"
checked={settings.searchPreference === 'furry'}
onChange={() => updateSearchPreference('furry')}
/>
<span>Только Furry (e621)</span>
</label>
<label className="radio-item">
<input
type="radio"
name="searchPref"
checked={settings.searchPreference === 'anime'}
onChange={() => updateSearchPreference('anime')}
/>
<span>Только Anime (gelbooru)</span>
</label>
<label className="radio-item">
<input
type="radio"
name="searchPref"
checked={settings.searchPreference === 'mixed'}
onChange={() => updateSearchPreference('mixed')}
/>
<span>Смешанный поиск</span>
</label>
<div className="search-switch">
<button
type="button"
className={`search-switch-btn ${settings.searchPreference === 'furry' ? 'active' : ''}`}
onClick={() => updateSearchPreference('furry')}
>
Только Furry (e621)
</button>
<button
type="button"
className={`search-switch-btn ${settings.searchPreference === 'anime' ? 'active' : ''}`}
onClick={() => updateSearchPreference('anime')}
>
Только Anime (gelbooru)
</button>
</div>
</div>
</div>

View File

@ -221,33 +221,6 @@
color: #000000;
}
.load-more-container {
padding: 20px;
display: flex;
justify-content: center;
}
.load-more-btn {
padding: 12px 24px;
border-radius: 12px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
border: 1px solid var(--divider-color);
cursor: pointer;
transition: all 0.2s;
}
.load-more-btn:hover:not(:disabled) {
background: var(--bg-secondary);
}
.load-more-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.send-selected-bar {
position: fixed;
bottom: 80px;

View File

@ -7,7 +7,8 @@ import api from '../utils/api'
import './Search.css'
export default function Search({ user }) {
const [mode, setMode] = useState(user.settings?.searchPreference || 'furry')
const initialMode = user.settings?.searchPreference === 'anime' ? 'anime' : 'furry'
const [mode, setMode] = useState(initialMode)
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
@ -18,9 +19,6 @@ export default function Search({ user }) {
const [selectionMode, setSelectionMode] = useState(false)
const [showCreatePost, setShowCreatePost] = useState(false)
const [imageForPost, setImageForPost] = useState(null)
const [hasMore, setHasMore] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const touchStartX = useRef(0)
const touchEndX = useRef(0)
@ -85,34 +83,6 @@ export default function Search({ user }) {
}
}
const loadMoreResults = async (searchQuery, pageNum) => {
let newResults = []
if (mode === 'furry') {
try {
const furryResults = await searchFurry(searchQuery, { limit: 320, page: pageNum })
if (furryResults && Array.isArray(furryResults)) {
newResults = [...newResults, ...furryResults]
}
} catch (error) {
console.error('Ошибка e621 поиска:', error)
}
}
if (mode === 'anime') {
try {
const animeResults = await searchAnime(searchQuery, { limit: 320, page: pageNum })
if (animeResults && Array.isArray(animeResults)) {
newResults = [...newResults, ...animeResults]
}
} catch (error) {
console.error('Ошибка Gelbooru поиска:', error)
}
}
return newResults
}
const handleSearch = async (searchQuery = query) => {
if (!searchQuery.trim()) return
@ -120,26 +90,29 @@ export default function Search({ user }) {
setLoading(true)
hapticFeedback('light')
setResults([])
setCurrentPage(1)
setHasMore(true)
let allResults = []
// Загружаем первую страницу результатов
const firstPageResults = await loadMoreResults(searchQuery, 1)
if (firstPageResults.length > 0) {
allResults = [...allResults, ...firstPageResults]
// Если получили меньше 320, значит это последняя страница
if (firstPageResults.length < 320) {
setHasMore(false)
} else {
setHasMore(true)
setCurrentPage(1)
if (mode === 'furry') {
try {
const furryResults = await searchFurry(searchQuery, { limit: 320, page: 1 })
if (Array.isArray(furryResults)) {
allResults = [...allResults, ...furryResults]
}
} catch (error) {
console.error('Ошибка e621 поиска:', error)
}
}
if (mode === 'anime') {
try {
const animeResults = await searchAnime(searchQuery, { limit: 320, page: 1 })
if (Array.isArray(animeResults)) {
allResults = [...allResults, ...animeResults]
}
} catch (error) {
console.error('Ошибка Gelbooru поиска:', error)
}
} else {
setHasMore(false)
}
setResults(allResults)
@ -159,29 +132,6 @@ export default function Search({ user }) {
}
}
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore || !query.trim()) return
try {
setIsLoadingMore(true)
const nextPage = currentPage + 1
const newResults = await loadMoreResults(query, nextPage)
if (newResults.length > 0) {
setResults(prev => [...prev, ...newResults])
setCurrentPage(nextPage)
setHasMore(newResults.length >= 320 && nextPage < 10)
} else {
setHasMore(false)
}
} catch (error) {
console.error('Ошибка загрузки дополнительных результатов:', error)
setHasMore(false)
} finally {
setIsLoadingMore(false)
}
}
const handleTagClick = (tagName) => {
// Разбить текущий query по пробелам
const queryParts = query.trim().split(/\s+/)
@ -487,17 +437,6 @@ export default function Search({ user }) {
</div>
{/* Кнопка загрузки дополнительных результатов */}
{hasMore && !selectionMode && (
<div className="load-more-container">
<button
className="load-more-btn"
onClick={handleLoadMore}
disabled={isLoadingMore}
>
{isLoadingMore ? 'Загрузка...' : `Загрузить ещё (показано ${results.length})`}
</button>
</div>
)}
{/* Кнопка отправки выбранных */}
{selectionMode && selectedImages.length > 0 && (

View File

@ -57,22 +57,6 @@ export const verifySession = async (telegramId) => {
}
// Авторизация через Telegram OAuth (Login Widget)
export const authWithTelegramOAuth = async (userData) => {
// userData от Telegram Login Widget содержит: id, first_name, last_name, username, photo_url, auth_date, hash
const response = await api.post('/auth/oauth', {
user: {
id: userData.id,
first_name: userData.first_name,
last_name: userData.last_name,
username: userData.username,
photo_url: userData.photo_url
},
auth_date: userData.auth_date,
hash: userData.hash
})
return response.data.user
}
// Posts API
export const getPosts = async (params = {}) => {
const response = await api.get('/posts', { params })

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#000000" />
<title>Nakama Moderation</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -0,0 +1,25 @@
{
"name": "nakama-moderation",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.6.0",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"vite": "^5.0.0"
}
}

View File

@ -0,0 +1,660 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
verifyAuth,
fetchUsers,
banUser,
fetchPosts,
updatePost,
deletePost,
removePostImage,
banPostAuthor,
fetchReports,
updateReportStatus,
publishToChannel
} from './utils/api';
import { io } from 'socket.io-client';
import {
Loader2,
Users,
Image as ImageIcon,
ShieldCheck,
SendHorizontal,
MessageSquare,
RefreshCw,
Trash2,
Edit,
Ban
} from 'lucide-react';
const TABS = [
{ id: 'users', title: 'Пользователи', icon: Users },
{ id: 'posts', title: 'Посты', icon: ImageIcon },
{ id: 'reports', title: 'Репорты', icon: ShieldCheck },
{ id: 'chat', title: 'Чат', icon: MessageSquare },
{ id: 'publish', title: 'Публикация', icon: SendHorizontal }
];
const FILTERS = [
{ id: 'active', label: 'Активные < 7д' },
{ id: 'inactive', label: 'Неактивные' },
{ id: 'banned', label: 'Бан' }
];
const slotOptions = Array.from({ length: 10 }, (_, i) => i + 1);
const initialChatState = {
messages: [],
online: [],
connected: false
};
function formatDate(dateString) {
if (!dateString) return '—';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit'
});
}
function classNames(...args) {
return args.filter(Boolean).join(' ');
}
export default function App() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tab, setTab] = useState('users');
// Users
const [userFilter, setUserFilter] = useState('active');
const [usersData, setUsersData] = useState({ users: [], total: 0, totalPages: 1 });
const [usersLoading, setUsersLoading] = useState(false);
// Posts
const [postsData, setPostsData] = useState({ posts: [], totalPages: 1 });
const [postsLoading, setPostsLoading] = useState(false);
// Reports
const [reportsData, setReportsData] = useState({ reports: [], totalPages: 1 });
const [reportsLoading, setReportsLoading] = useState(false);
// Publish
const [publishState, setPublishState] = useState({
description: '',
tags: '',
slot: 1,
files: []
});
const [publishing, setPublishing] = useState(false);
// Chat
const [chatState, setChatState] = useState(initialChatState);
const [chatInput, setChatInput] = useState('');
const chatSocketRef = useRef(null);
const chatListRef = useRef(null);
const isTelegram = typeof window !== 'undefined' && window.Telegram?.WebApp;
useEffect(() => {
if (isTelegram) {
window.Telegram.WebApp.disableVerticalSwipes();
window.Telegram.WebApp.ready();
window.Telegram.WebApp.expand();
}
const init = async () => {
try {
const response = await verifyAuth();
setUser(response.data.user);
if (isTelegram) {
window.Telegram.WebApp.MainButton.setText('Закрыть');
window.Telegram.WebApp.MainButton.show();
window.Telegram.WebApp.MainButton.onClick(() => window.Telegram.WebApp.close());
}
setLoading(false);
} catch (err) {
console.error(err);
setError('Нет доступа. Убедитесь, что вы добавлены как администратор.');
setLoading(false);
}
};
init();
return () => {
if (isTelegram) {
window.Telegram.WebApp.MainButton.hide();
}
};
}, [isTelegram]);
useEffect(() => {
if (tab === 'users') {
loadUsers();
} else if (tab === 'posts') {
loadPosts();
} else if (tab === 'reports') {
loadReports();
} else if (tab === 'chat' && user) {
initChat();
}
return () => {
if (tab !== 'chat' && chatSocketRef.current) {
chatSocketRef.current.disconnect();
chatSocketRef.current = null;
setChatState(initialChatState);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab, user, userFilter]);
const loadUsers = async () => {
setUsersLoading(true);
try {
const data = await fetchUsers({ filter: userFilter });
setUsersData(data);
} catch (err) {
console.error(err);
} finally {
setUsersLoading(false);
}
};
const loadPosts = async () => {
setPostsLoading(true);
try {
const data = await fetchPosts();
setPostsData(data);
} catch (err) {
console.error(err);
} finally {
setPostsLoading(false);
}
};
const loadReports = async () => {
setReportsLoading(true);
try {
const data = await fetchReports();
setReportsData(data);
} catch (err) {
console.error(err);
} finally {
setReportsLoading(false);
}
};
const initChat = () => {
if (!user || chatSocketRef.current) return;
const socket = io('/mod-chat', {
transports: ['websocket', 'polling']
});
socket.on('connect', () => {
socket.emit('auth', {
username: user.username,
telegramId: user.telegramId
});
});
socket.on('ready', () => {
setChatState((prev) => ({ ...prev, connected: true }));
});
socket.on('unauthorized', () => {
setChatState((prev) => ({ ...prev, connected: false }));
socket.disconnect();
});
socket.on('message', (message) => {
setChatState((prev) => ({
...prev,
messages: [...prev.messages, message]
}));
if (chatListRef.current) {
chatListRef.current.scrollTo({
top: chatListRef.current.scrollHeight,
behavior: 'smooth'
});
}
});
socket.on('online', (online) => {
setChatState((prev) => ({ ...prev, online }));
});
socket.on('disconnect', () => {
setChatState((prev) => ({ ...prev, connected: false }));
});
chatSocketRef.current = socket;
};
const handleSendChat = () => {
if (!chatSocketRef.current || !chatState.connected) return;
const text = chatInput.trim();
if (!text) return;
chatSocketRef.current.emit('message', { text });
setChatInput('');
};
const handleBanUser = async (id, banned) => {
const days = banned ? parseInt(prompt('Введите срок бана в днях', '7'), 10) : 0;
await banUser(id, { banned, days });
loadUsers();
};
const handlePostEdit = async (post) => {
const newContent = prompt('Новый текст поста', post.content || '');
if (newContent === null) return;
await updatePost(post.id, { content: newContent });
loadPosts();
};
const handlePostDelete = async (postId) => {
if (!window.confirm('Удалить пост?')) return;
await deletePost(postId);
loadPosts();
};
const handleRemoveImage = async (postId, index) => {
await removePostImage(postId, index);
loadPosts();
};
const handleBanAuthor = async (postId) => {
const days = parseInt(prompt('Срок бана автора (в днях)', '7'), 10);
await banPostAuthor(postId, { days });
loadPosts();
loadUsers();
};
const handleReportStatus = async (reportId, status) => {
await updateReportStatus(reportId, { status });
loadReports();
};
const handlePublish = async () => {
if (!publishState.files.length) {
alert('Добавьте изображения');
return;
}
setPublishing(true);
try {
const formData = new FormData();
publishState.files.forEach((file) => formData.append('images', file));
formData.append('description', publishState.description);
formData.append('tags', JSON.stringify(
publishState.tags
.split(/[,\s]+/)
.map((tag) => tag.trim())
.filter(Boolean)
));
formData.append('slot', publishState.slot);
await publishToChannel(formData);
setPublishState({
description: '',
tags: '',
slot: 1,
files: []
});
alert('Опубликовано в канал @reichenbfurry');
} catch (err) {
console.error(err);
alert('Не удалось опубликовать пост');
} finally {
setPublishing(false);
}
};
const handleFileChange = (event) => {
const files = Array.from(event.target.files || []).slice(0, 10);
setPublishState((prev) => ({ ...prev, files }));
};
const renderUsers = () => (
<div className="card">
<div className="section-header">
<h2>Пользователи</h2>
<button className="icon-btn" onClick={loadUsers} disabled={usersLoading}>
<RefreshCw size={18} />
</button>
</div>
<div className="filter-chips">
{FILTERS.map((filter) => (
<button
key={filter.id}
className={classNames('chip', userFilter === filter.id && 'chip-active')}
onClick={() => setUserFilter(filter.id)}
>
{filter.label}
</button>
))}
</div>
{usersLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{usersData.users.map((u) => (
<div key={u.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">@{u.username}</div>
<div className="list-item-subtitle">
{u.firstName} {u.lastName || ''}
</div>
<div className="list-item-meta">
<span>Роль: {u.role}</span>
<span>Активность: {formatDate(u.lastActiveAt)}</span>
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
</div>
</div>
<div className="list-item-actions">
{u.banned ? (
<button className="btn" onClick={() => handleBanUser(u.id, false)}>
Разблокировать
</button>
) : (
<button className="btn danger" onClick={() => handleBanUser(u.id, true)}>
Забанить
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
const renderPosts = () => (
<div className="card">
<div className="section-header">
<h2>Посты</h2>
<button className="icon-btn" onClick={loadPosts} disabled={postsLoading}>
<RefreshCw size={18} />
</button>
</div>
{postsLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{postsData.posts.map((post) => (
<div key={post.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">
Автор: @{post.author?.username || 'Удалён'} {formatDate(post.createdAt)}
</div>
<div className="post-content-preview">{post.content || 'Без текста'}</div>
<div className="list-item-meta">
<span>Лайки: {post.likesCount}</span>
<span>Комментарии: {post.commentsCount}</span>
{post.isNSFW && <span className="badge badge-warning">NSFW</span>}
</div>
{post.images?.length ? (
<div className="image-grid">
{post.images.map((img, idx) => (
<div key={idx} className="image-thumb">
<img src={img} alt="" />
<button className="image-remove" onClick={() => handleRemoveImage(post.id, idx)}>
<Trash2 size={16} />
</button>
</div>
))}
</div>
) : null}
</div>
<div className="list-item-actions">
<button className="btn" onClick={() => handlePostEdit(post)}>
<Edit size={16} />
Редактировать
</button>
<button className="btn danger" onClick={() => handlePostDelete(post.id)}>
<Trash2 size={16} />
Удалить
</button>
<button className="btn warn" onClick={() => handleBanAuthor(post.id)}>
<Ban size={16} />
Бан автора
</button>
</div>
</div>
))}
</div>
)}
</div>
);
const renderReports = () => (
<div className="card">
<div className="section-header">
<h2>Репорты</h2>
<button className="icon-btn" onClick={loadReports} disabled={reportsLoading}>
<RefreshCw size={18} />
</button>
</div>
{reportsLoading ? (
<div className="loader">
<Loader2 className="spin" size={32} />
</div>
) : (
<div className="list">
{reportsData.reports.map((report) => (
<div key={report.id} className="list-item">
<div className="list-item-main">
<div className="list-item-title">
Репорт от @{report.reporter?.username || 'Unknown'} {formatDate(report.createdAt)}
</div>
<div className="list-item-subtitle">Статус: {report.status}</div>
<div className="report-content">
<p>{report.reason || 'Причина не указана'}</p>
{report.post && (
<div className="report-post">
<strong>Пост:</strong> {report.post.content || 'Без текста'}
</div>
)}
</div>
</div>
<div className="list-item-actions">
<button className="btn" onClick={() => handleReportStatus(report.id, 'resolved')}>
Решено
</button>
<button className="btn warn" onClick={() => handleReportStatus(report.id, 'dismissed')}>
Отклонить
</button>
</div>
</div>
))}
</div>
)}
</div>
);
const renderChat = () => (
<div className="card chat-card">
<div className="section-header">
<h2>Лайвчат админов</h2>
{chatState.connected ? (
<span className="badge badge-success">В сети</span>
) : (
<span className="badge badge-warning">Подключение...</span>
)}
</div>
<div className="chat-online">
Онлайн:{' '}
{chatState.online.length
? chatState.online.map((admin) => `@${admin.username}`).join(', ')
: '—'}
</div>
<div className="chat-list" ref={chatListRef}>
{chatState.messages.map((message) => (
<div
key={message.id}
className={classNames(
'chat-message',
message.username === user.username && 'chat-message-own'
)}
>
<div className="chat-message-author">@{message.username}</div>
<div className="chat-message-text">{message.text}</div>
<div className="chat-message-time">{formatDate(message.createdAt)}</div>
</div>
))}
</div>
<div className="chat-input">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="Сообщение для админов..."
/>
<button className="btn" onClick={handleSendChat} disabled={!chatState.connected}>
<SendHorizontal size={18} />
</button>
</div>
</div>
);
const renderPublish = () => (
<div className="card">
<div className="section-header">
<h2>Публикация в @reichenbfurry</h2>
</div>
<div className="publish-form">
<label>
Описание
<textarea
value={publishState.description}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, description: e.target.value }))
}
maxLength={1024}
placeholder="Текст поста"
/>
</label>
<label>
Теги (через пробел или запятую)
<input
type="text"
value={publishState.tags}
onChange={(e) => setPublishState((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="#furry #art"
/>
</label>
<label>
Номер администратора (#a1 - #a10)
<select
value={publishState.slot}
onChange={(e) =>
setPublishState((prev) => ({ ...prev, slot: parseInt(e.target.value, 10) }))
}
>
{slotOptions.map((option) => (
<option key={option} value={option}>
#{`a${option}`}
</option>
))}
</select>
</label>
<label>
Изображения (до 10)
<input type="file" accept="image/*" multiple onChange={handleFileChange} />
</label>
{publishState.files.length > 0 && (
<div className="file-list">
{publishState.files.map((file, index) => (
<div key={index} className="file-item">
{file.name} ({Math.round(file.size / 1024)} KB)
</div>
))}
</div>
)}
<button className="btn primary" disabled={publishing} onClick={handlePublish}>
{publishing ? <Loader2 className="spin" size={18} /> : <SendHorizontal size={18} />}
Опубликовать
</button>
</div>
</div>
);
const renderContent = () => {
switch (tab) {
case 'users':
return renderUsers();
case 'posts':
return renderPosts();
case 'reports':
return renderReports();
case 'chat':
return renderChat();
case 'publish':
return renderPublish();
default:
return null;
}
};
if (loading) {
return (
<div className="fullscreen-center">
<Loader2 className="spin" size={48} />
<p>Загрузка модераторского интерфейса...</p>
</div>
);
}
if (error) {
return (
<div className="fullscreen-center">
<ShieldCheck size={48} />
<p>{error}</p>
</div>
);
}
return (
<div className="app-container">
<header className="app-header">
<div>
<h1>Nakama Moderation</h1>
<span className="subtitle">@{user.username}</span>
</div>
<div className="header-actions">
<button className="btn" onClick={() => window.Telegram?.WebApp?.close()}>
Закрыть
</button>
</div>
</header>
<nav className="tabbar">
{TABS.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
className={classNames('tab-btn', tab === item.id && 'tab-btn-active')}
onClick={() => setTab(item.id)}
>
<Icon size={18} />
<span>{item.title}</span>
</button>
);
})}
</nav>
<main className="content">{renderContent()}</main>
</div>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,434 @@
.app-container {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #111;
color: #f5f5f7;
min-height: 100vh;
padding: 16px;
box-sizing: border-box;
}
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.app-header h1 {
margin: 0;
font-size: 22px;
font-weight: 700;
}
.subtitle {
display: block;
margin-top: 4px;
color: #9da3af;
font-size: 14px;
}
.header-actions .btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 8px 16px;
color: inherit;
cursor: pointer;
}
.tabbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
}
.tab-btn {
flex: 1;
min-width: 120px;
display: flex;
gap: 6px;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 10px;
font-size: 14px;
color: inherit;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-btn-active {
background: #f5f5f7;
color: #111;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 24px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 20px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.25);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-header h2 {
margin: 0;
font-size: 18px;
}
.icon-btn {
background: transparent;
border: none;
color: inherit;
cursor: pointer;
opacity: 0.85;
}
.filter-chips {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.chip {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: transparent;
color: inherit;
font-size: 13px;
cursor: pointer;
}
.chip-active {
background: #f5f5f7;
color: #111;
}
.list {
display: flex;
flex-direction: column;
gap: 12px;
}
.list-item {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.list-item-main {
display: flex;
flex-direction: column;
gap: 6px;
}
.list-item-title {
font-size: 15px;
font-weight: 600;
}
.list-item-subtitle {
color: #9da3af;
font-size: 13px;
}
.list-item-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: #9da3af;
}
.list-item-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
border-radius: 12px;
border: none;
background: rgba(255, 255, 255, 0.1);
color: inherit;
cursor: pointer;
font-size: 13px;
transition: opacity 0.2s ease;
}
.btn:hover {
opacity: 0.85;
}
.btn.danger {
background: rgba(255, 59, 48, 0.25);
color: #ff453a;
}
.btn.warn {
background: rgba(255, 159, 10, 0.2);
color: #ff9f0a;
}
.btn.primary {
background: #f5f5f7;
color: #111;
}
.badge {
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.badge-danger {
background: rgba(255, 59, 48, 0.2);
color: #ff453a;
}
.badge-warning {
background: rgba(255, 159, 10, 0.2);
color: #ff9f0a;
}
.badge-success {
background: rgba(48, 209, 88, 0.2);
color: #30d158;
}
.loader {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
gap: 12px;
color: #9da3af;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 8px;
}
.image-thumb {
position: relative;
border-radius: 12px;
overflow: hidden;
}
.image-thumb img {
width: 100%;
height: 80px;
object-fit: cover;
display: block;
}
.image-remove {
position: absolute;
top: 4px;
right: 4px;
background: rgba(17, 17, 17, 0.65);
border: none;
border-radius: 999px;
color: #f5f5f7;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.chat-card {
display: flex;
flex-direction: column;
gap: 12px;
}
.chat-online {
font-size: 13px;
color: #9da3af;
}
.chat-list {
flex: 1;
min-height: 200px;
max-height: 360px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 4px;
}
.chat-message {
background: rgba(255, 255, 255, 0.04);
padding: 10px 12px;
border-radius: 12px;
}
.chat-message-own {
background: rgba(245, 245, 247, 0.9);
color: #111;
align-self: flex-end;
}
.chat-message-author {
font-weight: 600;
font-size: 13px;
}
.chat-message-text {
font-size: 14px;
margin-top: 2px;
}
.chat-message-time {
font-size: 11px;
margin-top: 6px;
color: #9da3af;
}
.chat-input {
display: flex;
gap: 8px;
align-items: center;
}
.chat-input input {
flex: 1;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
}
.publish-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.publish-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 14px;
}
.publish-form textarea,
.publish-form input,
.publish-form select {
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: inherit;
padding: 10px 14px;
font-size: 14px;
}
.publish-form textarea {
min-height: 120px;
resize: vertical;
}
.file-list {
background: rgba(255, 255, 255, 0.03);
border-radius: 12px;
padding: 8px 12px;
font-size: 13px;
}
.file-item + .file-item {
margin-top: 4px;
}
.fullscreen-center {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: #111;
color: #f5f5f7;
text-align: center;
}
.post-content-preview {
font-size: 14px;
color: #d1d5db;
white-space: pre-wrap;
}
.report-content {
font-size: 13px;
color: #d1d5db;
display: flex;
flex-direction: column;
gap: 6px;
}
.report-post {
background: rgba(255, 255, 255, 0.03);
padding: 8px;
border-radius: 10px;
}
@media (max-width: 640px) {
.app-container {
padding: 12px;
}
.list-item-actions {
flex-direction: column;
align-items: stretch;
}
.btn {
justify-content: center;
}
}

View File

@ -0,0 +1,64 @@
import axios from 'axios';
const API_URL =
import.meta.env.VITE_API_URL ||
(import.meta.env.PROD ? '/api' : 'http://localhost:3000/api');
const api = axios.create({
baseURL: API_URL,
withCredentials: false
});
const getInitData = () => {
if (typeof window === 'undefined') {
return null;
}
return window.Telegram?.WebApp?.initData || null;
};
api.interceptors.request.use((config) => {
const initData = getInitData();
if (initData) {
config.headers['x-telegram-init-data'] = initData;
}
return config;
});
export const verifyAuth = () => api.post('/mod-app/auth/verify');
export const fetchUsers = (params = {}) =>
api.get('/mod-app/users', { params }).then((res) => res.data);
export const banUser = (userId, data) =>
api.put(`/mod-app/users/${userId}/ban`, data).then((res) => res.data);
export const fetchPosts = (params = {}) =>
api.get('/mod-app/posts', { params }).then((res) => res.data);
export const updatePost = (postId, data) =>
api.put(`/mod-app/posts/${postId}`, data).then((res) => res.data);
export const deletePost = (postId) =>
api.delete(`/mod-app/posts/${postId}`).then((res) => res.data);
export const removePostImage = (postId, index) =>
api.delete(`/mod-app/posts/${postId}/images/${index}`).then((res) => res.data);
export const banPostAuthor = (postId, data) =>
api.post(`/mod-app/posts/${postId}/ban`, data).then((res) => res.data);
export const fetchReports = (params = {}) =>
api.get('/mod-app/reports', { params }).then((res) => res.data);
export const updateReportStatus = (reportId, data) =>
api.put(`/mod-app/reports/${reportId}`, data).then((res) => res.data);
export const publishToChannel = (formData) =>
api.post('/mod-app/channel/publish', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
export default api;

View File

@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5174,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true
},
'/mod-chat': {
target: process.env.VITE_API_URL || 'http://localhost:3000',
changeOrigin: true,
ws: true
}
}
},
build: {
outDir: 'dist',
sourcemap: false
}
});

View File

@ -8,7 +8,9 @@
"server": "nodemon backend/server.js",
"client": "cd frontend && npm run dev",
"build": "cd frontend && npm run build",
"start": "node backend/server.js"
"start": "node backend/server.js",
"mod-client": "cd moderation/frontend && npm run dev",
"mod-build": "cd moderation/frontend && npm run build"
},
"keywords": ["telegram", "mini-app", "social-network"],
"author": "",