"""Работа с изображениями объявлений: хостинг, 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