diff --git a/moderation/backend-py/routes/mod_app.py b/moderation/backend-py/routes/mod_app.py index 0572dd6..d3a4192 100644 --- a/moderation/backend-py/routes/mod_app.py +++ b/moderation/backend-py/routes/mod_app.py @@ -1,6 +1,7 @@ """ Moderation app routes - main moderation functionality """ +import secrets from datetime import datetime, timedelta from typing import Optional, List from fastapi import APIRouter, HTTPException, status, Depends, Query @@ -242,6 +243,33 @@ async def get_post( 'photoUrl': post['author'].get('photoUrl') } + # Serialize comments + comments_serialized = [] + for comment in post.get('comments', []): + comment_author_id = comment.get('author') + if isinstance(comment_author_id, ObjectId): + comment_author_id = str(comment_author_id) + elif isinstance(comment_author_id, dict): + comment_author_id = str(comment_author_id.get('_id', '')) + + comments_serialized.append({ + 'id': str(comment.get('_id', '')), + 'author': comment_author_id, + 'content': comment.get('content'), + 'createdAt': comment.get('createdAt', datetime.utcnow()).isoformat() if comment.get('createdAt') else None, + 'editedAt': comment.get('editedAt').isoformat() if comment.get('editedAt') else None + }) + + # Serialize likes (convert ObjectIds to strings) + likes_serialized = [] + for like in post.get('likes', []): + if isinstance(like, ObjectId): + likes_serialized.append(str(like)) + elif isinstance(like, dict): + likes_serialized.append(str(like.get('_id', ''))) + else: + likes_serialized.append(str(like)) + return { 'post': { 'id': str(post['_id']), @@ -250,12 +278,16 @@ async def get_post( 'hashtags': post.get('hashtags', []), 'tags': post.get('tags', []), 'images': post.get('images', []) or ([post.get('imageUrl')] if post.get('imageUrl') else []), - 'comments': post.get('comments', []), - 'likes': post.get('likes', []), + 'comments': comments_serialized, + 'likes': likes_serialized, + 'commentsCount': len(comments_serialized), + 'likesCount': len(likes_serialized), 'isNSFW': post.get('isNSFW', False), + 'isHomo': post.get('isHomo', False), 'isArt': post.get('isArt', False), 'publishedToChannel': post.get('publishedToChannel', False), 'adminNumber': post.get('adminNumber'), + 'editedAt': post.get('editedAt').isoformat() if post.get('editedAt') else None, 'createdAt': post.get('createdAt', datetime.utcnow()).isoformat() } } @@ -318,6 +350,203 @@ async def update_post( ) +@router.delete("/posts/{post_id}/comments/{comment_id}") +async def delete_comment( + post_id: str, + comment_id: str, + user: dict = Depends(require_moderator) +): + """Delete comment from post""" + try: + post = await posts_collection().find_one({'_id': ObjectId(post_id)}) + + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пост не найден" + ) + + # Find and remove comment + comments = post.get('comments', []) + comment_found = False + + for i, comment in enumerate(comments): + if str(comment.get('_id')) == comment_id: + comments.pop(i) + comment_found = True + break + + if not comment_found: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Комментарий не найден" + ) + + # Update post + await posts_collection().update_one( + {'_id': ObjectId(post_id)}, + {'$set': {'comments': comments}} + ) + + # Get updated comments with populated authors + updated_post = await posts_collection().find_one({'_id': ObjectId(post_id)}) + comments_serialized = [] + + for comment in updated_post.get('comments', []): + comment_author_id = comment.get('author') + if isinstance(comment_author_id, ObjectId): + # Populate author + author = await users_collection().find_one({'_id': comment_author_id}) + if author: + comments_serialized.append({ + 'id': str(comment.get('_id', '')), + 'author': { + 'id': str(author['_id']), + 'username': author.get('username'), + 'firstName': author.get('firstName'), + 'lastName': author.get('lastName'), + 'photoUrl': author.get('photoUrl') + }, + 'content': comment.get('content'), + 'createdAt': comment.get('createdAt', datetime.utcnow()).isoformat() if comment.get('createdAt') else None + }) + + return {"comments": comments_serialized} + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка удаления комментария: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка сервера" + ) + + +@router.delete("/posts/{post_id}/images/{index}") +async def delete_post_image( + post_id: str, + index: int, + user: dict = Depends(require_moderator) +): + """Delete specific image from post""" + try: + post = await posts_collection().find_one({'_id': ObjectId(post_id)}) + + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пост не найден" + ) + + images = post.get('images', []) or [] + + if index < 0 or index >= len(images): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Неверный индекс изображения" + ) + + # Remove image + removed_image = images.pop(index) + + # Update post + update_data = {'images': images} + if images: + update_data['imageUrl'] = images[0] # Set first image as main + else: + update_data['imageUrl'] = None + + await posts_collection().update_one( + {'_id': ObjectId(post_id)}, + {'$set': update_data} + ) + + # Delete from MinIO + if removed_image and 'nakama-media/' in removed_image: + from utils.minio_client import delete_file + try: + path_match = removed_image.split('nakama-media/') + if len(path_match) > 1: + file_path = path_match[-1] + await delete_file(file_path) + print(f"✅ Удалено изображение из MinIO: {file_path}") + except Exception as e: + print(f"❌ Ошибка удаления изображения из MinIO: {e}") + + return {"images": images} + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка удаления изображения: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка сервера" + ) + + +@router.post("/posts/{post_id}/ban") +async def ban_post_author( + post_id: str, + request: dict, + user: dict = Depends(require_moderator) +): + """Ban post author""" + try: + days = request.get('days', 7) + duration_days = max(int(days) if days else 7, 1) + + post = await posts_collection().find_one({'_id': ObjectId(post_id)}) + + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пост не найден" + ) + + author_id = post.get('author') + if not author_id: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Автор поста не найден" + ) + + # Ban author + await users_collection().update_one( + {'_id': author_id}, + { + '$set': { + 'banned': True, + 'bannedUntil': datetime.utcnow() + timedelta(days=duration_days) + } + } + ) + + # Get updated user + banned_user = await users_collection().find_one({'_id': author_id}) + + return { + "user": { + "id": str(banned_user['_id']), + "username": banned_user.get('username'), + "firstName": banned_user.get('firstName'), + "lastName": banned_user.get('lastName'), + "banned": banned_user.get('banned', False), + "bannedUntil": banned_user.get('bannedUntil').isoformat() if banned_user.get('bannedUntil') else None + } + } + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка бана автора: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка сервера" + ) + + @router.delete("/posts/{post_id}") async def delete_post( post_id: str, @@ -333,6 +562,22 @@ async def delete_post( detail="Пост не найден" ) + # Delete images from MinIO if needed + from utils.minio_client import delete_file + images = post.get('images', []) or ([post.get('imageUrl')] if post.get('imageUrl') else []) + + for image_url in images: + if image_url and 'nakama-media/' in image_url: + try: + # Extract path from URL + path_match = image_url.split('nakama-media/') + if len(path_match) > 1: + file_path = path_match[-1] + await delete_file(file_path) + print(f"✅ Удалено изображение из MinIO: {file_path}") + except Exception as e: + print(f"❌ Ошибка удаления изображения из MinIO: {e}") + # Delete post await posts_collection().delete_one({'_id': ObjectId(post_id)}) @@ -503,6 +748,293 @@ async def get_admins(user: dict = Depends(require_moderator)): ) +@router.post("/admins/initiate-add") +async def initiate_add_admin( + request: dict, + user: dict = Depends(require_owner) +): + """Initiate adding admin (owner only)""" + try: + user_id = request.get('userId') + admin_number = request.get('adminNumber') + + if not user_id or not admin_number: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Не указан ID пользователя или номер админа" + ) + + if admin_number < 1 or admin_number > 10: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Номер админа должен быть от 1 до 10" + ) + + # Check if number is taken + existing_admin = await moderation_admins_collection().find_one({'adminNumber': admin_number}) + if existing_admin: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Номер админа уже занят" + ) + + # Check if user exists + target_user = await users_collection().find_one({'_id': ObjectId(user_id)}) + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Check if already admin + is_already_admin = await moderation_admins_collection().find_one({ + 'telegramId': target_user.get('telegramId') + }) + if is_already_admin: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Пользователь уже является админом" + ) + + # Generate code + code = str(secrets.randbelow(900000) + 100000) + + # Save confirmation + from database import admin_confirmations_collection + await admin_confirmations_collection().insert_one({ + 'userId': target_user.get('telegramId'), + 'code': code, + 'adminNumber': admin_number, + 'action': 'add', + 'expiresAt': datetime.utcnow() + timedelta(minutes=5), + 'createdAt': datetime.utcnow() + }) + + # Send code to owner email + from utils.email_service import send_admin_confirmation_code + try: + await send_admin_confirmation_code( + code, + 'add', + { + 'username': target_user.get('username'), + 'firstName': target_user.get('firstName'), + 'adminNumber': admin_number + } + ) + except Exception as email_error: + print(f'[ModApp] Ошибка отправки кода на email: {email_error}') + # Continue anyway - code is saved + + return { + "success": True, + "message": "Код подтверждения отправлен на email владельца", + "username": target_user.get('username') + } + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка инициирования добавления админа: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка отправки кода подтверждения" + ) + + +@router.post("/admins/confirm-add") +async def confirm_add_admin( + request: dict, + user: dict = Depends(require_owner) +): + """Confirm adding admin (owner only)""" + try: + user_id = request.get('userId') + code = request.get('code') + + if not user_id or not code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Не указан ID пользователя или код" + ) + + target_user = await users_collection().find_one({'_id': ObjectId(user_id)}) + if not target_user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Пользователь не найден" + ) + + # Find confirmation + from database import admin_confirmations_collection + confirmation = await admin_confirmations_collection().find_one({ + 'userId': target_user.get('telegramId'), + 'code': code, + 'action': 'add' + }) + + if not confirmation: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Неверный код подтверждения" + ) + + # Check expiration + if datetime.utcnow() > confirmation['expiresAt']: + await admin_confirmations_collection().delete_one({'_id': confirmation['_id']}) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Код истек" + ) + + # Create admin + await moderation_admins_collection().insert_one({ + 'telegramId': target_user.get('telegramId'), + 'username': target_user.get('username'), + 'firstName': target_user.get('firstName'), + 'lastName': target_user.get('lastName'), + 'adminNumber': confirmation['adminNumber'], + 'addedBy': str(user['_id']), + 'createdAt': datetime.utcnow() + }) + + # Delete confirmation + await admin_confirmations_collection().delete_one({'_id': confirmation['_id']}) + + return {"success": True} + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка подтверждения добавления админа: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка добавления админа" + ) + + +@router.post("/admins/initiate-remove") +async def initiate_remove_admin( + request: dict, + user: dict = Depends(require_owner) +): + """Initiate removing admin (owner only)""" + try: + admin_id = request.get('adminId') + + if not admin_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Не указан ID админа" + ) + + admin = await moderation_admins_collection().find_one({'_id': ObjectId(admin_id)}) + if not admin: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Администратор не найден" + ) + + # Generate code + code = str(secrets.randbelow(900000) + 100000) + + # Save confirmation + from database import admin_confirmations_collection + await admin_confirmations_collection().insert_one({ + 'userId': admin.get('telegramId'), + 'code': code, + 'adminNumber': admin.get('adminNumber'), + 'action': 'remove', + 'expiresAt': datetime.utcnow() + timedelta(minutes=5), + 'createdAt': datetime.utcnow() + }) + + # Send code to owner email + from utils.email_service import send_admin_confirmation_code + try: + await send_admin_confirmation_code( + code, + 'remove', + { + 'username': admin.get('username'), + 'firstName': admin.get('firstName'), + 'adminNumber': admin.get('adminNumber') + } + ) + except Exception as email_error: + print(f'[ModApp] Ошибка отправки кода на email: {email_error}') + + return { + "success": True, + "message": "Код подтверждения отправлен на email владельца", + "username": admin.get('username') + } + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка инициирования удаления админа: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка отправки кода подтверждения" + ) + + +@router.post("/admins/confirm-remove") +async def confirm_remove_admin( + request: dict, + user: dict = Depends(require_owner) +): + """Confirm removing admin (owner only)""" + try: + admin_id = request.get('adminId') + code = request.get('code') + + if not admin_id or not code: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Не указан ID админа или код" + ) + + admin = await moderation_admins_collection().find_one({'_id': ObjectId(admin_id)}) + if not admin: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Администратор не найден" + ) + + # Find confirmation + from database import admin_confirmations_collection + confirmation = await admin_confirmations_collection().find_one({ + 'userId': admin.get('telegramId'), + 'code': code, + 'action': 'remove' + }) + + if not confirmation: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Неверный код подтверждения" + ) + + # Delete admin + await moderation_admins_collection().delete_one({'_id': ObjectId(admin_id)}) + + # Delete confirmation + await admin_confirmations_collection().delete_one({'_id': confirmation['_id']}) + + return {"success": True} + + except HTTPException: + raise + except Exception as e: + print(f"[ModApp] Ошибка подтверждения удаления админа: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ошибка удаления админа" + ) + + @router.post("/auth/verify") async def verify_auth(user: dict = Depends(require_moderator)): """Verify authentication and get admin list""" diff --git a/moderation/backend-py/utils/telegram_initdata.py b/moderation/backend-py/utils/telegram_initdata.py index ee59930..ffacc97 100644 --- a/moderation/backend-py/utils/telegram_initdata.py +++ b/moderation/backend-py/utils/telegram_initdata.py @@ -7,8 +7,8 @@ import urllib.parse from typing import Optional, Dict, Any from config import settings -MAX_AUTH_AGE_SECONDS = 60 * 60 # 1 час -AUTH_AGE_TOLERANCE_SECONDS = 300 # 5 минут допуск +MAX_AUTH_AGE_SECONDS = 60 * 60 * 24 # 24 часа (увеличено для модерации) +AUTH_AGE_TOLERANCE_SECONDS = 60 * 60 # 1 час допуск def validate_init_data(init_data_raw: str, bot_token: Optional[str] = None) -> Dict[str, Any]: @@ -85,9 +85,9 @@ def validate_init_data(init_data_raw: str, bot_token: Optional[str] = None) -> D age = abs(now - auth_date) if age > MAX_AUTH_AGE_SECONDS: - # Allow small tolerance in development - if settings.IS_DEVELOPMENT and age <= MAX_AUTH_AGE_SECONDS + AUTH_AGE_TOLERANCE_SECONDS: - print(f'⚠️ InitData expired ({age}s), but allowing due to tolerance in development.') + # Allow tolerance (особенно для модерации, где сессии могут быть длинными) + if age <= MAX_AUTH_AGE_SECONDS + AUTH_AGE_TOLERANCE_SECONDS: + print(f'⚠️ InitData expired ({age}s), but allowing due to tolerance.') else: raise ValueError(f'Данные авторизации устарели (возраст: {age}с, макс: {MAX_AUTH_AGE_SECONDS}с)') except ValueError: