Jump to…
snowfeat: given a new redesign and emulated terminal homepagerxkspqmsoknz1mo
Matt W1//! Global search over repositories, changes and issues (M5).
Matt W2//!
Matt W3//! > Global search over repos, changes, and issues (**not code**).
Matt W4//!
Matt W5//! Code search is explicitly out of scope for v1, and this deliberately does not
Matt W6//! approximate it: searching titles and descriptions and calling it code search
Matt W7//! would be worse than not having it.
Matt W8//!
Matt W9//! **The visibility rule is the whole security story of this page.** A search
Matt W10//! that ignores it becomes an enumeration oracle for private repositories —
Matt W11//! type a guess, learn whether it matched. Every query below applies the same
Matt W12//! predicate the repository pages do, in SQL, before ranking: nothing the viewer
Matt W13//! cannot open is ever loaded, let alone scored.
Matt W14
Matt W15use axum::extract::{Query, State};
Matt W16use axum::response::{IntoResponse, Response};
Matt W17use serde::Deserialize;
Matt W18use uuid::Uuid;
Matt W19
Matt W20use crate::error::AppResult;
Matt W21use crate::state::{AppState, CsrfToken, CurrentUser, Nonce};
Matt W22use crate::views::{self, Chrome};
Matt W23
Matt W24#[derive(Deserialize, Default)]
Matt W25pub struct SearchQuery {
Matt W26 pub q: Option<String>,
Matt W27 /// `repos` | `changes` | `issues`. Absent means all three.
Matt W28 #[serde(rename = "type")]
Matt W29 pub kind: Option<String>,
Matt W30 /// `1` when the ⌘K palette is asking. Returns the results list on its own,
Matt W31 /// with no page chrome, so the palette and the full page cannot disagree
Matt W32 /// about what the viewer is allowed to see.
Matt W33 pub fragment: Option<String>,
Matt W34}
Matt W35
Matt W36/// The visibility predicate, shared by all three queries.
Matt W37///
Matt W38/// Written once as a constant rather than pasted three times: this is the
Matt W39/// clause that must not diverge between result types, and three copies is how
Matt W40/// it would.
Matt W41///
Matt W42/// `$2` is the viewer's id (null when anonymous) and `$3` is whether they are a
Matt W43/// site admin. Every branch past the first requires `$2`, so an anonymous
Matt W44/// request can match only public repositories.
Matt W45const VISIBLE: &str = "(
Matt W46 r.visibility = 'public'
Matt W47 OR $3
Matt W48 OR r.owner_user_id = $2
Matt W49 OR EXISTS (SELECT 1 FROM repo_collaborators c WHERE c.repo_id = r.id AND c.user_id = $2)
Matt W50 OR EXISTS (SELECT 1 FROM org_members m WHERE m.org_id = r.owner_org_id AND m.user_id = $2)
Matt W51)";
Matt W52
Matt W53const LIMIT: i64 = 25;
Matt W54
Matt W55pub struct Hit {
Matt W56 pub kind: &'static str,
Matt W57 pub url: String,
Matt W58 pub title: String,
Matt W59 pub context: String,
Matt W60 pub badge: Option<String>,
Matt W61}
Matt W62
Matt W63/// `GET /search`
Matt W64pub async fn search(
Matt W65 State(state): State<AppState>,
Matt W66 Query(q): Query<SearchQuery>,
Matt W67 CurrentUser(user): CurrentUser,
Matt W68 CsrfToken(csrf): CsrfToken,
Matt W69 Nonce(nonce): Nonce,
Matt W70) -> AppResult<Response> {
Matt W71 let raw = q.q.as_deref().unwrap_or("").trim();
Matt W72 let kind = q.kind.as_deref().unwrap_or("all");
Matt W73
Matt W74 let viewer: Option<Uuid> = user.as_ref().map(|u| u.id);
Matt W75 let is_admin = user.as_ref().is_some_and(|u| u.is_admin);
Matt W76
Matt W77 // Short queries match almost everything and cost a full index scan for a
Matt W78 // useless result. Two characters is the floor.
Matt W79 let (repos, changes, issues) = if raw.chars().count() < 2 {
Matt W80 (vec![], vec![], vec![])
Matt W81 } else {
Matt W82 let term = to_tsquery(raw);
Matt W83 (
Matt W84 if matches!(kind, "all" | "repos") {
Matt W85 search_repos(&state, &term, viewer, is_admin).await?
Matt W86 } else {
Matt W87 vec![]
Matt W88 },
Matt W89 if matches!(kind, "all" | "changes") {
Matt W90 search_changes(&state, &term, viewer, is_admin).await?
Matt W91 } else {
Matt W92 vec![]
Matt W93 },
Matt W94 if matches!(kind, "all" | "issues") {
Matt W95 search_issues(&state, &term, viewer, is_admin).await?
Matt W96 } else {
Matt W97 vec![]
Matt W98 },
Matt W99 )
Matt W100 };
Matt W101
Matt W102 let total = repos.len() + changes.len() + issues.len();
Matt W103
Matt W104 // The palette asks for the same results without the page around them.
Matt W105 // Short queries fall back to the standing command list rather than an
Matt W106 // empty box, so the overlay is never blank.
Matt W107 if q.fragment.as_deref() == Some("1") {
Matt W108 let body = if raw.chars().count() < 2 {
Matt W109 views::layout::palette_hint()
Matt W110 } else {
Matt W111 views::layout::palette_results(&repos, &changes, &issues, raw)
Matt W112 };
Matt W113 return Ok(body.into_response());
Matt W114 }
Matt W115
Matt W116 Ok(views::page(
Matt W117 Chrome { title: "Search", user: user.as_deref(), csrf: &csrf, nonce: &nonce },
Matt W118 maud::html! {
Matt W119 div .page-head {
Matt W120 h1 { "Search" }
Matt W121 span .band-note {
Matt W122 "Titles, descriptions and bodies. Code search is not part of v1 — \
Matt W123 clone the repository and use " code { "jj" } " or " code { "grep" } "."
Matt W124 }
Matt W125 }
Matt W126
Matt W127 form .revset-bar method="get" action="/search" {
Matt W128 label .revset-tag for="q" { "find" }
Matt W129 input #q type="text" name="q" value=(raw) autofocus
Matt W130 placeholder="repositories, changes and issues"
Matt W131 aria-label="Search query";
Matt W132 select name="type" aria-label="Result type" {
Matt W133 @for (key, label) in [("all", "everything"), ("repos", "repositories"),
Matt W134 ("changes", "changes"), ("issues", "issues")] {
Matt W135 option value=(key) selected[kind == key] { (label) }
Matt W136 }
Matt W137 }
Matt W138 button .btn.btn-mono type="submit" { "search" }
Matt W139 }
Matt W140
Matt W141 @if !raw.is_empty() && total == 0 {
Matt W142 div .empty {
Matt W143 h2 { "No results" }
Matt W144 p { "Nothing you can see matches that." }
Matt W145 }
Matt W146 }
Matt W147
Matt W148 @for (heading, hits) in [("Repositories", &repos), ("Changes", &changes), ("Issues", &issues)] {
Matt W149 @if !hits.is_empty() {
Matt W150 section .search-group {
Matt W151 h2 .label-condensed { (heading) }
Matt W152 div .filelist {
Matt W153 @for h in hits.iter() {
Matt W154 a .search-hit href=(&h.url) {
Matt W155 span .search-kind { (h.kind) }
Matt W156 span .search-title { (h.title) }
Matt W157 @if let Some(b) = &h.badge { span .chip { (b) } }
Matt W158 @if !h.context.is_empty() {
Matt W159 span .search-context { (h.context) }
Matt W160 }
Matt W161 }
Matt W162 }
Matt W163 }
Matt W164 }
Matt W165 }
Matt W166 }
Matt W167 },
Matt W168 )
Matt W169 .into_response())
Matt W170}
Matt W171
Matt W172/// Turn user input into a `tsquery` safely.
Matt W173///
Matt W174/// `websearch_to_tsquery` would also work, but it accepts operators, and a
Matt W175/// search box that silently reinterprets `-` and `or` surprises people. This
Matt W176/// keeps alphanumeric words only and ANDs them, with a prefix match on the last
Matt W177/// word so typing feels responsive.
Matt W178fn to_tsquery(raw: &str) -> String {
Matt W179 let words: Vec<String> = raw
Matt W180 .split(|c: char| !c.is_alphanumeric())
Matt W181 .filter(|w| !w.is_empty())
Matt W182 .take(8)
Matt W183 .map(|w| w.chars().take(64).collect::<String>())
Matt W184 .collect();
Matt W185
Matt W186 if words.is_empty() {
Matt W187 // Matches nothing, which is the honest answer to a query of punctuation.
Matt W188 return String::from("''");
Matt W189 }
Matt W190
Matt W191 let mut parts: Vec<String> = words.iter().map(|w| format!("{w}:*")).collect();
Matt W192 // Only the last word gets prefix semantics for the *user*; the rest are
Matt W193 // whole words. Prefixing every word makes "in the" match the whole table.
Matt W194 for p in parts.iter_mut().take(words.len().saturating_sub(1)) {
Matt W195 p.truncate(p.len() - 2);
Matt W196 }
Matt W197 parts.join(" & ")
Matt W198}
Matt W199
Matt W200async fn search_repos(
Matt W201 state: &AppState,
Matt W202 term: &str,
Matt W203 viewer: Option<Uuid>,
Matt W204 is_admin: bool,
Matt W205) -> AppResult<Vec<Hit>> {
Matt W206 let sql = format!(
Matt W207 r#"
Matt W208 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text, r.description,
Matt W209 (r.visibility = 'private') AS private
Matt W210 FROM repos r
Matt W211 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W212 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W213 WHERE r.search @@ to_tsquery('english', $1) AND {VISIBLE}
Matt W214 ORDER BY ts_rank(r.search, to_tsquery('english', $1)) DESC
Matt W215 LIMIT {LIMIT}
Matt W216 "#
Matt W217 );
Matt W218
Matt W219 let rows: Vec<(String, String, Option<String>, bool)> = sqlx::query_as(&sql)
Matt W220 .bind(term)
Matt W221 .bind(viewer)
Matt W222 .bind(is_admin)
Matt W223 .fetch_all(&state.db)
Matt W224 .await?;
Matt W225
Matt W226 Ok(rows
Matt W227 .into_iter()
Matt W228 .map(|(owner, name, description, private)| Hit {
Matt W229 kind: "repo",
Matt W230 url: format!("/{owner}/{name}"),
Matt W231 title: format!("{owner}/{name}"),
Matt W232 context: description.unwrap_or_default(),
Matt W233 badge: private.then(|| "private".to_string()),
Matt W234 })
Matt W235 .collect())
Matt W236}
Matt W237
Matt W238async fn search_changes(
Matt W239 state: &AppState,
Matt W240 term: &str,
Matt W241 viewer: Option<Uuid>,
Matt W242 is_admin: bool,
Matt W243) -> AppResult<Vec<Hit>> {
Matt W244 let sql = format!(
Matt W245 r#"
Matt W246 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
Matt W247 c.number, c.title, c.description, c.state::text
Matt W248 FROM changes c
Matt W249 JOIN repos r ON r.id = c.repo_id
Matt W250 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W251 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W252 WHERE c.search @@ to_tsquery('english', $1) AND {VISIBLE}
Matt W253 ORDER BY ts_rank(c.search, to_tsquery('english', $1)) DESC
Matt W254 LIMIT {LIMIT}
Matt W255 "#
Matt W256 );
Matt W257
Matt W258 let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
Matt W259 .bind(term)
Matt W260 .bind(viewer)
Matt W261 .bind(is_admin)
Matt W262 .fetch_all(&state.db)
Matt W263 .await?;
Matt W264
Matt W265 Ok(rows
Matt W266 .into_iter()
Matt W267 .map(|(owner, name, number, title, description, st)| Hit {
Matt W268 kind: "change",
Matt W269 url: format!("/{owner}/{name}/changes/{number}"),
Matt W270 title,
Matt W271 context: format!(
Matt W272 "{owner}/{name} #{number} · {}",
Matt W273 df_render::excerpt(&description, 120)
Matt W274 ),
Matt W275 badge: Some(st),
Matt W276 })
Matt W277 .collect())
Matt W278}
Matt W279
Matt W280async fn search_issues(
Matt W281 state: &AppState,
Matt W282 term: &str,
Matt W283 viewer: Option<Uuid>,
Matt W284 is_admin: bool,
Matt W285) -> AppResult<Vec<Hit>> {
Matt W286 let sql = format!(
Matt W287 r#"
Matt W288 SELECT COALESCE(ou.handle, og.handle)::text, r.name::text,
Matt W289 i.number, i.title, i.body, i.state::text
Matt W290 FROM issues i
Matt W291 JOIN repos r ON r.id = i.repo_id
Matt W292 LEFT JOIN users ou ON ou.id = r.owner_user_id
Matt W293 LEFT JOIN orgs og ON og.id = r.owner_org_id
Matt W294 WHERE i.search @@ to_tsquery('english', $1) AND {VISIBLE}
Matt W295 ORDER BY ts_rank(i.search, to_tsquery('english', $1)) DESC
Matt W296 LIMIT {LIMIT}
Matt W297 "#
Matt W298 );
Matt W299
Matt W300 let rows: Vec<(String, String, i64, String, String, String)> = sqlx::query_as(&sql)
Matt W301 .bind(term)
Matt W302 .bind(viewer)
Matt W303 .bind(is_admin)
Matt W304 .fetch_all(&state.db)
Matt W305 .await?;
Matt W306
Matt W307 Ok(rows
Matt W308 .into_iter()
Matt W309 .map(|(owner, name, number, title, body, st)| Hit {
Matt W310 kind: "issue",
Matt W311 url: format!("/{owner}/{name}/issues/{number}"),
Matt W312 title,
Matt W313 context: format!("{owner}/{name} #{number} · {}", df_render::excerpt(&body, 120)),
Matt W314 badge: Some(st),
Matt W315 })
Matt W316 .collect())
Matt W317}
Matt W318
Matt W319#[cfg(test)]
Matt W320mod tests {
Matt W321 use super::to_tsquery;
Matt W322
Matt W323 #[test]
Matt W324 fn words_are_anded_with_a_prefix_on_the_last() {
Matt W325 assert_eq!(to_tsquery("revset error"), "revset & error:*");
Matt W326 assert_eq!(to_tsquery("revset"), "revset:*");
Matt W327 }
Matt W328
Matt W329 /// The input reaches `to_tsquery`, which raises an error on malformed
Matt W330 /// syntax — so operators must never survive the sanitisation.
Matt W331 #[test]
Matt W332 fn tsquery_operators_cannot_reach_postgres() {
Matt W333 for input in ["a & b", "a | b", "!a", "a <-> b", "'; DROP TABLE users; --", "((("] {
Matt W334 let out = to_tsquery(input);
Matt W335 assert!(
Matt W336 !out.contains('|') && !out.contains('!') && !out.contains('\'')
Matt W337 || out == "''",
Matt W338 "{input} produced {out}"
Matt W339 );
Matt W340 }
Matt W341 }
Matt W342
Matt W343 #[test]
Matt W344 fn punctuation_only_matches_nothing() {
Matt W345 assert_eq!(to_tsquery("!!!"), "''");
Matt W346 assert_eq!(to_tsquery(""), "''");
Matt W347 }
Matt W348
Matt W349 #[test]
Matt W350 fn absurd_input_is_bounded() {
Matt W351 let long = "word ".repeat(100);
Matt W352 assert_eq!(to_tsquery(&long).matches('&').count(), 7, "at most eight terms");
Matt W353
Matt W354 let huge_word = "a".repeat(500);
Matt W355 assert!(to_tsquery(&huge_word).len() <= 66);
Matt W356 }
Matt W357}

357 lines · Rust