diff --git a/backend/routes/modApp.js b/backend/routes/modApp.js index b702928..ec452ad 100644 --- a/backend/routes/modApp.js +++ b/backend/routes/modApp.js @@ -279,29 +279,21 @@ router.put('/posts/:id', authenticateModerationFlexible, requireModerationAccess return res.status(404).json({ error: 'Пост не найден' }); } - // Проверить, может ли админ редактировать этот пост - // Админ может редактировать: - // 1. Любой пост, если он владелец (req.isOwner) - // 2. Только свои посты из канала (где adminNumber совпадает) - if (!req.isOwner) { - // Получить админа текущего пользователя - const admin = await ModerationAdmin.findOne({ telegramId: req.user.telegramId }); - - // Если это пост из канала, проверить, что админ - автор - if (post.publishedToChannel && post.adminNumber) { - if (!admin || admin.adminNumber !== post.adminNumber) { - return res.status(403).json({ error: 'Вы можете редактировать только свои посты из канала' }); - } - } - // Если это обычный пост, владелец может редактировать любой, остальные админы - нет - // (это поведение можно изменить по необходимости) - } + // Модераторы и админы могут редактировать любой пост через панель модерации + // Доступ к панели модерации уже проверен через requireModerationAccess if (content !== undefined) { post.content = content; - post.hashtags = Array.isArray(hashtags) - ? hashtags.map((tag) => tag.toLowerCase()) - : post.hashtags; + // Обновить хэштеги из контента, если hashtags не переданы явно + if (hashtags !== undefined) { + post.hashtags = Array.isArray(hashtags) + ? hashtags.map((tag) => tag.toLowerCase()) + : post.hashtags; + } else { + // Извлечь хэштеги из контента + const { extractHashtags } = require('../utils/hashtags'); + post.hashtags = extractHashtags(content); + } } if (tags !== undefined) { diff --git a/backend/routes/moderationAuth.js b/backend/routes/moderationAuth.js index 59cc4cc..6bd837a 100644 --- a/backend/routes/moderationAuth.js +++ b/backend/routes/moderationAuth.js @@ -61,9 +61,9 @@ router.post('/send-code', codeLimiter, async (req, res) => { const userByEmail = await User.findOne({ email: emailLower }); if (userByEmail) { console.log(`[ModerationAuth] Пользователь найден, но роль не moderator/admin:`, userByEmail.role); - return res.status(403).json({ - error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.' - }); + return res.status(403).json({ + error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.' + }); } // Если пользователя нет вообще - разрешить отправку кода diff --git a/backend/routes/posts.js b/backend/routes/posts.js index ad08605..1b7b2fc 100644 --- a/backend/routes/posts.js +++ b/backend/routes/posts.js @@ -366,35 +366,46 @@ router.put('/:id', authenticate, async (req, res) => { return res.status(403).json({ error: 'Нет прав на редактирование' }); } - // Валидация контента - if (content !== undefined && !validatePostContent(content)) { - logSecurityEvent('INVALID_POST_CONTENT', req); - return res.status(400).json({ error: 'Недопустимый контент поста' }); + // Валидация контента (разрешаем пустой контент, если есть изображения) + if (content !== undefined) { + // Если контент не пустой, валидируем его + if (content && content.trim().length > 0) { + if (!validatePostContent(content)) { + logSecurityEvent('INVALID_POST_CONTENT', req); + return res.status(400).json({ error: 'Недопустимый контент поста' }); + } + post.content = content.trim(); + // Обновить хэштеги + post.hashtags = extractHashtags(content); + } else { + // Пустой контент разрешен, если есть изображения + if (!post.images || post.images.length === 0) { + return res.status(400).json({ error: 'Пост должен содержать текст или изображение' }); + } + post.content = ''; + post.hashtags = []; + } } // Валидация тегов - if (tags) { + if (tags !== undefined) { let parsedTags = []; - try { - parsedTags = JSON.parse(tags); - } catch (e) { - return res.status(400).json({ error: 'Неверный формат тегов' }); - } - - if (!validateTags(parsedTags)) { - logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags }); - return res.status(400).json({ error: 'Недопустимые теги' }); + if (tags) { + try { + parsedTags = typeof tags === 'string' ? JSON.parse(tags) : tags; + } catch (e) { + return res.status(400).json({ error: 'Неверный формат тегов' }); + } + + if (!validateTags(parsedTags)) { + logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags }); + return res.status(400).json({ error: 'Недопустимые теги' }); + } } post.tags = parsedTags; } - if (content !== undefined) { - post.content = content; - // Обновить хэштеги - post.hashtags = extractHashtags(content); - } - if (isNSFW !== undefined) { post.isNSFW = isNSFW === 'true' || isNSFW === true; } @@ -406,6 +417,10 @@ router.put('/:id', authenticate, async (req, res) => { res.json({ post }); } catch (error) { console.error('Ошибка редактирования поста:', error); + // Более детальная обработка ошибок + if (error.name === 'CastError') { + return res.status(400).json({ error: 'Неверный ID поста' }); + } res.status(500).json({ error: 'Ошибка сервера' }); } }); diff --git a/backend/utils/email.js b/backend/utils/email.js index f819c10..601a2e1 100644 --- a/backend/utils/email.js +++ b/backend/utils/email.js @@ -202,31 +202,31 @@ const sendEmail = async (to, subject, html, text) => { } } else { // Обычный AWS SES - const params = { - Source: fromEmail, - Destination: { - ToAddresses: [to] + const params = { + Source: fromEmail, + Destination: { + ToAddresses: [to] + }, + Message: { + Subject: { + Data: subject, + Charset: 'UTF-8' }, - Message: { - Subject: { - Data: subject, + Body: { + Html: { + Data: html, Charset: 'UTF-8' }, - Body: { - Html: { - Data: html, - Charset: 'UTF-8' - }, - Text: { - Data: text || html.replace(/<[^>]*>/g, ''), - Charset: 'UTF-8' - } + Text: { + Data: text || html.replace(/<[^>]*>/g, ''), + Charset: 'UTF-8' } } - }; + } + }; - const result = await sesClient.sendEmail(params).promise(); - return { success: true, messageId: result.MessageId }; + const result = await sesClient.sendEmail(params).promise(); + return { success: true, messageId: result.MessageId }; } } else if (transporter) { // Отправка через SMTP (Yandex, Gmail и т.д.) diff --git a/frontend/src/pages/Feed.jsx b/frontend/src/pages/Feed.jsx index 60b2ca9..1bc4e96 100644 --- a/frontend/src/pages/Feed.jsx +++ b/frontend/src/pages/Feed.jsx @@ -90,7 +90,7 @@ export default function Feed({ user }) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }) } else { console.warn('[Feed] Элемент поста не найден после загрузки:', postId) - } + } }, 300) } catch (error) { console.error('[Feed] Ошибка загрузки поста:', error) diff --git a/moderation/backend-py/main.py b/moderation/backend-py/main.py index 838aa5a..c33f8ac 100644 --- a/moderation/backend-py/main.py +++ b/moderation/backend-py/main.py @@ -80,8 +80,22 @@ app.include_router(mod_app_router, prefix="/api/mod-app", tags=["moderation"]) app.include_router(moderation_auth_router, prefix="/api/moderation-auth", tags=["auth"]) # Mount Socket.IO app for WebSocket -socketio_app = get_socketio_app() -app.mount("/socket.io", socketio_app) +# Socket.IO ASGI app needs to wrap FastAPI app to handle both HTTP and WebSocket +try: + socketio_app = get_socketio_app() + # Socket.IO will handle /socket.io and /mod-chat paths + # We need to mount it so it can intercept WebSocket connections + # But FastAPI routes should still work + # The Socket.IO ASGI app will handle its own paths and pass others to FastAPI + app.mount("/socket.io", socketio_app) + app.mount("/mod-chat", socketio_app) # Also mount at /mod-chat for namespace + print("✅ WebSocket (Socket.IO) настроен") + print(" 📡 Namespace /mod-chat доступен для чата модераторов") +except Exception as e: + print(f"⚠️ Ошибка настройки WebSocket: {e}") + import traceback + traceback.print_exc() + print(" Чат модераторов будет недоступен") if __name__ == "__main__": diff --git a/moderation/backend-py/models.py b/moderation/backend-py/models.py index 7c30fc5..728ba4e 100644 --- a/moderation/backend-py/models.py +++ b/moderation/backend-py/models.py @@ -131,6 +131,7 @@ class AdminResponse(AdminBase): # Request models class UpdatePostRequest(BaseModel): content: Optional[str] = None + hashtags: Optional[List[str]] = None tags: Optional[List[str]] = None isNSFW: Optional[bool] = None isHomo: Optional[bool] = None diff --git a/moderation/backend-py/routes/mod_app.py b/moderation/backend-py/routes/mod_app.py index d3a4192..ba13fed 100644 --- a/moderation/backend-py/routes/mod_app.py +++ b/moderation/backend-py/routes/mod_app.py @@ -318,12 +318,25 @@ async def update_post( detail="Пост не найден" ) + # Модераторы и админы могут редактировать любой пост через панель модерации + # Доступ к панели модерации уже проверен через require_moderator + update_data = {} if request.content is not None: update_data['content'] = request.content + # Обновить хэштеги из контента, если hashtags не переданы явно + if request.hashtags is not None: + update_data['hashtags'] = [tag.replace('#', '').lower() if tag.startswith('#') else tag.lower() for tag in request.hashtags] + else: + # Извлечь хэштеги из контента (поддержка кириллицы) + import re + hashtags = re.findall(r'#([\wа-яА-ЯёЁ]+)', request.content) + update_data['hashtags'] = [tag.lower() for tag in hashtags] + if request.tags is not None: update_data['tags'] = [t.lower() for t in request.tags] + if request.isNSFW is not None: update_data['isNSFW'] = request.isNSFW if request.isHomo is not None: @@ -338,12 +351,42 @@ async def update_post( {'$set': update_data} ) - return {"success": True} + # Get updated post with author + updated_post = await posts_collection().find_one({'_id': ObjectId(post_id)}) + author = None + if updated_post.get('author'): + author_doc = await users_collection().find_one({'_id': updated_post['author']}) + if author_doc: + author = { + 'id': str(author_doc['_id']), + 'username': author_doc.get('username'), + 'firstName': author_doc.get('firstName'), + 'lastName': author_doc.get('lastName') + } + + return { + "post": { + "id": str(updated_post['_id']), + "author": author, + "content": updated_post.get('content'), + "hashtags": updated_post.get('hashtags', []), + "tags": updated_post.get('tags', []), + "images": updated_post.get('images', []) or ([updated_post.get('imageUrl')] if updated_post.get('imageUrl') else []), + "isNSFW": updated_post.get('isNSFW', False), + "isArt": updated_post.get('isArt', False), + "publishedToChannel": updated_post.get('publishedToChannel', False), + "adminNumber": updated_post.get('adminNumber'), + "editedAt": updated_post.get('editedAt').isoformat() if updated_post.get('editedAt') else None, + "createdAt": updated_post.get('createdAt', datetime.utcnow()).isoformat() + } + } except HTTPException: raise except Exception as e: print(f"[ModApp] Ошибка обновления поста: {e}") + import traceback + traceback.print_exc() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Ошибка сервера" diff --git a/moderation/backend-py/routes/moderation_auth.py b/moderation/backend-py/routes/moderation_auth.py index 0f74048..688b3f6 100644 --- a/moderation/backend-py/routes/moderation_auth.py +++ b/moderation/backend-py/routes/moderation_auth.py @@ -22,6 +22,7 @@ from utils.auth import ( get_current_user, normalize_username, is_moderation_admin ) from utils.email_service import send_verification_code +from utils.telegram_widget import validate_telegram_widget from config import settings router = APIRouter() @@ -318,6 +319,34 @@ async def telegram_widget_auth(request: TelegramWidgetAuth, response: Response): print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram виджет: id={request.id}, username={request.username}") try: + # Validate Telegram widget data if hash is provided + if request.hash and request.auth_date: + auth_data = { + 'id': request.id, + 'first_name': request.first_name, + 'last_name': request.last_name, + 'username': request.username, + 'photo_url': request.photo_url, + 'auth_date': request.auth_date, + 'hash': request.hash + } + + try: + is_valid = validate_telegram_widget(auth_data, settings.MODERATION_BOT_TOKEN) + if not is_valid: + print(f"[ModerationAuth] ❌ Неверная подпись Telegram виджета") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Неверная подпись авторизации" + ) + print(f"[ModerationAuth] ✅ Подпись Telegram виджета валидна") + except ValueError as e: + print(f"[ModerationAuth] ❌ Ошибка валидации виджета: {e}") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(e) + ) + # Find user by telegramId print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}") user = await users_collection().find_one({'telegramId': str(request.id)}) diff --git a/moderation/backend-py/utils/email_service.py b/moderation/backend-py/utils/email_service.py index f55d76b..3ebc165 100644 --- a/moderation/backend-py/utils/email_service.py +++ b/moderation/backend-py/utils/email_service.py @@ -1,5 +1,5 @@ """ -Email sending utilities with support for Yandex SMTP +Email sending utilities with support for AWS SES and Yandex SMTP """ import smtplib import logging @@ -11,6 +11,15 @@ from config import settings logger = logging.getLogger(__name__) +# Try to import boto3 for AWS SES +try: + import boto3 + from botocore.exceptions import ClientError, BotoCoreError + BOTO3_AVAILABLE = True +except ImportError: + BOTO3_AVAILABLE = False + logger.warning("[Email] boto3 не установлен. AWS SES недоступен. Установите: pip install boto3") + def generate_verification_email(code: str) -> str: """Generate HTML email with verification code""" @@ -43,6 +52,117 @@ def generate_verification_email(code: str) -> str: """ +async def send_email_aws_ses(to: str, subject: str, html: str, text: Optional[str] = None): + """Send email via AWS SES or Yandex Cloud Postbox (SESv2)""" + if not BOTO3_AVAILABLE: + raise ValueError("boto3 не установлен. Установите: pip install boto3") + + if not settings.AWS_SES_ACCESS_KEY_ID or not settings.AWS_SES_SECRET_ACCESS_KEY: + raise ValueError("AWS_SES_ACCESS_KEY_ID и AWS_SES_SECRET_ACCESS_KEY должны быть установлены в .env") + + try: + # Check if using Yandex Cloud Postbox (has custom endpoint) + is_yandex_cloud = bool(settings.AWS_SES_ENDPOINT_URL and 'yandex' in settings.AWS_SES_ENDPOINT_URL.lower()) + + # Create client config + client_config = { + 'aws_access_key_id': settings.AWS_SES_ACCESS_KEY_ID, + 'aws_secret_access_key': settings.AWS_SES_SECRET_ACCESS_KEY, + 'region_name': settings.AWS_SES_REGION + } + + # Add custom endpoint if specified (for Yandex Cloud Postbox) + if settings.AWS_SES_ENDPOINT_URL: + client_config['endpoint_url'] = settings.AWS_SES_ENDPOINT_URL + + # Yandex Cloud Postbox uses SESv2 API + # Also check if region is ru-central1 (Yandex Cloud region) + if is_yandex_cloud or settings.AWS_SES_REGION == 'ru-central1': + endpoint_url = settings.AWS_SES_ENDPOINT_URL or 'https://postbox.cloud.yandex.net' + logger.info(f"[Email] Использование Yandex Cloud Postbox (SESv2 API): {endpoint_url}") + + # Override endpoint if not set + if not settings.AWS_SES_ENDPOINT_URL: + client_config['endpoint_url'] = endpoint_url + + sesv2_client = boto3.client('sesv2', **client_config) + + # Prepare email for SESv2 + destination = {'ToAddresses': [to]} + content = { + 'Simple': { + 'Subject': { + 'Data': subject, + 'Charset': 'UTF-8' + }, + 'Body': { + 'Html': { + 'Data': html, + 'Charset': 'UTF-8' + }, + 'Text': { + 'Data': text or html.replace('<[^>]*>', ''), + 'Charset': 'UTF-8' + } + } + } + } + + # Send email via SESv2 + response = sesv2_client.send_email( + FromEmailAddress=settings.EMAIL_FROM, + Destination=destination, + Content=content + ) + + logger.info(f"✅ Email отправлен через Yandex Cloud Postbox на {to}, MessageId: {response.get('MessageId')}") + return {"success": True, "to": to, "messageId": response.get('MessageId')} + else: + # Standard AWS SES (SESv1 API) + logger.info(f"[Email] Использование AWS SES (SESv1 API): {settings.AWS_SES_REGION}") + ses_client = boto3.client('ses', **client_config) + + # Prepare email for SESv1 + destination = {'ToAddresses': [to]} + message = { + 'Subject': {'Data': subject, 'Charset': 'UTF-8'}, + 'Body': { + 'Html': {'Data': html, 'Charset': 'UTF-8'}, + 'Text': {'Data': text or html.replace('<[^>]*>', ''), 'Charset': 'UTF-8'} + } + } + + # Send email + response = ses_client.send_email( + Source=settings.EMAIL_FROM, + Destination=destination, + Message=message + ) + + logger.info(f"✅ Email отправлен через AWS SES на {to}, MessageId: {response.get('MessageId')}") + return {"success": True, "to": to, "messageId": response.get('MessageId')} + + except ClientError as e: + error_code = e.response.get('Error', {}).get('Code', 'Unknown') + error_msg = e.response.get('Error', {}).get('Message', str(e)) + logger.error(f"❌ AWS SES ошибка ({error_code}): {error_msg}") + + # More informative error messages + if error_code == 'MessageRejected': + raise ValueError(f"Письмо отклонено: {error_msg}. Проверьте, что адрес {settings.EMAIL_FROM} верифицирован.") + elif error_code == 'InvalidParameterValue': + raise ValueError(f"Неверный параметр: {error_msg}") + elif error_code == 'AccessDenied': + raise ValueError(f"Доступ запрещен: {error_msg}. Проверьте права доступа ключей.") + + raise ValueError(f"AWS SES ошибка ({error_code}): {error_msg}") + except Exception as e: + logger.error(f"❌ Ошибка отправки email через AWS SES: {e}") + import traceback + traceback.print_exc() + raise + + async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] = None): """Send email via SMTP (Yandex, Gmail, etc.)""" try: @@ -81,7 +201,7 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] logger.info(f"✅ Email отправлен на {to}") return {"success": True, "to": to} else: - raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'yandex' или 'smtp'") + raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'aws', 'yandex' или 'smtp'") except Exception as e: logger.error(f"❌ Ошибка отправки email: {e}") @@ -100,13 +220,25 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] raise +async def send_email(to: str, subject: str, html: str, text: Optional[str] = None): + """Send email using configured provider (AWS SES or SMTP)""" + email_provider = settings.EMAIL_PROVIDER.lower() + + if email_provider == 'aws': + return await send_email_aws_ses(to, subject, html, text) + elif email_provider in ['yandex', 'smtp']: + return await send_email_smtp(to, subject, html, text) + else: + raise ValueError(f"Email provider '{settings.EMAIL_PROVIDER}' не поддерживается. Используйте 'aws', 'yandex' или 'smtp'") + + async def send_verification_code(email: str, code: str): """Send verification code to email""" subject = "Код подтверждения регистрации - Nakama" html = generate_verification_email(code) text = f"Ваш код подтверждения: {code}. Код действителен 15 минут." - return await send_email_smtp(email, subject, html, text) + return await send_email(email, subject, html, text) async def send_admin_confirmation_code(code: str, action: str, user_info: dict): @@ -152,5 +284,5 @@ async def send_admin_confirmation_code(code: str, action: str, user_info: dict): Пользователь: @{user_info.get('username', 'не указан')} Код действителен 5 минут.""" - return await send_email_smtp(settings.OWNER_EMAIL, subject, html, text) + return await send_email(settings.OWNER_EMAIL, subject, html, text) diff --git a/moderation/backend-py/utils/telegram_widget.py b/moderation/backend-py/utils/telegram_widget.py new file mode 100644 index 0000000..b929552 --- /dev/null +++ b/moderation/backend-py/utils/telegram_widget.py @@ -0,0 +1,68 @@ +""" +Telegram Login Widget validation +""" +import hmac +import hashlib +from typing import Optional, Dict, Any +from config import settings + + +def validate_telegram_widget(auth_data: Dict[str, Any], bot_token: Optional[str] = None) -> bool: + """ + Validate Telegram Login Widget authentication data + + Args: + auth_data: Dictionary with user data and hash from Telegram widget + bot_token: Bot token (uses MODERATION_BOT_TOKEN if not provided) + + Returns: + True if validation successful + + Raises: + ValueError: If validation fails + """ + token_to_use = bot_token or settings.MODERATION_BOT_TOKEN + + if not token_to_use or not isinstance(token_to_use, str) or not token_to_use.strip(): + raise ValueError('Bot token модерации не настроен (MODERATION_BOT_TOKEN)') + + if not auth_data or 'hash' not in auth_data: + raise ValueError('Отсутствует hash в authData') + + received_hash = auth_data.pop('hash') + + # Clean data - remove None, empty strings, and convert all to strings + clean_data = {} + for key, value in auth_data.items(): + if value is not None and value != '': + clean_data[key] = str(value) + + # Create data check string (sorted keys) + data_check_arr = sorted(clean_data.items()) + data_check_string = '\n'.join(f'{key}={value}' for key, value in data_check_arr) + + # Create secret key + secret_key = hmac.new( + 'WebAppData'.encode('utf-8'), + token_to_use.encode('utf-8'), + hashlib.sha256 + ).digest() + + # Calculate hash + calculated_hash = hmac.new( + secret_key, + data_check_string.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + # Compare hashes + is_valid = calculated_hash == received_hash + + if not is_valid: + print(f"[TelegramWidget] Hash mismatch:") + print(f" Calculated: {calculated_hash[:20]}...") + print(f" Received: {received_hash[:20]}...") + print(f" Data check string: {data_check_string}") + + return is_valid + diff --git a/moderation/backend-py/websocket_server.py b/moderation/backend-py/websocket_server.py index 8e9e26e..cc9a1e8 100644 --- a/moderation/backend-py/websocket_server.py +++ b/moderation/backend-py/websocket_server.py @@ -3,7 +3,12 @@ WebSocket server for moderation chat """ import socketio import logging -from typing import Dict, Set +from typing import Dict, Set, Optional +from datetime import datetime + +from config import settings +from database import moderation_admins_collection +from utils.auth import normalize_username logger = logging.getLogger(__name__) @@ -16,109 +21,120 @@ sio = socketio.AsyncServer( ) # Track connected moderators -connected_moderators: Dict[str, Set[str]] = {} # {room: {sid1, sid2, ...}} +connected_moderators: Dict[str, dict] = {} # {sid: {username, telegramId, isOwner}} -@sio.event -async def connect(sid, environ): - """Handle client connection""" - logger.info(f"[WebSocket] Client connected: {sid}") - - -@sio.event -async def disconnect(sid): - """Handle client disconnection""" - logger.info(f"[WebSocket] Client disconnected: {sid}") - - # Remove from all rooms - for room, sids in connected_moderators.items(): - if sid in sids: - sids.remove(sid) - await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid) - - -@sio.event -async def join_moderation_chat(sid, data): - """Join moderation chat room""" - try: - user_id = data.get('userId') +def broadcast_online(): + """Broadcast list of online moderators""" + unique = {} + for data in connected_moderators.values(): username = data.get('username') + if username: + unique[username] = data + + online_list = list(unique.values()) + sio.emit('online', online_list, namespace='/mod-chat') + + +# Create namespace for moderation chat +mod_chat = sio.namespace('/mod-chat') + + +@mod_chat.on('connect') +async def on_connect(sid, environ): + """Handle client connection to /mod-chat namespace""" + logger.info(f"[WebSocket] Client connected to /mod-chat: {sid}") + # Don't authorize immediately - wait for 'auth' event + + +@mod_chat.on('disconnect') +async def on_disconnect(sid): + """Handle client disconnection""" + logger.info(f"[WebSocket] Client disconnected from /mod-chat: {sid}") + + if sid in connected_moderators: + del connected_moderators[sid] + broadcast_online() + + +@mod_chat.on('auth') +async def on_auth(sid, data): + """Handle authentication for moderation chat""" + try: + username = normalize_username(data.get('username')) if data.get('username') else None + telegram_id = data.get('telegramId') - if not user_id: + if not username or not telegram_id: + logger.warning(f"[WebSocket] Auth failed: missing username or telegramId") + await sio.emit('unauthorized', namespace='/mod-chat', room=sid) + await sio.disconnect(sid, namespace='/mod-chat') return - room = 'moderation_chat' - await sio.enter_room(sid, room) + # Check if user is owner + owner_usernames = settings.OWNER_USERNAMES_LIST + is_owner = username.lower() in [u.lower() for u in owner_usernames] - if room not in connected_moderators: - connected_moderators[room] = set() - connected_moderators[room].add(sid) + # Check if user is moderation admin + admin = await moderation_admins_collection().find_one({ + 'telegramId': str(telegram_id) + }) + is_admin = admin is not None - logger.info(f"[WebSocket] {username} ({user_id}) joined moderation chat") + if not is_owner and not is_admin: + logger.warning(f"[WebSocket] Access denied: {username} (telegramId: {telegram_id})") + await sio.emit('unauthorized', namespace='/mod-chat', room=sid) + await sio.disconnect(sid, namespace='/mod-chat') + return - # Notify others - await sio.emit('user_joined', { - 'userId': user_id, + # Store connection data + connected_moderators[sid] = { 'username': username, - 'sid': sid - }, room=room, skip_sid=sid) - - except Exception as e: - logger.error(f"[WebSocket] Error joining chat: {e}") - - -@sio.event -async def leave_moderation_chat(sid, data): - """Leave moderation chat room""" - try: - room = 'moderation_chat' - await sio.leave_room(sid, room) - - if room in connected_moderators and sid in connected_moderators[room]: - connected_moderators[room].remove(sid) - - # Notify others - await sio.emit('user_left', {'sid': sid}, room=room, skip_sid=sid) - - except Exception as e: - logger.error(f"[WebSocket] Error leaving chat: {e}") - - -@sio.event -async def moderation_message(sid, data): - """Handle moderation chat message""" - try: - room = 'moderation_chat' - - message_data = { - 'userId': data.get('userId'), - 'username': data.get('username'), - 'message': data.get('message'), - 'timestamp': data.get('timestamp') + 'telegramId': telegram_id, + 'isOwner': is_owner } - # Broadcast to all in room - await sio.emit('moderation_message', message_data, room=room) - - logger.info(f"[WebSocket] Message from {data.get('username')}: {data.get('message')[:50]}...") + logger.info(f"[WebSocket] Auth success: {username} (owner: {is_owner}, admin: {is_admin})") + await sio.emit('ready', namespace='/mod-chat', room=sid) + broadcast_online() except Exception as e: - logger.error(f"[WebSocket] Error sending message: {e}") + logger.error(f"[WebSocket] Error in auth: {e}") + import traceback + traceback.print_exc() + await sio.emit('unauthorized', namespace='/mod-chat', room=sid) + await sio.disconnect(sid, namespace='/mod-chat') -@sio.event -async def typing(sid, data): - """Handle typing indicator""" +@mod_chat.on('message') +async def on_message(sid, data): + """Handle moderation chat message""" try: - room = 'moderation_chat' - await sio.emit('typing', { - 'userId': data.get('userId'), - 'username': data.get('username'), - 'isTyping': data.get('isTyping', True) - }, room=room, skip_sid=sid) + if sid not in connected_moderators: + logger.warning(f"[WebSocket] Message from unauthorized client: {sid}") + return + + user_data = connected_moderators[sid] + text = (data.get('text') or '').strip() + + if not text: + return + + message = { + 'id': f"{int(datetime.utcnow().timestamp() * 1000)}-{sid[:8]}", + 'username': user_data['username'], + 'telegramId': user_data['telegramId'], + 'text': text, + 'createdAt': datetime.utcnow().isoformat() + } + + # Broadcast to all in namespace + await sio.emit('message', message, namespace='/mod-chat') + logger.info(f"[WebSocket] Message from {user_data['username']}: {text[:50]}...") except Exception as e: - logger.error(f"[WebSocket] Error handling typing: {e}") + logger.error(f"[WebSocket] Error handling message: {e}") + import traceback + traceback.print_exc() def get_socketio_app():