187 lines
6.0 KiB
Python
187 lines
6.0 KiB
Python
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}
|