diff --git a/routes/gallery/gallery_routes.py b/routes/gallery/gallery_routes.py index 8094afa9f..406b5167a 100644 --- a/routes/gallery/gallery_routes.py +++ b/routes/gallery/gallery_routes.py @@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint from core.database import Session as DbSession from src.auth_helpers import get_current_user, owner_filter, require_privilege +from src.session_search import escape_like_pattern from src.upload_limits import ( read_upload_limited, GALLERY_UPLOAD_MAX_BYTES, @@ -1184,15 +1185,18 @@ def setup_gallery_routes() -> APIRouter: ) q = _owner_filter(q, user) - # Search filter (prompt + tags + ai_tags) + # Per-term AND search: a single `%foo bar%` LIKE only matched the + # exact phrase, so "red car" missed "a car that is red". + # LIKE wildcards are escaped per token. if search: - term = f"%{search}%" from sqlalchemy import or_ - q = q.filter(or_( - GalleryImage.prompt.ilike(term), - GalleryImage.tags.ilike(term), - GalleryImage.ai_tags.ilike(term), - )) + for tok in search.split(): + term = f"%{escape_like_pattern(tok)}%" + q = q.filter(or_( + GalleryImage.prompt.ilike(term, escape="\\"), + GalleryImage.tags.ilike(term, escape="\\"), + GalleryImage.ai_tags.ilike(term, escape="\\"), + )) # Tag filter. The UI stacks multiple tag pills by passing them # comma-separated — each tag adds a separate AND-filter so the @@ -1203,9 +1207,10 @@ def setup_gallery_routes() -> APIRouter: for one in (t.strip() for t in tag.split(",")): if not one: continue + safe_tag = f"%{escape_like_pattern(one)}%" q = q.filter(_or( - GalleryImage.tags.ilike(f"%{one}%"), - GalleryImage.ai_tags.ilike(f"%{one}%"), + GalleryImage.tags.ilike(safe_tag, escape="\\"), + GalleryImage.ai_tags.ilike(safe_tag, escape="\\"), )) # Model filter diff --git a/src/session_search.py b/src/session_search.py index 143ad0832..b8c629845 100644 --- a/src/session_search.py +++ b/src/session_search.py @@ -57,10 +57,19 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]: } -def _escape_like(value: str) -> str: +def escape_like_pattern(value: str) -> str: + """Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself. + + Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")`` + patterns from user input. Must escape backslash first. + """ return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") +def _escape_like(value: str) -> str: + return escape_like_pattern(value) + + def _snippet(content: str, query: str, radius: int = 60) -> str: content = content or "" query = query or "" diff --git a/tests/test_gallery_search_multiword.py b/tests/test_gallery_search_multiword.py new file mode 100644 index 000000000..b851e6f36 --- /dev/null +++ b/tests/test_gallery_search_multiword.py @@ -0,0 +1,135 @@ +"""Regression for #2123 (gallery part): library search must match multi-word +queries in any order and treat % / _ literally. + +Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so +"red car" missed "a car that is red", and a literal "%" matched every row. +""" + +import uuid + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import NullPool + +import core.database as cdb +from core.database import GalleryImage +import routes.gallery_routes as gallery_routes + + +def _client_with_gallery(monkeypatch, tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path / 'gallery_search.db'}", + connect_args={"check_same_thread": False}, + poolclass=NullPool, + ) + cdb.Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory) + + db = session_factory() + try: + db.add_all( + [ + GalleryImage( + id="img-red-car", + filename=f"{uuid.uuid4().hex}.png", + prompt="a car that is red", + model="model-a", + tags="", + ai_tags="", + is_active=True, + file_size=10, + ), + GalleryImage( + id="img-boat", + filename=f"{uuid.uuid4().hex}.png", + prompt="blue boat on the lake", + model="model-a", + tags="", + ai_tags="", + is_active=True, + file_size=10, + ), + GalleryImage( + id="img-percent", + filename=f"{uuid.uuid4().hex}.png", + prompt="100% cotton shirt", + model="model-a", + tags="sale_2024", + ai_tags="", + is_active=True, + file_size=10, + ), + GalleryImage( + id="img-backslash", + filename=f"{uuid.uuid4().hex}.png", + prompt=r"C:\photos\sunset", + model="model-a", + tags="", + ai_tags="", + is_active=True, + file_size=10, + ), + ] + ) + db.commit() + finally: + db.close() + + app = FastAPI() + app.include_router(gallery_routes.setup_gallery_routes()) + return TestClient(app) + + +def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path): + monkeypatch.setenv("AUTH_ENABLED", "false") + client = _client_with_gallery(monkeypatch, tmp_path) + + body = client.get("/api/gallery/library", params={"search": "red car"}).json() + ids = {item["id"] for item in body["items"]} + assert "img-red-car" in ids + assert "img-boat" not in ids + + +def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path): + monkeypatch.setenv("AUTH_ENABLED", "false") + client = _client_with_gallery(monkeypatch, tmp_path) + + body = client.get("/api/gallery/library", params={"search": "%"}).json() + ids = {item["id"] for item in body["items"]} + assert ids == {"img-percent"} + + +def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path): + monkeypatch.setenv("AUTH_ENABLED", "false") + client = _client_with_gallery(monkeypatch, tmp_path) + + # Only img-percent has a literal underscore (in tags "sale_2024"). + # Unescaped, "_" matches any single char so every row matches. + body = client.get("/api/gallery/library", params={"search": "_"}).json() + ids = {item["id"] for item in body["items"]} + assert ids == {"img-percent"} + + +def test_gallery_search_escapes_backslash(monkeypatch, tmp_path): + monkeypatch.setenv("AUTH_ENABLED", "false") + client = _client_with_gallery(monkeypatch, tmp_path) + + # Only img-backslash has a literal backslash. The escape char itself + # must survive escaping instead of escaping the surrounding wildcards. + body = client.get("/api/gallery/library", params={"search": "\\"}).json() + ids = {item["id"] for item in body["items"]} + assert ids == {"img-backslash"} + + +def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path): + monkeypatch.setenv("AUTH_ENABLED", "false") + client = _client_with_gallery(monkeypatch, tmp_path) + + # Unescaped, "%" / "_" match every row; escaped, only literal matches. + assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == [] + + body = client.get("/api/gallery/library", params={"tag": "_"}).json() + assert {item["id"] for item in body["items"]} == {"img-percent"}