Update files
This commit is contained in:
parent
3be93d1353
commit
bab6b49585
|
|
@ -279,29 +279,21 @@ router.put('/posts/:id', authenticateModerationFlexible, requireModerationAccess
|
||||||
return res.status(404).json({ error: 'Пост не найден' });
|
return res.status(404).json({ error: 'Пост не найден' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверить, может ли админ редактировать этот пост
|
// Модераторы и админы могут редактировать любой пост через панель модерации
|
||||||
// Админ может редактировать:
|
// Доступ к панели модерации уже проверен через requireModerationAccess
|
||||||
// 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: 'Вы можете редактировать только свои посты из канала' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Если это обычный пост, владелец может редактировать любой, остальные админы - нет
|
|
||||||
// (это поведение можно изменить по необходимости)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content !== undefined) {
|
if (content !== undefined) {
|
||||||
post.content = content;
|
post.content = content;
|
||||||
post.hashtags = Array.isArray(hashtags)
|
// Обновить хэштеги из контента, если hashtags не переданы явно
|
||||||
? hashtags.map((tag) => tag.toLowerCase())
|
if (hashtags !== undefined) {
|
||||||
: post.hashtags;
|
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) {
|
if (tags !== undefined) {
|
||||||
|
|
|
||||||
|
|
@ -61,9 +61,9 @@ router.post('/send-code', codeLimiter, async (req, res) => {
|
||||||
const userByEmail = await User.findOne({ email: emailLower });
|
const userByEmail = await User.findOne({ email: emailLower });
|
||||||
if (userByEmail) {
|
if (userByEmail) {
|
||||||
console.log(`[ModerationAuth] Пользователь найден, но роль не moderator/admin:`, userByEmail.role);
|
console.log(`[ModerationAuth] Пользователь найден, но роль не moderator/admin:`, userByEmail.role);
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.'
|
error: 'Регистрация недоступна. Обратитесь к администратору для получения доступа.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если пользователя нет вообще - разрешить отправку кода
|
// Если пользователя нет вообще - разрешить отправку кода
|
||||||
|
|
|
||||||
|
|
@ -366,35 +366,46 @@ router.put('/:id', authenticate, async (req, res) => {
|
||||||
return res.status(403).json({ error: 'Нет прав на редактирование' });
|
return res.status(403).json({ error: 'Нет прав на редактирование' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Валидация контента
|
// Валидация контента (разрешаем пустой контент, если есть изображения)
|
||||||
if (content !== undefined && !validatePostContent(content)) {
|
if (content !== undefined) {
|
||||||
logSecurityEvent('INVALID_POST_CONTENT', req);
|
// Если контент не пустой, валидируем его
|
||||||
return res.status(400).json({ error: 'Недопустимый контент поста' });
|
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 = [];
|
let parsedTags = [];
|
||||||
try {
|
if (tags) {
|
||||||
parsedTags = JSON.parse(tags);
|
try {
|
||||||
} catch (e) {
|
parsedTags = typeof tags === 'string' ? JSON.parse(tags) : tags;
|
||||||
return res.status(400).json({ error: 'Неверный формат тегов' });
|
} catch (e) {
|
||||||
}
|
return res.status(400).json({ error: 'Неверный формат тегов' });
|
||||||
|
}
|
||||||
if (!validateTags(parsedTags)) {
|
|
||||||
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
|
if (!validateTags(parsedTags)) {
|
||||||
return res.status(400).json({ error: 'Недопустимые теги' });
|
logSecurityEvent('INVALID_TAGS', req, { tags: parsedTags });
|
||||||
|
return res.status(400).json({ error: 'Недопустимые теги' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
post.tags = parsedTags;
|
post.tags = parsedTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content !== undefined) {
|
|
||||||
post.content = content;
|
|
||||||
// Обновить хэштеги
|
|
||||||
post.hashtags = extractHashtags(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNSFW !== undefined) {
|
if (isNSFW !== undefined) {
|
||||||
post.isNSFW = isNSFW === 'true' || isNSFW === true;
|
post.isNSFW = isNSFW === 'true' || isNSFW === true;
|
||||||
}
|
}
|
||||||
|
|
@ -406,6 +417,10 @@ router.put('/:id', authenticate, async (req, res) => {
|
||||||
res.json({ post });
|
res.json({ post });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка редактирования поста:', error);
|
console.error('Ошибка редактирования поста:', error);
|
||||||
|
// Более детальная обработка ошибок
|
||||||
|
if (error.name === 'CastError') {
|
||||||
|
return res.status(400).json({ error: 'Неверный ID поста' });
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Ошибка сервера' });
|
res.status(500).json({ error: 'Ошибка сервера' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -202,31 +202,31 @@ const sendEmail = async (to, subject, html, text) => {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Обычный AWS SES
|
// Обычный AWS SES
|
||||||
const params = {
|
const params = {
|
||||||
Source: fromEmail,
|
Source: fromEmail,
|
||||||
Destination: {
|
Destination: {
|
||||||
ToAddresses: [to]
|
ToAddresses: [to]
|
||||||
|
},
|
||||||
|
Message: {
|
||||||
|
Subject: {
|
||||||
|
Data: subject,
|
||||||
|
Charset: 'UTF-8'
|
||||||
},
|
},
|
||||||
Message: {
|
Body: {
|
||||||
Subject: {
|
Html: {
|
||||||
Data: subject,
|
Data: html,
|
||||||
Charset: 'UTF-8'
|
Charset: 'UTF-8'
|
||||||
},
|
},
|
||||||
Body: {
|
Text: {
|
||||||
Html: {
|
Data: text || html.replace(/<[^>]*>/g, ''),
|
||||||
Data: html,
|
Charset: 'UTF-8'
|
||||||
Charset: 'UTF-8'
|
|
||||||
},
|
|
||||||
Text: {
|
|
||||||
Data: text || html.replace(/<[^>]*>/g, ''),
|
|
||||||
Charset: 'UTF-8'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const result = await sesClient.sendEmail(params).promise();
|
const result = await sesClient.sendEmail(params).promise();
|
||||||
return { success: true, messageId: result.MessageId };
|
return { success: true, messageId: result.MessageId };
|
||||||
}
|
}
|
||||||
} else if (transporter) {
|
} else if (transporter) {
|
||||||
// Отправка через SMTP (Yandex, Gmail и т.д.)
|
// Отправка через SMTP (Yandex, Gmail и т.д.)
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ export default function Feed({ user }) {
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
} else {
|
} else {
|
||||||
console.warn('[Feed] Элемент поста не найден после загрузки:', postId)
|
console.warn('[Feed] Элемент поста не найден после загрузки:', postId)
|
||||||
}
|
}
|
||||||
}, 300)
|
}, 300)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Feed] Ошибка загрузки поста:', error)
|
console.error('[Feed] Ошибка загрузки поста:', error)
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
app.include_router(moderation_auth_router, prefix="/api/moderation-auth", tags=["auth"])
|
||||||
|
|
||||||
# Mount Socket.IO app for WebSocket
|
# Mount Socket.IO app for WebSocket
|
||||||
socketio_app = get_socketio_app()
|
# Socket.IO ASGI app needs to wrap FastAPI app to handle both HTTP and WebSocket
|
||||||
app.mount("/socket.io", socketio_app)
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ class AdminResponse(AdminBase):
|
||||||
# Request models
|
# Request models
|
||||||
class UpdatePostRequest(BaseModel):
|
class UpdatePostRequest(BaseModel):
|
||||||
content: Optional[str] = None
|
content: Optional[str] = None
|
||||||
|
hashtags: Optional[List[str]] = None
|
||||||
tags: Optional[List[str]] = None
|
tags: Optional[List[str]] = None
|
||||||
isNSFW: Optional[bool] = None
|
isNSFW: Optional[bool] = None
|
||||||
isHomo: Optional[bool] = None
|
isHomo: Optional[bool] = None
|
||||||
|
|
|
||||||
|
|
@ -318,12 +318,25 @@ async def update_post(
|
||||||
detail="Пост не найден"
|
detail="Пост не найден"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Модераторы и админы могут редактировать любой пост через панель модерации
|
||||||
|
# Доступ к панели модерации уже проверен через require_moderator
|
||||||
|
|
||||||
update_data = {}
|
update_data = {}
|
||||||
|
|
||||||
if request.content is not None:
|
if request.content is not None:
|
||||||
update_data['content'] = request.content
|
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:
|
if request.tags is not None:
|
||||||
update_data['tags'] = [t.lower() for t in request.tags]
|
update_data['tags'] = [t.lower() for t in request.tags]
|
||||||
|
|
||||||
if request.isNSFW is not None:
|
if request.isNSFW is not None:
|
||||||
update_data['isNSFW'] = request.isNSFW
|
update_data['isNSFW'] = request.isNSFW
|
||||||
if request.isHomo is not None:
|
if request.isHomo is not None:
|
||||||
|
|
@ -338,12 +351,42 @@ async def update_post(
|
||||||
{'$set': update_data}
|
{'$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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"[ModApp] Ошибка обновления поста: {e}")
|
print(f"[ModApp] Ошибка обновления поста: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail="Ошибка сервера"
|
detail="Ошибка сервера"
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ from utils.auth import (
|
||||||
get_current_user, normalize_username, is_moderation_admin
|
get_current_user, normalize_username, is_moderation_admin
|
||||||
)
|
)
|
||||||
from utils.email_service import send_verification_code
|
from utils.email_service import send_verification_code
|
||||||
|
from utils.telegram_widget import validate_telegram_widget
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
router = APIRouter()
|
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}")
|
print(f"[ModerationAuth] 🔍 Запрос авторизации через Telegram виджет: id={request.id}, username={request.username}")
|
||||||
|
|
||||||
try:
|
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
|
# Find user by telegramId
|
||||||
print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}")
|
print(f"[ModerationAuth] Поиск пользователя с telegramId={request.id}")
|
||||||
user = await users_collection().find_one({'telegramId': str(request.id)})
|
user = await users_collection().find_one({'telegramId': str(request.id)})
|
||||||
|
|
|
||||||
|
|
@ -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 smtplib
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -11,6 +11,15 @@ from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
def generate_verification_email(code: str) -> str:
|
||||||
"""Generate HTML email with verification code"""
|
"""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):
|
async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str] = None):
|
||||||
"""Send email via SMTP (Yandex, Gmail, etc.)"""
|
"""Send email via SMTP (Yandex, Gmail, etc.)"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -81,7 +201,7 @@ async def send_email_smtp(to: str, subject: str, html: str, text: Optional[str]
|
||||||
logger.info(f"✅ Email отправлен на {to}")
|
logger.info(f"✅ Email отправлен на {to}")
|
||||||
return {"success": True, "to": to}
|
return {"success": True, "to": to}
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"❌ Ошибка отправки email: {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
|
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):
|
async def send_verification_code(email: str, code: str):
|
||||||
"""Send verification code to email"""
|
"""Send verification code to email"""
|
||||||
subject = "Код подтверждения регистрации - Nakama"
|
subject = "Код подтверждения регистрации - Nakama"
|
||||||
html = generate_verification_email(code)
|
html = generate_verification_email(code)
|
||||||
text = f"Ваш код подтверждения: {code}. Код действителен 15 минут."
|
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):
|
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', 'не указан')}
|
Пользователь: @{user_info.get('username', 'не указан')}
|
||||||
Код действителен 5 минут."""
|
Код действителен 5 минут."""
|
||||||
|
|
||||||
return await send_email_smtp(settings.OWNER_EMAIL, subject, html, text)
|
return await send_email(settings.OWNER_EMAIL, subject, html, text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
@ -3,7 +3,12 @@ WebSocket server for moderation chat
|
||||||
"""
|
"""
|
||||||
import socketio
|
import socketio
|
||||||
import logging
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -16,109 +21,120 @@ sio = socketio.AsyncServer(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Track connected moderators
|
# Track connected moderators
|
||||||
connected_moderators: Dict[str, Set[str]] = {} # {room: {sid1, sid2, ...}}
|
connected_moderators: Dict[str, dict] = {} # {sid: {username, telegramId, isOwner}}
|
||||||
|
|
||||||
|
|
||||||
@sio.event
|
def broadcast_online():
|
||||||
async def connect(sid, environ):
|
"""Broadcast list of online moderators"""
|
||||||
"""Handle client connection"""
|
unique = {}
|
||||||
logger.info(f"[WebSocket] Client connected: {sid}")
|
for data in connected_moderators.values():
|
||||||
|
|
||||||
|
|
||||||
@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')
|
|
||||||
username = data.get('username')
|
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
|
return
|
||||||
|
|
||||||
room = 'moderation_chat'
|
# Check if user is owner
|
||||||
await sio.enter_room(sid, room)
|
owner_usernames = settings.OWNER_USERNAMES_LIST
|
||||||
|
is_owner = username.lower() in [u.lower() for u in owner_usernames]
|
||||||
|
|
||||||
if room not in connected_moderators:
|
# Check if user is moderation admin
|
||||||
connected_moderators[room] = set()
|
admin = await moderation_admins_collection().find_one({
|
||||||
connected_moderators[room].add(sid)
|
'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
|
# Store connection data
|
||||||
await sio.emit('user_joined', {
|
connected_moderators[sid] = {
|
||||||
'userId': user_id,
|
|
||||||
'username': username,
|
'username': username,
|
||||||
'sid': sid
|
'telegramId': telegram_id,
|
||||||
}, room=room, skip_sid=sid)
|
'isOwner': is_owner
|
||||||
|
|
||||||
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')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Broadcast to all in room
|
logger.info(f"[WebSocket] Auth success: {username} (owner: {is_owner}, admin: {is_admin})")
|
||||||
await sio.emit('moderation_message', message_data, room=room)
|
await sio.emit('ready', namespace='/mod-chat', room=sid)
|
||||||
|
broadcast_online()
|
||||||
logger.info(f"[WebSocket] Message from {data.get('username')}: {data.get('message')[:50]}...")
|
|
||||||
|
|
||||||
except Exception as e:
|
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
|
@mod_chat.on('message')
|
||||||
async def typing(sid, data):
|
async def on_message(sid, data):
|
||||||
"""Handle typing indicator"""
|
"""Handle moderation chat message"""
|
||||||
try:
|
try:
|
||||||
room = 'moderation_chat'
|
if sid not in connected_moderators:
|
||||||
await sio.emit('typing', {
|
logger.warning(f"[WebSocket] Message from unauthorized client: {sid}")
|
||||||
'userId': data.get('userId'),
|
return
|
||||||
'username': data.get('username'),
|
|
||||||
'isTyping': data.get('isTyping', True)
|
user_data = connected_moderators[sid]
|
||||||
}, room=room, skip_sid=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:
|
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():
|
def get_socketio_app():
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue