Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
1//! Global search over repositories, changes and issues (M5).
2//!
3//! > Global search over repos, changes, and issues (**not code**).
4//!
5//! Code search is explicitly out of scope for v1, and this deliberately does not
6//! approximate it: searching titles and descriptions and calling it code search
7//! would be worse than not having it.
8//!
9//! **The visibility rule is the whole security story of this page.** A search
10//! that ignores it becomes an enumeration oracle for private repositories —
11//! type a guess, learn whether it matched. Every query below applies the same
12//! predicate the repository pages do, in SQL, before ranking: nothing the viewer
13//! cannot open is ever loaded, let alone scored.
14
15use axum::extract::{Query, State};
16use axum::response::{IntoResponse, Response};
17use serde::Deserialize;
18use uuid::Uuid;
19
20use crate::error::AppResult;
21use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
22use crate::views::{self, Chrome};
23
24#[derive(Deserialize, Default)]
25pub struct SearchQuery {
26 pub q: Option<String>,
27 /// `repos` | `changes` | `issues`. Absent means all three.
28 #[serde(rename = "type")]
29 pub kind: Option<String>,
30 /// `1` when the ⌘K palette is asking. Returns the results list on its own,
31 /// with no page chrome, so the palette and the full page cannot disagree
32 /// about what the viewer is allowed to see.
33 pub fragment: Option<String>,
34}
35
36/// The visibility predicate, shared by all three queries.
37///
38/// Written once as a constant rather than pasted three times: this is the
39/// clause that must not diverge between result types, and three copies is how
40/// it would.
41///
42/// `$2` is the viewer's id (null when anonymous) and `$3` is whether they are a
43/// site admin. Every branch past the first requires `$2`, so an anonymous
44/// request can match only public repositories.
45const VISIBLE: &str = "(
46 r.visibility = 'public'
47 OR $3
48 OR r.owner_user_id = $2
49 OR EXISTS (SELECT 1 FROM repo_collaborators c WHERE c.repo_id = r.id AND c.user_id = $2)
50 OR EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = r.owner_org_id AND m.user_id = $2)
51)";
52
53const LIMIT: i64 = 25;
54
55pub struct Hit {
56 pub kind: &'static str,
57 pub url: String,
58 pub title: String,
59 pub context: String,
60 pub badge: Option<String>,
61}
62
63/// `GET /search`
64pub async fn search(
65 State(state): State<AppState>,
66 Query(q): Query<SearchQuery>,
67 CurrentUser(user): CurrentUser,
68 CsrfToken(csrf): CsrfToken,
69 Nonce(nonce): Nonce,
70) -> AppResult<Response> {
71 let raw = q.q.as_deref().unwrap_or("").trim();
72 let kind = q.kind.as_deref().unwrap_or("all");
73
74 let viewer: Option<Uuid> = user.as_ref().map(|u| u.id);
75 let is_admin = user.as_ref().is_some_and(|u| u.is_admin);
76
77 // Short queries match almost everything and cost a full index scan for a
78 // useless result. Two characters is the floor.
79 let (repos, changes, issues) = if raw.chars().count() < 2 {
80 (vec![], vec![], vec![])
81 } else {
82 let term = to_tsquery(raw);
83 (
84 if matches!(kind, "all" | "repos") {
85 search_repos(&state, &term, viewer, is_admin).await?
86 } else {
87 vec![]
88 },
89 if matches!(kind, "all" | "changes") {
90 search_changes(&state, &term, viewer, is_admin).await?
91 } else {
92 vec![]
93 },
94 if matches!(kind, "all" | "issues") {
95 search_issues(&state, &term, viewer, is_admin).await?
96 } else {
97 vec![]
98 },
99 )
100 };
101
102 let total = repos.len() + changes.len() + issues.len();
103
104 // The palette asks for the same results without the page around them.
105 // Short queries fall back to the standing command list rather than an
106 // empty box, so the overlay is never blank.
107 if q.fragment.as_deref() == Some("1") {
108 let body = if raw.chars().count() < 2 {
109 views::layout::palette_hint()
110 } else {
111 views::layout::palette_results(&repos, &changes, &issues, raw)
112 };
113 return Ok(body.into_response());
114 }
115
116 Ok(views::page(
117 Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
118 maud::html! {
119 div .page-head {
120 h1 { "Search" }
121 span .band-note {
122 "Titles, descriptions and bodies. Code search is not part of v1 — \
123 clone the repository and use " code { "jj" } " or " code { "grep" } "."
124 }
125 }
126
127 form .revset-bar method="get" action="/search" {
128 label .revset-tag for="q" { "find" }
129 input #q type="text" name="q" value=(raw) autofocus
130 placeholder="repositories, changes and issues"
131 aria-label="Search query";
132 select name="type" aria-label="Result type" {
133 @for (key, label) in [("all", "everything"), ("repos", "repositories"),
134 ("changes", "changes"), ("issues", "issues")] {
135 option value=(key) selected[kind == key] { (label) }
136 }
137 }
138 button .btn.btn-mono type="submit" { "search" }
139 }
140
141 @if !raw.is_empty() && total == 0 {
142 div .empty {
143 h2 { "No results" }
144 p { "Nothing you can see matches that." }
145 }
146 }
147
148 @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
149 @if !hits.is_empty() {
150 section .search-group {
151 h2 .label-condensed { (heading) }
152 div .filelist {
153 @for h in hits.iter() {
154 a .search-hit href=(&h.url) {
155 span .search-kind { (h.kind) }
156 span .search-title { (h.title) }
157 @if let Some(b) = &h.badge { span .chip { (b) } }
158 @if !h.context.is_empty() {
159 span .search-context { (h.context) }
160 }
161 }
162 }
163 }
164 }
165 }
166 }
167 },
168 )
169 .into_response())
170}
171
172/// Turn user input into a `tsquery` safely.
173///
174/// `websearch_to_tsquery` would also work, but it accepts operators, and a
175/// search box that silently reinterprets `-` and `or` surprises people. This
176/// keeps alphanumeric words only and ANDs them, with a prefix match on the last
177/// word so typing feels responsive.
178fn to_tsquery(raw: &str) -> String {
179 let words: Vec<String> = raw
180 .split(|c: char| !c.is_alphanumeric())
181 .filter(|w| !w.is_empty())
182 .take(8)
183 .map(|w| w.chars().take(64).collect::<String>())
184 .collect();
185
186 if words.is_empty() {
187 // Matches nothing, which is the honest answer to a query of punctuation.
188 return String::from("''");
189 }
190
191 let mut parts: Vec<String> = words.iter().map(|w| format!("{w}:*")).collect();
192 // Only the last word gets prefix semantics for the *user*; the rest are
193 // whole words. Prefixing every word makes "in the" match the whole table.
194 for p in parts.iter_mut().take(words.len().saturating_sub(1)) {
195 p.truncate(p.len() - 2);
196 }
197 parts.join(" & ")
198}
199
200async fn search_repos(
201 state: &AppState,
202 term: &str,
203 viewer: Option<Uuid>,
204 is_admin: bool,
205) -> AppResult<Vec<Hit>> {
206 let sql = format!(
207 r#"
208 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, r.description,
209 (r.visibility = 'private') AS private
210 FROM repos r
211 LEFT JOIN users ou ON ou.id = r.owner_user_id
212 LEFT JOIN orgs og ON og.id = r.owner_org_id
213 WHERE r.search @@ to_tsquery('english', $1) AND {VISIBLE}
214 ORDER BY ts_rank(r.search, to_tsquery('english', $1)) DESC
215 LIMIT {LIMIT}
216 "#
217 );
218
219 let rows: Vec<(String, String, Option<String>, bool)> = sqlx::query_as(&sql)
220 .bind(term)
221 .bind(viewer)
222 .bind(is_admin)
223 .fetch_all(&state.db)
224 .await?;
225
226 Ok(rows
227 .into_iter()
228 .map(|(owner, name, description, private)| Hit {
229 kind: "repo",
230 url: format!("/{owner}/{name}"),
231 title: format!("{owner}/{name}"),
232 context: description.unwrap_or_default(),
233 badge: private.then(|| "private".to_string()),
234 })
235 .collect())
236}
237
238async fn search_changes(
239 state: &AppState,
240 term: &str,
241 viewer: Option<Uuid>,
242 is_admin: bool,
243) -> AppResult<Vec<Hit>> {
244 let sql = format!(
245 r#"
246 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
247 c.number, c.title, c.description, c.state::text
248 FROM changes c
249 JOIN repos r ON r.id = c.repo_id
250 LEFT JOIN users ou ON ou.id = r.owner_user_id
251 LEFT JOIN orgs og ON og.id = r.owner_org_id
252 WHERE c.search @@ to_tsquery('english', $1) AND {VISIBLE}
253 ORDER BY ts_rank(c.search, to_tsquery('english', $1)) DESC
254 LIMIT {LIMIT}
255 "#
256 );
257
258 let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
259 .bind(term)
260 .bind(viewer)
261 .bind(is_admin)
262 .fetch_all(&state.db)
263 .await?;
264
265 Ok(rows
266 .into_iter()
267 .map(|(owner, name, number, title, description, st)| Hit {
268 kind: "change",
269 url: format!("/{owner}/{name}/changes/{number}"),
270 title,
271 context: format!(
272 "{owner}/{name} #{number} · {}",
273 df_render::excerpt(&description, 120)
274 ),
275 badge: Some(st),
276 })
277 .collect())
278}
279
280async fn search_issues(
281 state: &AppState,
282 term: &str,
283 viewer: Option<Uuid>,
284 is_admin: bool,
285) -> AppResult<Vec<Hit>> {
286 let sql = format!(
287 r#"
288 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
289 i.number, i.title, i.body, i.state::text
290 FROM issues i
291 JOIN repos r ON r.id = i.repo_id
292 LEFT JOIN users ou ON ou.id = r.owner_user_id
293 LEFT JOIN orgs og ON og.id = r.owner_org_id
294 WHERE i.search @@ to_tsquery('english', $1) AND {VISIBLE}
295 ORDER BY ts_rank(i.search, to_tsquery('english', $1)) DESC
296 LIMIT {LIMIT}
297 "#
298 );
299
300 let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
301 .bind(term)
302 .bind(viewer)
303 .bind(is_admin)
304 .fetch_all(&state.db)
305 .await?;
306
307 Ok(rows
308 .into_iter()
309 .map(|(owner, name, number, title, body, st)| Hit {
310 kind: "issue",
311 url: format!("/{owner}/{name}/issues/{number}"),
312 title,
313 context: format!("{owner}/{name} #{number} · {}", df_render::excerpt(&body, 120)),
314 badge: Some(st),
315 })
316 .collect())
317}
318
319#[cfg(test)]
320mod tests {
321 use super::to_tsquery;
322
323 #[test]
324 fn words_are_anded_with_a_prefix_on_the_last() {
325 assert_eq!(to_tsquery("revset error"), "revset & error:*");
326 assert_eq!(to_tsquery("revset"), "revset:*");
327 }
328
329 /// The input reaches `to_tsquery`, which raises an error on malformed
330 /// syntax — so operators must never survive the sanitisation.
331 #[test]
332 fn tsquery_operators_cannot_reach_postgres() {
333 for input in ["a & b", "a | b", "!a", "a <-> b", "'; DROP TABLE users; --", "((("] {
334 let out = to_tsquery(input);
335 assert!(
336 !out.contains('|') && !out.contains('!') && !out.contains('\'')
337 || out == "''",
338 "{input} produced {out}"
339 );
340 }
341 }
342
343 #[test]
344 fn punctuation_only_matches_nothing() {
345 assert_eq!(to_tsquery("!!!"), "''");
346 assert_eq!(to_tsquery(""), "''");
347 }
348
349 #[test]
350 fn absurd_input_is_bounded() {
351 let long = "word ".repeat(100);
352 assert_eq!(to_tsquery(&long).matches('&').count(), 7, "at most eight terms");
353
354 let huge_word = "a".repeat(500);
355 assert!(to_tsquery(&huge_word).len() <= 66);
356 }
357}

357 lines · Rust