mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
Merge pull request #1986 from BEDOLAGA-DEV/bedolaga/add-/upload-endpoint-for-file-uploads-lowg6q
Proxy media downloads without exposing bot token
This commit is contained in:
@@ -13,6 +13,7 @@ from .routes import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
miniapp,
|
||||
polls,
|
||||
promocodes,
|
||||
@@ -96,6 +97,10 @@ OPENAPI_TAGS = [
|
||||
"данных между ботом и панелью."
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "media",
|
||||
"description": "Загрузка файлов в Telegram и получение ссылок на медиа.",
|
||||
},
|
||||
{
|
||||
"name": "miniapp",
|
||||
"description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.",
|
||||
@@ -158,6 +163,7 @@ def create_web_api_app() -> FastAPI:
|
||||
app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"])
|
||||
app.include_router(tokens.router, prefix="/tokens", tags=["auth"])
|
||||
app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"])
|
||||
app.include_router(media.router, tags=["media"])
|
||||
app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"])
|
||||
app.include_router(polls.router, prefix="/polls", tags=["polls"])
|
||||
app.include_router(logs.router, prefix="/logs", tags=["logs"])
|
||||
|
||||
@@ -2,6 +2,7 @@ from . import (
|
||||
config,
|
||||
health,
|
||||
main_menu_buttons,
|
||||
media,
|
||||
miniapp,
|
||||
polls,
|
||||
promo_offers,
|
||||
@@ -22,6 +23,7 @@ __all__ = [
|
||||
"config",
|
||||
"health",
|
||||
"main_menu_buttons",
|
||||
"media",
|
||||
"miniapp",
|
||||
"polls",
|
||||
"promo_offers",
|
||||
|
||||
158
app/webapi/routes/media.py
Normal file
158
app/webapi/routes/media.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.enums import ParseMode
|
||||
from aiogram.types import BufferedInputFile
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
File,
|
||||
Form,
|
||||
HTTPException,
|
||||
Request,
|
||||
Response,
|
||||
Security,
|
||||
UploadFile,
|
||||
status,
|
||||
)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
from ..dependencies import require_api_token
|
||||
from ..schemas.media import MediaUploadResponse
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_MEDIA_TYPES = {"photo", "video", "document"}
|
||||
|
||||
|
||||
def _resolve_target_chat_id() -> int:
|
||||
"""Выбирает чат для загрузки файлов (канал уведомлений или первый админ)."""
|
||||
|
||||
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.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
"Не настроен чат для загрузки файлов (ADMIN_NOTIFICATIONS_CHAT_ID или ADMIN_IDS)",
|
||||
)
|
||||
|
||||
|
||||
def _build_media_url(request: Request, file_id: str) -> str:
|
||||
return str(request.url_for("download_media", file_id=file_id))
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MediaUploadResponse, tags=["media"], status_code=status.HTTP_201_CREATED)
|
||||
async def upload_media(
|
||||
request: Request,
|
||||
_: Any = Security(require_api_token),
|
||||
file: UploadFile = File(...),
|
||||
media_type: str = Form("document", description="Тип файла: photo, video или document"),
|
||||
caption: str | None = Form(None, description="Необязательная подпись к файлу"),
|
||||
) -> MediaUploadResponse:
|
||||
media_type_normalized = (media_type or "").strip().lower()
|
||||
if media_type_normalized not in ALLOWED_MEDIA_TYPES:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Unsupported media type")
|
||||
|
||||
file_bytes = await file.read()
|
||||
if not file_bytes:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "File is empty")
|
||||
|
||||
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,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.photo[-1]
|
||||
elif media_type_normalized == "video":
|
||||
message = await bot.send_video(
|
||||
chat_id=target_chat_id,
|
||||
video=upload,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.video
|
||||
else:
|
||||
message = await bot.send_document(
|
||||
chat_id=target_chat_id,
|
||||
document=upload,
|
||||
caption=caption,
|
||||
)
|
||||
media = message.document
|
||||
|
||||
media_url = _build_media_url(request, 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: %s", error)
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to upload media") from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
|
||||
@router.get("/media/{file_id}", name="download_media", tags=["media"])
|
||||
async def download_media(
|
||||
file_id: str,
|
||||
_: Any = Security(require_api_token),
|
||||
) -> Response:
|
||||
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.HTTP_404_NOT_FOUND, "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}",
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as error: # pragma: no cover - неожиданные ошибки загрузки файла
|
||||
logger.error("Failed to download media %s: %s", file_id, error)
|
||||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, "Failed to download media") from error
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
@@ -3,7 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Security, status
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from aiogram import Bot
|
||||
@@ -27,6 +29,7 @@ from ..schemas.tickets import (
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_message(message: TicketMessage) -> TicketMessageResponse:
|
||||
@@ -248,6 +251,7 @@ async def reply_to_ticket(
|
||||
async def get_ticket_message_media(
|
||||
ticket_id: int,
|
||||
message_id: int,
|
||||
request: Request,
|
||||
_: Any = Security(require_api_token),
|
||||
db: AsyncSession = Depends(get_db_session),
|
||||
) -> TicketMediaResponse:
|
||||
@@ -262,10 +266,25 @@ async def get_ticket_message_media(
|
||||
if not message.has_media or not message.media_file_id or not message.media_type:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Media not found for this message")
|
||||
|
||||
media_url: Optional[str] = None
|
||||
bot = Bot(
|
||||
token=settings.BOT_TOKEN,
|
||||
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||
)
|
||||
try:
|
||||
file = await bot.get_file(message.media_file_id)
|
||||
if file.file_path:
|
||||
media_url = str(request.url_for("download_media", file_id=message.media_file_id))
|
||||
except Exception as error:
|
||||
logger.warning("Failed to resolve media URL for ticket %s message %s: %s", ticket_id, message_id, error)
|
||||
finally:
|
||||
await bot.session.close()
|
||||
|
||||
return TicketMediaResponse(
|
||||
id=message.id,
|
||||
ticket_id=ticket.id,
|
||||
media_type=message.media_type,
|
||||
media_file_id=message.media_file_id,
|
||||
media_caption=message.media_caption,
|
||||
media_url=media_url,
|
||||
)
|
||||
|
||||
16
app/webapi/schemas/media.py
Normal file
16
app/webapi/schemas/media.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MediaUploadResponse(BaseModel):
|
||||
media_type: str = Field(description="Тип загруженного файла (photo, video, document)")
|
||||
file_id: str = Field(description="Telegram file_id загруженного файла")
|
||||
file_unique_id: Optional[str] = Field(
|
||||
default=None, description="Уникальный идентификатор файла"
|
||||
)
|
||||
media_url: Optional[str] = Field(
|
||||
default=None, description="Прямая ссылка на файл для предпросмотра"
|
||||
)
|
||||
@@ -67,3 +67,4 @@ class TicketMediaResponse(BaseModel):
|
||||
media_type: str
|
||||
media_file_id: str
|
||||
media_caption: Optional[str] = None
|
||||
media_url: Optional[str] = None
|
||||
|
||||
Reference in New Issue
Block a user