<?php
declare(strict_types=1);
namespace App\Controller;
use App\Controller\BaseController;
use Doctrine\DBAL\Connection;
use Predis\Client as RedisClient;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ChatController extends BaseController
{
/* настройки */
private const DEPOSIT_THRESHOLD = 0; // ₽
private const CHAT_HISTORY_LIMIT = 20; // сообщений, хранимых в Redis
private const MIN_MESSAGE_INTERVAL = 0.7; // cек между сообщениями одного пользователя
private const DUPLICATE_WINDOW_SECONDS = 5; // таймаут, в течение которого одно и то же сообщение считается дубликатом
private const ROLE_MAP = [
0 => 'user',
1 => 'admin',
2 => 'moderator',
3 => 'yt',
4 => 'system',
];
/**
* @Route("/api/chat/message", name="chat_message", methods={"POST"})
*/
public function send(
Request $request,
SessionInterface $session,
Connection $db,
RedisClient $redis,
ValidatorInterface $validator,
LoggerInterface $logger,
CsrfTokenManagerInterface $csrf
): JsonResponse {
$rawToken = $request->headers->get('X-CSRF-TOKEN')
?: $request->request->get('_csrf_token');
if (!$rawToken || !$csrf->isTokenValid(new CsrfToken('general', $rawToken))) {
return $this->json(['success' => false, 'mess' => 'CSRF‑токен недействителен'], 419);
}
$now = microtime(true);
$lastMsg = (float) $session->get('last_chat_time', 0.0);
if ($now - $lastMsg < self::MIN_MESSAGE_INTERVAL) {
return $this->json(['success' => false, 'mess' => 'Сообщения слишком частые. Подождите чуть‑чуть.']);
}
$session->set('last_chat_time', $now);
$sid = $session->get('hash');
if (!$sid) {
return $this->json(['success' => false, 'mess' => 'Авторизуйтесь!'], 401);
}
$textRaw = $request->request->get('text');
if ($textRaw === null) {
$payload = json_decode($request->getContent(), true);
if (is_array($payload)) {
$textRaw = $payload['text'] ?? null;
}
}
$errors = $validator->validate(
['text' => $textRaw],
new Assert\Collection([
'text' => [
new Assert\NotBlank(['message' => 'Сообщение не может быть пустым']),
new Assert\Length(['max' => 60, 'maxMessage' => 'Слишком длинное сообщение']),
],
])
);
if (count($errors) > 0) {
return $this->json(['success' => false, 'mess' => $errors[0]->getMessage()]);
}
try {
$db->beginTransaction();
$user = $db->fetchAssociative(
'SELECT id, login, img, admin, chatban FROM users WHERE hash = :hash FOR UPDATE',
['hash' => $sid]
);
if (!$user) {
$db->rollBack();
return $this->json(['success' => false, 'mess' => 'Пользователь не найден']);
}
if ((int) $user['chatban'] === 1) {
$db->rollBack();
return $this->json(['success' => false, 'mess' => 'Вы заблокированы в чате.']);
}
$sumDeposits = (float) $db->fetchOne(
'SELECT COALESCE(SUM(suma),0) FROM deposits WHERE user_id = :uid AND status = 1',
['uid' => $user['id']]
);
if ($sumDeposits < self::DEPOSIT_THRESHOLD) {
$db->rollBack();
return $this->json([
'success' => false,
'mess' => sprintf('Для чата пополните баланс минимум на %.0f ₽', self::DEPOSIT_THRESHOLD),
]);
}
$db->commit();
} catch (\Throwable $e) {
if ($db->isTransactionActive()) {
$db->rollBack();
}
$logger->error('Chat auth/deposit check failed', ['e' => $e]);
return $this->json(['success' => false, 'mess' => 'Ошибка сервера'], 500);
}
$dupKey = "chat:last_message:" . $user['id'];
$lastText = $redis->get($dupKey);
if ($lastText !== null && $lastText === $textRaw) {
return $this->json(['success' => false, 'mess' => 'Повторять одно и то же не нужно']);
}
$redis->setex($dupKey, self::DUPLICATE_WINDOW_SECONDS, $textRaw);
$cleanText = $this->sanitizeMessage((string) $textRaw);
if ($cleanText === '') {
return $this->json(['success' => false, 'mess' => 'Сообщение не может быть пустым!']);
}
$role = self::ROLE_MAP[$user['admin']] ?? 'user';
$timeStr = (new \DateTimeImmutable())->format('H:i');
$payload = [
'user_id' => (int) $user['id'],
'avatar' => (string) ($user['img'] ?? ''),
'username' => $this->truncateUsername((string) ($user['login'] ?? '')),
'role' => $role,
'text' => $cleanText,
'time' => $timeStr,
'message_id' => bin2hex(random_bytes(8))
];
try {
$redis->publish('chat_message', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$redis->lpush('chat_messages', json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$redis->ltrim('chat_messages', 0, self::CHAT_HISTORY_LIMIT - 1);
} catch (\Throwable $e) {
$logger->error('Failed to write to Redis', ['e' => $e]);
return $this->json(['success' => false, 'mess' => 'Ошибка сервера'], 500);
}
return $this->json([
'success' => true,
'mess' => null,
'data' => $payload,
]);
}
/**
* @Route("/api/chat/delete", name="chat_delete", methods={"POST"})
*/
public function deleteMessage(
Request $request,
SessionInterface $session,
Connection $db,
RedisClient $redis,
LoggerInterface $logger,
CsrfTokenManagerInterface $csrf
): JsonResponse {
$rawToken = $request->request->get('_csrf_token');
if (!$rawToken || !$csrf->isTokenValid(new CsrfToken('general', $rawToken))) {
return $this->json(['success' => false, 'mess' => 'CSRF‑токен недействителен'], 419);
}
$sid = $session->get('hash');
if (!$sid) {
return $this->json(['success' => false, 'mess' => 'Авторизуйтесь!'], 401);
}
$user = $db->fetchAssociative('SELECT id, login, admin FROM users WHERE hash = :hash', ['hash' => $sid]);
if (!$user || !in_array($user['admin'], [1, 2])) {
return $this->json(['success' => false, 'mess' => 'Недостаточно прав'], 403);
}
$messageId = $request->request->get('id');
if (!$messageId) {
return $this->json(['success' => false, 'mess' => 'ID не указан']);
}
try {
$messages = $redis->lrange('chat_messages', 0, -1);
foreach ($messages as $msg) {
$data = json_decode($msg, true);
if ($data['message_id'] == $messageId) {
$redis->lrem('chat_messages', 1, $msg);
break;
}
}
$redis->publish('chat_delete', json_encode(['id' => $messageId], JSON_UNESCAPED_UNICODE));
return $this->json(['success' => true]);
} catch (\Throwable $e) {
$logger->error('Ошибка удаления сообщения', ['e' => $e]);
return $this->json(['success' => false, 'mess' => 'Ошибка сервера'], 500);
}
}
/**
* @Route("/api/chat/ban", name="chat_ban", methods={"POST"})
*/
public function banUser(
Request $request,
SessionInterface $session,
Connection $db,
CsrfTokenManagerInterface $csrf
): JsonResponse {
$rawToken = $request->request->get('_csrf_token');
if (!$rawToken || !$csrf->isTokenValid(new CsrfToken('general', $rawToken))) {
return $this->json(['success' => false, 'mess' => 'CSRF‑токен недействителен'], 419);
}
$sid = $session->get('hash');
if (!$sid) {
return $this->json(['success' => false, 'mess' => 'Авторизуйтесь!'], 401);
}
$current = $db->fetchAssociative('SELECT id, admin FROM users WHERE hash = :hash', ['hash' => $sid]);
if (!$current || !in_array($current['admin'], [1, 2])) {
return $this->json(['success' => false, 'mess' => 'Недостаточно прав'], 403);
}
$userId = (int) $request->request->get('id');
if ($userId <= 0) {
return $this->json(['success' => false, 'mess' => 'Некорректный ID']);
}
$db->update('users', ['chatban' => 1], ['id' => $userId]);
return $this->json(['success' => true, 'mess' => 'Пользователь заблокирован']);
}
/**
* @Route("/api/chat/clear", name="chat_clear", methods={"POST"})
*/
public function clearChat(
Request $request,
SessionInterface $session,
Connection $db,
RedisClient $redis,
CsrfTokenManagerInterface $csrf
): JsonResponse {
$rawToken = $request->request->get('_csrf_token');
if (!$rawToken || !$csrf->isTokenValid(new CsrfToken('general', $rawToken))) {
return $this->json(['success' => false, 'mess' => 'CSRF‑токен недействителен'], 419);
}
$sid = $session->get('hash');
if (!$sid) {
return $this->json(['success' => false, 'mess' => 'Авторизуйтесь!'], 401);
}
$current = $db->fetchAssociative('SELECT id, admin FROM users WHERE hash = :hash', ['hash' => $sid]);
if (!$current || !in_array($current['admin'], [1, 2])) {
return $this->json(['success' => false, 'mess' => 'Недостаточно прав'], 403);
}
$redis->del('chat_messages');
$redis->publish('chat_clear', json_encode(['clear' => true]));
$payload = [
'user_id' => 0,
'avatar' => '/assets/icons/little-logo.svg',
'username' => 'Система',
'role' => 'system',
'text' => 'Чат был очищен модератором.',
'time' => (new \DateTimeImmutable())->format('H:i'),
];
$redis->publish('chat_message', json_encode($payload, JSON_UNESCAPED_UNICODE));
$redis->lpush('chat_messages', json_encode($payload, JSON_UNESCAPED_UNICODE));
return $this->json(['success' => true, 'mess' => 'Чат очищен']);
}
/**
* @Route("/api/chat/send-promo-auto", name="chat_send_promo_auto", methods={"POST"})
*/
public function sendPromoCodeAuto(Request $request, Connection $db, RedisClient $redis): JsonResponse {
$secret = $request->get('secret') ?? json_decode($request->getContent(), true)['secret'] ?? null;
if ($secret !== 'fasdfniODSX1b1xz') {
return $this->json(['success' => false, 'mess' => 'Доступ запрещён'], 403);
}
$code = strtoupper(bin2hex(random_bytes(3)));
$db->insert('promo', [
'name' => $code,
'price' => 50,
'count' => 25,
'auto' => 1,
'daydep' => 0,
'deposit' => 0,
'created_by' => "system"
]);
$message = "🎁 Новый промокод: <b>$code</b><br>💸 50₽ на баланс<br>🔁 Доступно 25 активаций!";
$payload = [
'user_id' => 0,
'avatar' => '/assets/icons/little-logo.svg',
'username' => 'Система',
'role' => 'system',
'text' => $message,
'time' => (new \DateTimeImmutable())->format('H:i'),
'message_id' => bin2hex(random_bytes(8))
];
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$redis->publish('chat_message', $json);
$redis->lpush('chat_messages', $json);
$redis->ltrim('chat_messages', 0, self::CHAT_HISTORY_LIMIT - 1);
return $this->json(['success' => true, 'code' => $code]);
}
private function sanitizeMessage(string $raw): string
{
$msg = trim($raw);
$msg = preg_replace('/[\p{C}]/u', '', $msg);
$msg = mb_substr($msg, 0, 500, 'UTF-8');
$msg = htmlspecialchars($msg, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
$msg = preg_replace('~https?://[^\s<]+~iu', '[ссылка]', $msg);
$msg = str_ireplace(['х.у.й.н.я', 'Скам', 'СКАМ', 'СкАм','СКам', 'сKам', 'vk.com', 't.me', '@', 'тгк', 'канал', 'подписывайтесь', 'подписывайся', 'скам','6ля', '6лядь', '6лять', 'b3ъeб', 'cock', 'cunt', 'e6aль', 'ebal', 'eblan', 'eбaл', 'eбaть', 'eбyч', 'eбать', 'eбёт', 'eблантий', 'fuck', 'fucker', 'fucking', 'xyёв', 'xyй', 'xyя', 'xуе','xуй', 'xую', 'zaeb', 'zaebal', 'zaebali', 'zaebat', 'архипиздрит', 'ахуел', 'ахуеть', 'бздение', 'бздеть', 'бздех', 'бздецы', 'бздит', 'бздицы', 'бздло', 'бзднуть', 'бздун', 'бздунья', 'бздюха', 'бздюшка', 'бздюшко', 'бля', 'блябу', 'блябуду', 'бляд', 'бляди', 'блядина', 'блядище', 'блядки', 'блядовать', 'блядство', 'блядун', 'блядуны', 'блядунья', 'блядь', 'блядюга', 'блять', 'вафел', 'вафлёр', 'взъебка', 'взьебка', 'взьебывать', 'въеб', 'въебался', 'въебенн', 'въебусь', 'въебывать', 'выблядок', 'выблядыш', 'выеб', 'выебать', 'выебен', 'выебнулся', 'выебон', 'выебываться', 'выпердеть', 'высраться', 'выссаться', 'вьебен', 'гавно', 'гавнюк', 'гавнючка', 'гамно', 'гандон', 'гнид', 'гнида', 'гниды', 'говенка', 'говенный', 'говешка', 'говназия', 'говнецо', 'говнище', 'говно', 'говноед', 'говнолинк', 'говночист', 'говнюк', 'говнюха', 'говнядина', 'говняк', 'говняный', 'говнять', 'гондон', 'доебываться', 'долбоеб', 'долбоёб', 'долбоящер', 'дрисня', 'дрист', 'дристануть', 'дристать', 'дристун', 'дристуха', 'дрочелло', 'дрочена', 'дрочила', 'дрочилка', 'дрочистый', 'дрочить', 'дрочка', 'дрочун', 'е6ал', 'е6ут', 'еб твою мать', 'ёб твою мать', 'ёбaн', 'ебaть', 'ебyч', 'ебал', 'ебало', 'ебальник', 'ебан', 'ебанамать', 'ебанат', 'ебаная', 'ёбаная', 'ебанический', 'ебанный', 'ебанныйврот', 'ебаное', 'ебануть', 'ебануться', 'ёбаную', 'ебаный', 'ебанько', 'ебарь', 'ебат', 'ёбат', 'ебатория', 'ебать', 'ебать-копать', 'ебаться', 'ебашить', 'ебёна', 'ебет', 'ебёт', 'ебец', 'ебик', 'ебин', 'ебись', 'ебическая', 'ебки', 'ебла', 'еблан', 'ебливый', 'еблище', 'ебло', 'еблыст', 'ебля', 'ёбн', 'ебнуть', 'ебнуться', 'ебня', 'ебошить', 'ебская', 'ебский', 'ебтвоюмать', 'ебун', 'ебут', 'ебуч', 'ебуче', 'ебучее', 'ебучий', 'ебучим', 'ебущ', 'ебырь', 'елда', 'елдак', 'елдачить', 'жопа', 'жопу', 'заговнять', 'задрачивать', 'задристать', 'задрота', 'зае6', 'заё6', 'заеб', 'заёб', 'заеба', 'заебал', 'заебанец', 'заебастая', 'заебастый', 'заебать', 'заебаться', 'заебашить', 'заебистое', 'заёбистое', 'заебистые', 'заёбистые', 'заебистый', 'заёбистый', 'заебись', 'заебошить', 'заебываться', 'залуп', 'залупа', 'залупаться', 'залупить', 'залупиться', 'замудохаться', 'запиздячить', 'засерать', 'засерун', 'засеря', 'засирать', 'засрун', 'захуячить', 'заябестая', 'злоеб', 'злоебучая', 'злоебучее', 'злоебучий', 'ибанамат', 'ибонех', 'изговнять', 'изговняться', 'изъебнуться', 'ипать', 'ипаться', 'ипаццо', 'Какдвапальцаобоссать', 'конча', 'курва', 'курвятник', 'лох', 'лошарa', 'лошара', 'лошары', 'лошок', 'лярва', 'малафья', 'манда', 'мандавошек', 'мандавошка', 'мандавошки', 'мандей', 'мандень', 'мандеть', 'мандища', 'мандой', 'манду', 'мандюк', 'минет', 'минетчик', 'минетчица', 'млять', 'мокрощелка', 'мокрощёлка', 'мразь', 'мудak', 'мудaк', 'мудаг', 'мудак', 'муде', 'мудель', 'мудеть', 'муди', 'мудил', 'мудила', 'мудистый', 'мудня', 'мудоеб', 'мудозвон', 'мудоклюй', 'на хер', 'на хуй', 'набздел', 'набздеть', 'наговнять', 'надристать', 'надрочить', 'наебать', 'наебет', 'наебнуть', 'наебнуться', 'наебывать', 'напиздел', 'напиздели', 'напиздело', 'напиздили', 'насрать', 'настопиздить', 'нахер', 'нахрен', 'нахуй', 'нахуйник', 'не ебет', 'не ебёт', 'невротебучий', 'невъебенно', 'нехира', 'нехрен', 'Нехуй', 'нехуйственно', 'ниибацо', 'ниипацца', 'ниипаццо', 'ниипет', 'никуя', 'нихера', 'нихуя', 'обдристаться', 'обосранец', 'обосрать', 'обосцать', 'обосцаться', 'обсирать', 'объебос', 'обьебать обьебос', 'однохуйственно', 'опездал', 'опизде', 'опизденивающе', 'остоебенить', 'остопиздеть', 'отмудохать', 'отпиздить', 'отпиздячить', 'отпороть', 'отъебись', 'охуевательский', 'охуевать', 'охуевающий', 'охуел', 'охуенно', 'охуеньчик', 'охуеть', 'охуительно', 'охуительный', 'охуяньчик', 'охуячивать', 'охуячить', 'очкун', 'падла', 'падонки', 'падонок', 'паскуда', 'педерас', 'педик', 'педрик', 'педрила', 'педрилло', 'педрило', 'педрилы', 'пездень', 'пездит', 'пездишь', 'пездо', 'пездят', 'пердануть', 'пердеж', 'пердение', 'пердеть', 'пердильник', 'перднуть', 'пёрднуть', 'пердун', 'пердунец', 'пердунина', 'пердунья', 'пердуха', 'пердь', 'переёбок', 'пернуть', 'пёрнуть', 'пи3д', 'пи3де', 'пи3ду', 'пиzдец', 'пидар', 'пидарaс', 'пидарас', 'пидарасы', 'пидары', 'пидор', 'пидорасы', 'пидорка', 'пидорок', 'пидоры', 'пидрас', 'пизда', 'пиздануть', 'пиздануться', 'пиздарваньчик', 'пиздато', 'пиздатое', 'пиздатый', 'пизденка', 'пизденыш', 'пиздёныш', 'пиздеть', 'пиздец', 'пиздит', 'пиздить', 'пиздиться', 'пиздишь', 'пиздища', 'пиздище', 'пиздобол', 'пиздоболы', 'пиздобратия', 'пиздоватая', 'пиздоватый', 'пиздолиз', 'пиздонутые', 'пиздорванец', 'пиздорванка', 'пиздострадатель', 'пизду', 'пиздуй', 'пиздун', 'пиздунья', 'пизды', 'пиздюга', 'пиздюк', 'пиздюлина', 'пиздюля', 'пиздят', 'пиздячить', 'писбшки', 'писька', 'писькострадатель', 'писюн', 'писюшка', 'по хуй', 'по хую', 'подговнять', 'подонки', 'подонок', 'подъебнуть', 'подъебнуться', 'поебать', 'поебень', 'поёбываает', 'поскуда', 'посрать', 'потаскуха', 'потаскушка', 'похер', 'похерил', 'похерила', 'похерили', 'похеру', 'похрен', 'похрену', 'похуй', 'похуист', 'похуистка', 'похую', 'придурок', 'приебаться', 'припиздень', 'припизднутый', 'припиздюлина', 'пробзделся', 'проблядь', 'проеб', 'проебанка', 'проебать', 'промандеть', 'промудеть', 'пропизделся', 'пропиздеть', 'пропиздячить', 'раздолбай', 'разхуячить', 'разъеб', 'разъеба', 'разъебай', 'разъебать', 'распиздай', 'распиздеться', 'распиздяй', 'распиздяйство', 'распроеть', 'сволота', 'сволочь', 'сговнять', 'секель', 'серун', 'серька', 'сестроеб', 'сикель', 'сила', 'сирать', 'сирывать', 'соси', 'спиздел', 'спиздеть', 'спиздил', 'спиздила', 'спиздили', 'спиздит', 'спиздить', 'срака', 'сраку', 'сраный', 'сранье', 'срать', 'срун', 'ссака', 'ссышь', 'стерва', 'страхопиздище', 'сука', 'суки', 'суходрочка', 'сучара', 'сучий', 'сучка', 'сучко', 'сучонок', 'сучье', 'сцание', 'сцать', 'сцука', 'сцуки', 'сцуконах', 'сцуль', 'сцыха', 'сцышь', 'съебаться', 'сыкун', 'трахае6', 'трахаеб', 'трахаёб', 'трахатель', 'ублюдок', 'уебать', 'уёбища', 'уебище', 'уёбище', 'уебищное', 'уёбищное', 'уебк', 'уебки', 'уёбки', 'уебок', 'уёбок', 'урюк', 'усраться', 'ушлепок', 'х_у_я_р_а', 'хyё', 'хyй', 'хyйня', 'хамло', 'хер', 'херня', 'херовато', 'херовина', 'херовый', 'хитровыебанный', 'хитрожопый', 'хуeм', 'хуе', 'хуё', 'хуевато', 'хуёвенький', 'хуевина', 'хуево', 'хуевый', 'хуёвый', 'хуек', 'хуёк', 'хуел', 'хуем', 'хуенч', 'хуеныш', 'хуенький', 'хуеплет', 'хуеплёт', 'хуепромышленник', 'хуерик', 'хуерыло', 'хуесос', 'хуесоска', 'хуета', 'хуетень', 'хуею', 'хуи', 'хуй', 'хуйком', 'хуйло', 'хуйня', 'хуйрик', 'хуище', 'хуля', 'хую', 'хуюл', 'хуя', 'хуяк', 'хуякать', 'хуякнуть', 'хуяра', 'хуясе', 'хуячить', 'целка', 'чмо', 'чмошник', 'чмырь', 'шалава', 'шалавой', 'шараёбиться', 'шлюха', 'шлюхой', 'шлюшка', 'ябывает'], '****', $msg);
return trim($msg);
}
private function truncateUsername(string $name): string
{
$maxLength = 6;
$safe = htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8');
if (mb_strlen($safe, 'UTF-8') <= $maxLength) {
return $safe;
}
return mb_substr($safe, 0, $maxLength, 'UTF-8') . '****';
}
}