11"""
22API route for search — mirrors src/app/api/search/route.ts
3- GET /api/search?q=...&type=all|chat|composer&all_history=1
3+ GET /api/search?q=...&type=all|chat|composer&all_history=1&workspace=<id>
44"""
55
6+ from __future__ import annotations
7+
68import logging
9+ import os
10+ import re
11+ import sqlite3
712from typing import Any
813
914from flask import Blueprint , Response , current_app , request
1924 search_global_storage ,
2025 search_legacy_workspaces ,
2126)
27+ from utils .cli_chat_reader import list_cli_projects
2228from utils .workspace_path import get_cli_chats_path , resolve_workspace_path
2329
2430bp = Blueprint ("search" , __name__ )
2531_logger = logging .getLogger (__name__ )
2632
2733_MAX_SEARCH_SINCE_DAYS = 36_500 # ~100 years; avoids timedelta overflow on bad input
34+ _MAX_SEARCH_QUERY_LEN = 500
35+ _VALID_SEARCH_TYPES = frozenset ({"all" , "chat" , "composer" })
36+ _SAFE_WORKSPACE_ID_RE = re .compile (r"^[A-Za-z0-9][A-Za-z0-9._-]*$" )
2837
2938
3039def _parse_since_days_param (raw : str | None ) -> int | None :
@@ -39,31 +48,103 @@ def _parse_since_days_param(raw: str | None) -> int | None:
3948 return days
4049
4150
51+ def _search_error (
52+ message : str ,
53+ code : str ,
54+ status : int ,
55+ ) -> tuple [Response , int ]:
56+ return json_response ({"error" : message , "code" : code }, status )
57+
58+
59+ def _is_safe_workspace_folder_id (workspace_id : str ) -> bool :
60+ """Return whether *workspace_id* is a safe Cursor workspace folder name."""
61+ if not workspace_id or workspace_id in {"." , ".." }:
62+ return False
63+ if (
64+ os .path .isabs (workspace_id )
65+ or ".." in workspace_id
66+ or "/" in workspace_id
67+ or "\\ " in workspace_id
68+ ):
69+ return False
70+ return _SAFE_WORKSPACE_ID_RE .fullmatch (workspace_id ) is not None
71+
72+
73+ def _workspace_exists (workspace_id : str , workspace_path : str ) -> bool :
74+ if workspace_id == "global" :
75+ return True
76+ if workspace_id .startswith ("cli:" ):
77+ project_id = workspace_id [4 :]
78+ if not _is_safe_workspace_folder_id (project_id ):
79+ return False
80+ return any (
81+ cp .get ("project_id" ) == project_id
82+ for cp in list_cli_projects (get_cli_chats_path ())
83+ )
84+ if not _is_safe_workspace_folder_id (workspace_id ):
85+ return False
86+ candidate = os .path .join (workspace_path , workspace_id )
87+ root = os .path .normpath (workspace_path )
88+ joined = os .path .normpath (candidate )
89+ if os .path .commonpath ([root , joined ]) != root :
90+ return False
91+ return os .path .isdir (joined )
92+
93+
94+ def _filter_results_by_workspace (
95+ results : list [SearchResult ],
96+ workspace_id : str ,
97+ ) -> list [SearchResult ]:
98+ return [r for r in results if r .get ("workspaceId" ) == workspace_id ]
99+
100+
42101@bp .route ("/api/search" )
43102def search () -> tuple [Response , int ] | Response :
44103 """Search chats, composers, and CLI sessions across Cursor storage.
45104
46105 Args:
47106 q: Search query string (required; 400 when empty).
48107 type: Filter scope — ``all`` (default), ``chat``, or ``composer``.
108+ workspace: Optional workspace folder hash; 404 when unknown (bonus API
109+ filter — not exposed in the search UI).
49110
50111 Returns:
51- JSON ``{"results": [...]}`` with optional ``warnings``. 400 when ``q`` is
52- empty; 500 with ``{"error": ..., "results": []}`` on unexpected failure.
112+ JSON ``{"results": [...]}`` with optional ``warnings``. Structured
113+ ``{"error", "code"}`` bodies for 400/404/503/500 failures. Error
114+ responses omit ``results`` (breaking change vs. legacy 500 bodies).
53115 """
116+ query = request .args .get ("q" , "" ).strip ()
117+ if not query :
118+ return _search_error ("No search query provided" , "empty_query" , 400 )
119+ if len (query ) > _MAX_SEARCH_QUERY_LEN :
120+ return _search_error ("Search query is too long" , "query_too_long" , 400 )
121+
122+ search_type = request .args .get ("type" , "all" )
123+ if search_type not in _VALID_SEARCH_TYPES :
124+ return _search_error ("Invalid search type" , "invalid_type" , 400 )
125+
126+ since_days_raw = request .args .get ("since_days" )
127+ if (
128+ since_days_raw is not None
129+ and str (since_days_raw ).strip ()
130+ and _parse_since_days_param (since_days_raw ) is None
131+ ):
132+ return _search_error ("Invalid since_days parameter" , "invalid_since_days" , 400 )
133+
134+ workspace_filter = request .args .get ("workspace" , "" ).strip () or None
135+
54136 try :
55- query = request .args .get ("q" , "" ).strip ()
56- search_type = request .args .get ("type" , "all" )
137+ workspace_path = resolve_workspace_path ()
138+ if workspace_filter and not _workspace_exists (workspace_filter , workspace_path ):
139+ return _search_error ("Workspace not found" , "workspace_not_found" , 404 )
140+
57141 rules = current_app .config .get ("EXCLUSION_RULES" ) or []
58142 all_history = request .args .get ("all_history" ) in ("1" , "true" )
59143 since_ms = resolve_search_since_ms (
60144 all_history = all_history ,
61- since_days = _parse_since_days_param (request . args . get ( "since_days" ) ),
145+ since_days = _parse_since_days_param (since_days_raw ),
62146 )
63147
64- if not query :
65- return json_response ({"error" : "No search query provided" }, 400 )
66- workspace_path = resolve_workspace_path ()
67148 parse_warnings = ParseWarningCollector ()
68149 query_lower = query .lower ()
69150
@@ -101,18 +182,36 @@ def search() -> tuple[Response, int] | Response:
101182 )
102183 )
103184
185+ ranked = rank_results (results )
186+ if workspace_filter :
187+ ranked = _filter_results_by_workspace (ranked , workspace_filter )
188+
104189 payload : dict [str , Any ] = {
105- "results" : rank_results ( results ) ,
190+ "results" : ranked ,
106191 "allHistory" : since_ms is None ,
107192 "searchWindowDays" : (
108193 None if since_ms is None else (
109- _parse_since_days_param (request . args . get ( "since_days" ) )
194+ _parse_since_days_param (since_days_raw )
110195 or DEFAULT_SEARCH_WINDOW_DAYS
111196 )
112197 ),
113198 }
114199 return json_response (parse_warnings .attach_to (payload ))
115200
201+ except sqlite3 .OperationalError :
202+ _logger .exception ("Search index unavailable" )
203+ return _search_error (
204+ "Search index is temporarily unavailable" ,
205+ "search_index_unavailable" ,
206+ 503 ,
207+ )
208+ except OSError :
209+ _logger .exception ("Workspace storage unavailable" )
210+ return _search_error (
211+ "Workspace storage is temporarily unavailable" ,
212+ "storage_unavailable" ,
213+ 503 ,
214+ )
116215 except Exception :
117216 _logger .exception ("Search failed" )
118- return json_response ({ "error" : " Search failed" , "results" : []} , 500 )
217+ return _search_error ( " Search failed" , "internal_error" , 500 )
0 commit comments