import os import io import uuid from datetime import datetime from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from minio import Minio from minio.error import S3Error from slugify import slugify from bd.database import get_db from bd.models import Media, Article from route.deps import require_admin router = APIRouter() MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "minioadmin") MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "minioadmin") MINIO_BUCKET = os.getenv("MINIO_BUCKET", "news-media") MINIO_PUBLIC_URL = os.getenv("MINIO_PUBLIC_URL", "http://localhost:9000") ALLOWED_IMAGE = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"} ALLOWED_VIDEO = {"video/mp4", "video/webm", "video/ogg", "video/quicktime"} MAX_SIZE_BYTES = 100 * 1024 * 1024 # 100 MB def get_minio() -> Minio: return Minio(MINIO_ENDPOINT, access_key=MINIO_ACCESS_KEY, secret_key=MINIO_SECRET_KEY, secure=False) def ensure_bucket(client: Minio): try: if not client.bucket_exists(MINIO_BUCKET): client.make_bucket(MINIO_BUCKET) policy = f"""{{ "Version":"2012-10-17", "Statement":[{{ "Effect":"Allow", "Principal":"*", "Action":["s3:GetObject"], "Resource":["arn:aws:s3:::{MINIO_BUCKET}/*"] }}] }}""" client.set_bucket_policy(MINIO_BUCKET, policy) except S3Error: pass @router.post("/upload") async def upload_media( file: UploadFile = File(...), article_id: str | None = Query(None), db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): content_type = file.content_type or "" if content_type not in ALLOWED_IMAGE and content_type not in ALLOWED_VIDEO: raise HTTPException(status_code=400, detail=f"Тип файла не поддерживается: {content_type}") data = await file.read() if len(data) > MAX_SIZE_BYTES: raise HTTPException(status_code=400, detail="Файл слишком большой (макс. 100 МБ)") media_type = "image" if content_type in ALLOWED_IMAGE else "video" ext = (file.filename or "file").rsplit(".", 1)[-1].lower() # Определяем путь в MinIO art_uuid = None if article_id: try: art_uuid = uuid.UUID(article_id) # Проверяем существование статьи art = (await db.execute(select(Article).where(Article.id == art_uuid))).scalar_one_or_none() if not art: art_uuid = None except ValueError: art_uuid = None if art_uuid: folder = "images" if media_type == "image" else "videos" art_slug = slugify(art.title) if art and art.title else str(art_uuid)[:8] art_dir = f"{art_slug}-{str(art_uuid)[:8]}" base_name = f"{art_slug}-{uuid.uuid4().hex[:6]}.{ext}" object_name = f"articles/{art_dir}/{folder}/{base_name}" else: object_name = f"{media_type}s/{uuid.uuid4().hex}.{ext}" client = get_minio() ensure_bucket(client) client.put_object( MINIO_BUCKET, object_name, io.BytesIO(data), length=len(data), content_type=content_type, ) public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" media = Media( filename=object_name.split("/")[-1], url=public_url, media_type=media_type, size_bytes=len(data), article_id=art_uuid, ) db.add(media) await db.commit() await db.refresh(media) return { "id": str(media.id), "url": public_url, "filename": media.filename, "media_type": media_type, "size_bytes": media.size_bytes, } @router.get("") async def list_media( offset: int = Query(0, ge=0), limit: int = Query(40, ge=1, le=200), media_type: str | None = Query(None), q: str | None = Query(None, max_length=200), db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): from sqlalchemy import func from sqlalchemy.orm import outerjoin base = select(Media) if media_type: base = base.where(Media.media_type == media_type) if q: base = base.where(Media.filename.ilike(f"%{q}%")) total = (await db.execute(select(func.count()).select_from(base.subquery()))).scalar_one() items = (await db.execute(base.order_by(Media.created_at.desc()).offset(offset).limit(limit))).scalars().all() # Fetch article titles for media that have an article_id art_ids = {m.article_id for m in items if m.article_id} art_titles: dict = {} if art_ids: arts = (await db.execute(select(Article.id, Article.title).where(Article.id.in_(art_ids)))).all() art_titles = {a.id: a.title for a in arts} return { "total": total, "items": [ { "id": str(m.id), "url": m.url, "filename": m.filename, "media_type": m.media_type, "size_bytes": m.size_bytes, "created_at": m.created_at.isoformat(), "article_id": str(m.article_id) if m.article_id else None, "article_title": art_titles.get(m.article_id) if m.article_id else None, } for m in items ], } @router.delete("/{media_id}") async def delete_media( media_id: str, db: AsyncSession = Depends(get_db), _: str = Depends(require_admin), ): import uuid as uuid_lib media = (await db.execute(select(Media).where(Media.id == uuid_lib.UUID(media_id)))).scalar_one_or_none() if not media: raise HTTPException(status_code=404, detail="Not found") object_name = media.url.split(f"/{MINIO_BUCKET}/", 1)[-1] try: client = get_minio() client.remove_object(MINIO_BUCKET, object_name) except Exception: pass await db.delete(media) await db.commit() return {"ok": True}