1f0fef114b
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding via structlog contextvars (aiogram) and http_method/http_path (FastAPI) - Use bound_contextvars() context manager instead of clear_contextvars() to safely restore previous state instead of wiping all context - Register ContextVarsMiddleware as outermost middleware (before GlobalError) so all error logs include user context - Replace structlog.get_logger() with structlog.get_logger(__name__) across 270 calls in 265 files for meaningful logger names - Switch wrapper_class from BoundLogger to make_filtering_bound_logger() for pre-processor level filtering (performance optimization) - Migrate 1411 %-style positional arg logger calls to structlog kwargs style across 161 files via AST script - Migrate log_rotation_service.py from stdlib logging to structlog - Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking to Telegram notifications and general log files - Fix LoggingMiddleware: add from_user null-safety for channel posts, switch time.time() to time.monotonic() for duration measurement - Remove duplicate logger assignments in purchase.py, config.py, inline.py, and admin/payments.py
202 lines
6.3 KiB
Python
202 lines
6.3 KiB
Python
"""Media upload/download routes for cabinet tickets."""
|
|
|
|
import mimetypes
|
|
|
|
import structlog
|
|
from aiogram import Bot
|
|
from aiogram.client.default import DefaultBotProperties
|
|
from aiogram.enums import ParseMode
|
|
from aiogram.types import BufferedInputFile
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status
|
|
from pydantic import BaseModel
|
|
|
|
from app.config import settings
|
|
from app.database.models import User
|
|
|
|
from ..dependencies import get_current_cabinet_user
|
|
|
|
|
|
logger = structlog.get_logger(__name__)
|
|
|
|
router = APIRouter(prefix='/media', tags=['Cabinet Media'])
|
|
|
|
ALLOWED_MEDIA_TYPES = {'photo', 'video', 'document'}
|
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
|
|
|
|
|
class MediaUploadResponse(BaseModel):
|
|
"""Response after successful media upload."""
|
|
|
|
media_type: str
|
|
file_id: str
|
|
file_unique_id: str | None = None
|
|
media_url: str
|
|
|
|
|
|
def _resolve_target_chat_id() -> int:
|
|
"""Get chat ID for uploading files (notification channel or first admin)."""
|
|
chat_id = settings.get_admin_notifications_chat_id()
|
|
if chat_id is not None:
|
|
return chat_id
|
|
|
|
admin_ids = settings.get_admin_ids()
|
|
if admin_ids:
|
|
return admin_ids[0]
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='No chat configured for file uploads',
|
|
)
|
|
|
|
|
|
def _build_media_url(request: Request, file_id: str) -> str:
|
|
"""Build URL for downloading media."""
|
|
return str(request.url_for('cabinet_download_media', file_id=file_id))
|
|
|
|
|
|
@router.post('/upload', response_model=MediaUploadResponse, status_code=status.HTTP_201_CREATED)
|
|
async def upload_media(
|
|
request: Request,
|
|
user: User = Depends(get_current_cabinet_user),
|
|
file: UploadFile = File(...),
|
|
media_type: str = Form('photo', description='File type: photo, video, or document'),
|
|
):
|
|
"""
|
|
Upload media file for use in ticket messages.
|
|
Returns file_id that can be used when creating ticket or adding message.
|
|
"""
|
|
media_type_normalized = (media_type or '').strip().lower()
|
|
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f'Unsupported media type. Allowed: {", ".join(ALLOWED_MEDIA_TYPES)}',
|
|
)
|
|
|
|
# Read and validate file
|
|
file_bytes = await file.read()
|
|
if not file_bytes:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='File is empty',
|
|
)
|
|
|
|
if len(file_bytes) > MAX_FILE_SIZE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f'File too large. Maximum size: {MAX_FILE_SIZE // 1024 // 1024}MB',
|
|
)
|
|
|
|
# Validate content type for photos
|
|
if media_type_normalized == 'photo':
|
|
allowed_image_types = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
|
|
if file.content_type and file.content_type not in allowed_image_types:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail='Invalid image type. Allowed: JPEG, PNG, GIF, WebP',
|
|
)
|
|
|
|
target_chat_id = _resolve_target_chat_id()
|
|
upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload')
|
|
|
|
bot = Bot(
|
|
token=settings.BOT_TOKEN,
|
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
|
)
|
|
|
|
try:
|
|
if media_type_normalized == 'photo':
|
|
message = await bot.send_photo(
|
|
chat_id=target_chat_id,
|
|
photo=upload,
|
|
)
|
|
media = message.photo[-1]
|
|
elif media_type_normalized == 'video':
|
|
message = await bot.send_video(
|
|
chat_id=target_chat_id,
|
|
video=upload,
|
|
)
|
|
media = message.video
|
|
else:
|
|
message = await bot.send_document(
|
|
chat_id=target_chat_id,
|
|
document=upload,
|
|
)
|
|
media = message.document
|
|
|
|
media_url = _build_media_url(request, media.file_id)
|
|
|
|
logger.info(
|
|
'User uploaded',
|
|
telegram_id=user.telegram_id,
|
|
media_type_normalized=media_type_normalized,
|
|
file_id=media.file_id,
|
|
)
|
|
|
|
return MediaUploadResponse(
|
|
media_type=media_type_normalized,
|
|
file_id=media.file_id,
|
|
file_unique_id=getattr(media, 'file_unique_id', None),
|
|
media_url=media_url,
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as error:
|
|
logger.error('Failed to upload media for user', telegram_id=user.telegram_id, error=error)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to upload media',
|
|
) from error
|
|
finally:
|
|
await bot.session.close()
|
|
|
|
|
|
@router.get('/{file_id}', name='cabinet_download_media')
|
|
async def download_media(
|
|
file_id: str,
|
|
) -> Response:
|
|
"""
|
|
Download media file by file_id.
|
|
Used to display images/documents in ticket messages.
|
|
"""
|
|
bot = Bot(
|
|
token=settings.BOT_TOKEN,
|
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
|
)
|
|
|
|
try:
|
|
file = await bot.get_file(file_id)
|
|
if not file.file_path:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail='Media file not found',
|
|
)
|
|
|
|
buffer = await bot.download_file(file.file_path)
|
|
|
|
if hasattr(buffer, 'seek'):
|
|
buffer.seek(0)
|
|
|
|
content = buffer.read() if hasattr(buffer, 'read') else bytes(buffer)
|
|
filename = file.file_path.split('/')[-1]
|
|
|
|
media_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
|
|
|
|
return Response(
|
|
content=content,
|
|
media_type=media_type,
|
|
headers={
|
|
'Content-Disposition': f'inline; filename={filename}',
|
|
'Cache-Control': 'public, max-age=86400', # Cache for 24 hours
|
|
},
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as error:
|
|
logger.error('Failed to download media', file_id=file_id, error=error)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail='Failed to download media',
|
|
) from error
|
|
finally:
|
|
await bot.session.close()
|