Newer
Older
vmk-360_data_mcp / src / vmk_data_mcp / images.py
"""Работа с изображениями объявлений: хостинг, URL, batch-загрузка метаданных."""

from pathlib import Path

from vmk_data_mcp.config import settings
from vmk_data_mcp.db import fetch
from vmk_data_mcp.models import ListingImage


def resolve_image_path(local_path: str) -> Path | None:
    """Разрешает относительный путь из БД в абсолютный путь на диске.

    Выполняет проверку безопасности: итоговый путь должен находиться внутри
    IMAGE_STORAGE_PATH, чтобы предотвратить path traversal (/images/../../../etc/passwd).
    """
    if not local_path:
        return None

    storage_root = Path(settings.image_storage_path).resolve()
    stored = Path(local_path)

    absolute = (
        stored.resolve()
        if stored.is_absolute()
        else (storage_root / stored).resolve()
    )

    # Проверка: путь должен быть внутри корня хранилища
    try:
        absolute.relative_to(storage_root)
    except ValueError:
        return None

    return absolute


def build_image_url(local_path: str) -> str:
    """Строит публичный URL для изображения по относительному пути из БД."""
    base = settings.image_base_url.strip()
    if base:
        return f"{base.rstrip('/')}/{local_path.lstrip('/')}"
    return f"/images/{local_path.lstrip('/')}"


def _row_to_listing_image(row: dict) -> ListingImage:
    """Конвертирует строку property_images в ListingImage."""
    local_path = row.get("local_path") or ""
    return ListingImage(
        url=build_image_url(local_path),
        width=row.get("width"),
        height=row.get("height"),
        file_size=row.get("file_size"),
        ai_description=row.get("ai_description"),
        order_index=row.get("order_index"),
    )


async def fetch_images_for_listing(listing_id: int) -> list[ListingImage]:
    """Возвращает все изображения для одного объявления, отсортированные по order_index."""
    sql = """
        SELECT local_path, width, height, file_size, ai_description, order_index
        FROM property_images
        WHERE property_id = $1
          AND local_path IS NOT NULL
          AND download_status = 'downloaded'
        ORDER BY order_index NULLS LAST, id
    """
    rows = await fetch(sql, listing_id)
    return [_row_to_listing_image(r) for r in rows]


async def fetch_images_for_listings(
    listing_ids: list[int], max_per_listing: int = 0
) -> dict[int, list[ListingImage]]:
    """Batch-загрузка изображений для множества объявлений.

    Args:
        listing_ids: список ID объявлений.
        max_per_listing: максимальное количество фото на одно объявление.
            0 означает "без ограничения".

    Returns:
        Словарь {property_id: [ListingImage, ...]}.
    """
    if not listing_ids:
        return {}

    sql = """
        SELECT property_id, local_path, width, height, file_size, ai_description, order_index
        FROM property_images
        WHERE property_id = ANY($1::int[])
          AND local_path IS NOT NULL
          AND download_status = 'downloaded'
        ORDER BY property_id, order_index NULLS LAST, id
    """
    rows = await fetch(sql, listing_ids)

    grouped: dict[int, list[ListingImage]] = {pid: [] for pid in listing_ids}
    for row in rows:
        pid = row["property_id"]
        if max_per_listing and len(grouped[pid]) >= max_per_listing:
            continue
        grouped[pid].append(_row_to_listing_image(row))

    return grouped