diff --git a/.env.example b/.env.example index a118964..8ec42ff 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,14 @@ # ============= БАЗА ДАННЫХ ============= -DATABASE_URL=postgresql+asyncpg://news_admin:news_secure_2026@192.168.1.11:5432/news +DATABASE_URL=postgresql+asyncpg://db_user:db_password@db-host:5432/news # ============= MINIO / ХРАНИЛИЩЕ ============= -MINIO_ENDPOINT=minio:9000 -MINIO_ACCESS_KEY=minioadmin -MINIO_SECRET_KEY=minioadmin +MINIO_ENDPOINT=minio-host:9000 +MINIO_ACCESS_KEY=your-access-key +MINIO_SECRET_KEY=your-secret-key MINIO_BUCKET=news-media -MINIO_PUBLIC_URL=http://localhost:9000 +# MINIO_PUBLIC_URL — публичный URL через который браузер загружает медиа. +# nginx проксирует /media/ → MinIO bucket, поэтому ставим домен сайта + /media (без имени бакета) +MINIO_PUBLIC_URL=https://your-domain.ru/media # ============= JWT / БЕЗОПАСНОСТЬ ============= JWT_SECRET=your-super-secret-key-change-in-production diff --git a/api/route/admin_system.py b/api/route/admin_system.py index 14562f2..11c3e9e 100644 --- a/api/route/admin_system.py +++ b/api/route/admin_system.py @@ -113,3 +113,60 @@ async def system_status(db: AsyncSession = Depends(get_db), _: str = Depends(req "redis": redis, "minio": minio, } + + +@router.post("/repair-media-urls") +async def repair_media_urls(db: AsyncSession = Depends(get_db), _: str = Depends(require_admin)): + """Заменяет старые MinIO URL на актуальный MINIO_PUBLIC_URL во всех статьях и медиафайлах. + + Системный nginx проксирует /media/ → MinIO bucket /news-media/, поэтому bucket исключается из URL. + Старый формат: http://host:port/news-media/object_path + Новый формат: MINIO_PUBLIC_URL/object_path (без bucket) + + Запускать один раз после изменения MINIO_PUBLIC_URL в .env. + """ + new_base = MINIO_PUBLIC_URL.rstrip("/") + + # cover_url: http://host/news-media/path → new_base/path + r_cover = await db.execute(text(r""" + UPDATE articles + SET cover_url = :new_base || '/' || + regexp_replace(cover_url, '^https?://[^/]+(:[0-9]+)?/[^/]+/', '') + WHERE cover_url IS NOT NULL + AND cover_url LIKE '%/news-media/%' + AND cover_url NOT LIKE :prefix + RETURNING id + """), {"new_base": new_base, "prefix": new_base + "%"}) + + # HTML-контент: src="http://host/news-media/path" → src="new_base/path" + r_content = await db.execute(text(r""" + UPDATE articles + SET content = regexp_replace( + content, + 'https?://[^/]+(:[0-9]+)?/[^/]+/([^"'' ]+)', + :new_base || '/\2', + 'g' + ) + WHERE content LIKE '%/news-media/%' + AND content NOT LIKE :prefix + RETURNING id + """), {"new_base": new_base, "prefix": "%" + new_base + "%"}) + + # media.url + r_media = await db.execute(text(r""" + UPDATE media + SET url = :new_base || '/' || + regexp_replace(url, '^https?://[^/]+(:[0-9]+)?/[^/]+/', '') + WHERE url LIKE '%/news-media/%' + AND url NOT LIKE :prefix + RETURNING id + """), {"new_base": new_base, "prefix": new_base + "%"}) + + await db.commit() + + return { + "new_base": new_base, + "cover_url_fixed": r_cover.rowcount, + "content_fixed": r_content.rowcount, + "media_fixed": r_media.rowcount, + } diff --git a/api/vk_parser.py b/api/vk_parser.py index c6654aa..9aa093a 100644 --- a/api/vk_parser.py +++ b/api/vk_parser.py @@ -162,7 +162,8 @@ async def _upload_from_url(url: str, db: AsyncSession | None = None, mc = _minio_client() _ensure_bucket(mc) mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type=ct) - public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" + # Системный nginx проксирует /media/ → MinIO bucket, поэтому bucket не дублируем в URL + public_url = f"{MINIO_PUBLIC_URL}/{object_name}" if db is not None: from bd.models import Media db.add(Media( @@ -302,7 +303,7 @@ async def _download_vk_video(owner_id: int, video_id: int, mc = _minio_client() _ensure_bucket(mc) mc.put_object(MINIO_BUCKET, object_name, io.BytesIO(data), len(data), content_type="video/mp4") - public_url = f"{MINIO_PUBLIC_URL}/{MINIO_BUCKET}/{object_name}" + public_url = f"{MINIO_PUBLIC_URL}/{object_name}" print(f"[VK] Видео загружено в MinIO: {object_name} ({total/1024/1024:.1f}MB)") if db is not None: from bd.models import Media diff --git a/web-react/nginx.conf b/web-react/nginx.conf index 1823dff..f98b1e3 100644 --- a/web-react/nginx.conf +++ b/web-react/nginx.conf @@ -23,7 +23,6 @@ server { proxy_pass http://api:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - # Отключаем буферизацию для быстрого ответа proxy_buffering off; } diff --git a/web-react/src/api/index.js b/web-react/src/api/index.js index c104682..cd6ec8d 100644 --- a/web-react/src/api/index.js +++ b/web-react/src/api/index.js @@ -114,3 +114,6 @@ export const adminVkImportHistory = (token, sourceId, sinceDays = 365) => export const adminSystemStatus = token => req('GET', 'admin/system/status', token) + +export const adminRepairMediaUrls = token => + req('POST', 'admin/system/repair-media-urls', token) diff --git a/web-react/src/pages/admin/SystemStatus.jsx b/web-react/src/pages/admin/SystemStatus.jsx index 069f98d..3e80307 100644 --- a/web-react/src/pages/admin/SystemStatus.jsx +++ b/web-react/src/pages/admin/SystemStatus.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { useAuth } from '../../hooks/useAuth' -import { adminSystemStatus } from '../../api/index' +import { adminSystemStatus, adminRepairMediaUrls } from '../../api/index' import AdminLayout from '../../components/AdminLayout' import Loader from '../../components/Loader' @@ -90,6 +90,7 @@ export default function SystemStatus() { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [lastUpdated, setLastUpdated] = useState(null) + const [repairState, setRepairState] = useState(null) // null | 'running' | result-object const load = useCallback(async () => { setLoading(true) @@ -106,6 +107,17 @@ export default function SystemStatus() { useEffect(() => { load() }, [load]) + const runRepair = async () => { + if (!window.confirm('Обновить все MinIO URL в базе данных?\nЭто перезапишет cover_url и img-теги в контенте статей.')) return + setRepairState('running') + try { + const result = await adminRepairMediaUrls(token) + setRepairState(result) + } catch (e) { + setRepairState({ error: String(e) }) + } + } + const allOk = data && Object.values(data).every(s => s.ok) return ( @@ -127,11 +139,34 @@ export default function SystemStatus() { )} - +