diff --git a/api/search.py b/api/search.py index f6e1442..05b6e1d 100644 --- a/api/search.py +++ b/api/search.py @@ -1,9 +1,14 @@ """ API route for search — mirrors src/app/api/search/route.ts -GET /api/search?q=...&type=all|chat|composer&all_history=1 +GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace= """ +from __future__ import annotations + import logging +import os +import re +import sqlite3 from typing import Any from flask import Blueprint, Response, current_app, request @@ -19,12 +24,16 @@ search_global_storage, search_legacy_workspaces, ) +from utils.cli_chat_reader import list_cli_projects from utils.workspace_path import get_cli_chats_path, resolve_workspace_path bp = Blueprint("search", __name__) _logger = logging.getLogger(__name__) _MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input +_MAX_SEARCH_QUERY_LEN = 500 +_VALID_SEARCH_TYPES = frozenset({"all", "chat", "composer"}) +_SAFE_WORKSPACE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") def _parse_since_days_param(raw: str | None) -> int | None: @@ -39,6 +48,56 @@ def _parse_since_days_param(raw: str | None) -> int | None: return days +def _search_error( + message: str, + code: str, + status: int, +) -> tuple[Response, int]: + return json_response({"error": message, "code": code}, status) + + +def _is_safe_workspace_folder_id(workspace_id: str) -> bool: + """Return whether *workspace_id* is a safe Cursor workspace folder name.""" + if not workspace_id or workspace_id in {".", ".."}: + return False + if ( + os.path.isabs(workspace_id) + or ".." in workspace_id + or "/" in workspace_id + or "\\" in workspace_id + ): + return False + return _SAFE_WORKSPACE_ID_RE.fullmatch(workspace_id) is not None + + +def _workspace_exists(workspace_id: str, workspace_path: str) -> bool: + if workspace_id == "global": + return True + if workspace_id.startswith("cli:"): + project_id = workspace_id[4:] + if not _is_safe_workspace_folder_id(project_id): + return False + return any( + cp.get("project_id") == project_id + for cp in list_cli_projects(get_cli_chats_path()) + ) + if not _is_safe_workspace_folder_id(workspace_id): + return False + candidate = os.path.join(workspace_path, workspace_id) + root = os.path.normpath(workspace_path) + joined = os.path.normpath(candidate) + if os.path.commonpath([root, joined]) != root: + return False + return os.path.isdir(joined) + + +def _filter_results_by_workspace( + results: list[SearchResult], + workspace_id: str, +) -> list[SearchResult]: + return [r for r in results if r.get("workspaceId") == workspace_id] + + @bp.route("/api/search") def search() -> tuple[Response, int] | Response: """Search chats, composers, and CLI sessions across Cursor storage. @@ -46,24 +105,44 @@ def search() -> tuple[Response, int] | Response: Args: q: Search query string (required; 400 when empty). type: Filter scope — ``all`` (default), ``chat``, or ``composer``. + workspace: Optional workspace folder hash; 404 when unknown. Returns: - JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is - empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure. + JSON ``{"results": [...]}`` with optional ``warnings``. Structured + ``{"error", "code"}`` bodies for 400/404/503/500 failures. """ + query = request.args.get("q", "").strip() + if not query: + return _search_error("No search query provided", "empty_query", 400) + if len(query) > _MAX_SEARCH_QUERY_LEN: + return _search_error("Search query is too long", "query_too_long", 400) + + search_type = request.args.get("type", "all") + if search_type not in _VALID_SEARCH_TYPES: + return _search_error("Invalid search type", "invalid_type", 400) + + since_days_raw = request.args.get("since_days") + if ( + since_days_raw is not None + and str(since_days_raw).strip() + and _parse_since_days_param(since_days_raw) is None + ): + return _search_error("Invalid since_days parameter", "invalid_since_days", 400) + + workspace_filter = request.args.get("workspace", "").strip() or None + try: - query = request.args.get("q", "").strip() - search_type = request.args.get("type", "all") + workspace_path = resolve_workspace_path() + if workspace_filter and not _workspace_exists(workspace_filter, workspace_path): + return _search_error("Workspace not found", "workspace_not_found", 404) + rules = current_app.config.get("EXCLUSION_RULES") or [] all_history = request.args.get("all_history") in ("1", "true") since_ms = resolve_search_since_ms( all_history=all_history, - since_days=_parse_since_days_param(request.args.get("since_days")), + since_days=_parse_since_days_param(since_days_raw), ) - if not query: - return json_response({"error": "No search query provided"}, 400) - workspace_path = resolve_workspace_path() parse_warnings = ParseWarningCollector() query_lower = query.lower() @@ -101,18 +180,30 @@ def search() -> tuple[Response, int] | Response: ) ) + ranked = rank_results(results) + if workspace_filter: + ranked = _filter_results_by_workspace(ranked, workspace_filter) + payload: dict[str, Any] = { - "results": rank_results(results), + "results": ranked, "allHistory": since_ms is None, "searchWindowDays": ( None if since_ms is None else ( - _parse_since_days_param(request.args.get("since_days")) + _parse_since_days_param(since_days_raw) or DEFAULT_SEARCH_WINDOW_DAYS ) ), } return json_response(parse_warnings.attach_to(payload)) + except sqlite3.OperationalError: + _logger.exception("Search index unavailable") + return _search_error( + "Search index is temporarily unavailable", + "search_index_unavailable", + 503, + ) except Exception: _logger.exception("Search failed") - return json_response({"error": "Search failed", "results": []}, 500) \ No newline at end of file + return _search_error("Search failed", "internal_error", 500) + diff --git a/static/css/style.css b/static/css/style.css index d6387f0..01e4535 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -245,9 +245,58 @@ h3 { font-size: 1.15rem; font-weight: 600; } } .input:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } -.search-bar { display: flex; gap: 0.5rem; } +.search-bar { display: flex; gap: 0.5rem; align-items: center; } .search-bar .input { flex: 1; } +.search-info-tooltip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border: none; + background: none; + padding: 0; + font: inherit; + cursor: help; +} +.search-info-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + border: 1px solid var(--border); + color: var(--text-muted); + font-size: 0.75rem; + font-weight: 700; + font-style: italic; + cursor: help; + user-select: none; +} +.search-info-tooltip-text { + display: none; + position: absolute; + top: calc(100% + 0.5rem); + right: 0; + z-index: 60; + width: min(20rem, 70vw); + padding: 0.75rem 0.875rem; + border: 1px solid var(--info-border); + border-radius: 0.5rem; + background: var(--info-bg); + color: var(--info-text); + font-size: 0.8125rem; + line-height: 1.45; + box-shadow: 0 4px 12px var(--shadow); +} +.search-info-tooltip:hover .search-info-tooltip-text, +.search-info-tooltip:focus .search-info-tooltip-text, +.search-info-tooltip:focus-within .search-info-tooltip-text { + display: block; +} + /* ---------- Alerts ---------- */ .alert { padding: 0.75rem 1rem; diff --git a/templates/search.html b/templates/search.html index 50ebce0..b2408ac 100644 --- a/templates/search.html +++ b/templates/search.html @@ -14,6 +14,19 @@

Search