"""Cleanup helpers for images attached to chat sessions.""" from __future__ import annotations import json import logging import os import re from pathlib import Path from src.constants import GENERATED_IMAGES_DIR logger = logging.getLogger(__name__) def _database_models(): """Import DB models at call time so early import stubs cannot stick here.""" from core.database import ChatMessage, GalleryImage, SessionLocal return ChatMessage, GalleryImage, SessionLocal def _generated_image_path_for_cleanup(filename: str) -> Path | None: if not isinstance(filename, str) or not filename: return None name = Path(filename).name if name != filename or name in {".", ".."}: return None root = Path(GENERATED_IMAGES_DIR).resolve() path = (root / name).resolve() try: if os.path.commonpath([str(root), str(path)]) != str(root): return None except Exception: return None return path def _image_filename_from_url(url: str) -> str: if not isinstance(url, str) or not url: return "" match = re.search(r"/api/generated-image/([^?#/]+)", url) return match.group(1) if match else "" def session_image_refs(db, session_id: str) -> tuple[set[str], set[str]]: """Return gallery image ids and generated-image filenames referenced by a chat.""" ChatMessage, GalleryImage, _ = _database_models() image_ids: set[str] = set() filenames: set[str] = set() rows = db.query(GalleryImage).filter(GalleryImage.session_id == session_id).all() for img in rows: if img.id: image_ids.add(str(img.id)) if img.filename: filenames.add(str(img.filename)) messages = db.query(ChatMessage.meta_data).filter(ChatMessage.session_id == session_id).all() for row in messages: raw = getattr(row, "meta_data", None) if not raw: continue try: meta = json.loads(raw) except Exception: continue events = meta.get("tool_events") if isinstance(meta, dict) else None if not isinstance(events, list): continue for ev in events: if not isinstance(ev, dict): continue image_id = ev.get("image_id") if image_id: image_ids.add(str(image_id)) filename = _image_filename_from_url(ev.get("image_url") or ev.get("url") or "") if filename: filenames.add(filename) return image_ids, filenames def cleanup_session_images(session_id: str, db=None) -> int: """Soft-delete Gallery rows and unlink generated files owned by a chat.""" _, GalleryImage, SessionLocal = _database_models() owns_db = db is None db = db or SessionLocal() try: image_ids, filenames = session_image_refs(db, session_id) query = db.query(GalleryImage).filter(GalleryImage.session_id == session_id) if image_ids or filenames: from sqlalchemy import or_ clauses = [GalleryImage.session_id == session_id] if image_ids: clauses.append(GalleryImage.id.in_(list(image_ids))) if filenames: clauses.append(GalleryImage.filename.in_(list(filenames))) query = db.query(GalleryImage).filter(or_(*clauses)) images = query.all() removed = 0 for img in images: img.is_active = False if img.filename: path = _generated_image_path_for_cleanup(img.filename) if path and path.exists(): try: path.unlink() except Exception as exc: logger.warning( "Could not remove generated image %s for deleted session %s: %s", img.filename, session_id, exc, ) removed += 1 if owns_db and images: db.commit() return removed except Exception as exc: if owns_db: db.rollback() logger.warning("Failed to clean images for deleted session %s: %s", session_id, exc) return 0 finally: if owns_db: db.close()