Update files
This commit is contained in:
parent
138eba28e8
commit
ce159a3fcd
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
Если вы уже используете официальный клиент и видите это сообщение,
|
||||
пожалуйста сообщите об ошибке в
|
||||
<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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -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": "",
|
||||
|
|
|
|||
Loading…
Reference in New Issue