Krolyxon
Tag v0.1.0

ferreniumsrcserverdispatch.rs

173 lines · 5.5 KB · Rust

1//! The catch-all route: map a URL path onto a repository and an action.
2//!
3//! Repository names may contain `/`, so the longest prefix of the path that
4//! names a repository wins; the next segment is the action. Ferrenium's own
5//! endpoints live under `/-/`, which never names a repository.
6
7use std::sync::Arc;
8
9use axum::extract::{Query, Request, State};
10use axum::http::{Method, StatusCode, header};
11use axum::response::{IntoResponse, Redirect, Response};
12
13use super::clone;
14use crate::handlers::error;
15use crate::handlers::repo::{PageQuery, RepoRequest, handle};
16use crate::index::{Repo, RepoIndex};
17use crate::render::{encode_path, percent_decode};
18use crate::state::AppState;
19
20pub enum Match<'a> {
21 Repo {
22 repo: Arc<Repo>,
23 rest: &'a [Vec<u8>],
24 },
25 /// `/{repo}.git` → `/{repo}`; `/{repo}.git/…` is only for cloning.
26 DotGit {
27 repo: Arc<Repo>,
28 rest: &'a [Vec<u8>],
29 },
30 None,
31}
32
33/// Split a raw URL path into percent-decoded segments, dropping empty ones.
34/// `None` for `.`/`..` segments, which are never valid.
35pub fn segments(path: &str) -> Option<Vec<Vec<u8>>> {
36 let segments: Vec<Vec<u8>> = path
37 .split('/')
38 .filter(|s| !s.is_empty())
39 .map(percent_decode)
40 .collect();
41 let bad = segments
42 .iter()
43 .any(|s| s == b"." || s == b".." || s.contains(&b'/') || s.contains(&0));
44 (!bad).then_some(segments)
45}
46
47/// Find the repository named by the longest prefix of `segments`.
48pub fn find_repo<'a>(index: &RepoIndex, segments: &'a [Vec<u8>]) -> Match<'a> {
49 if segments.first().is_some_and(|s| s == b"-") {
50 return Match::None;
51 }
52 for n in (1..=segments.len()).rev() {
53 let parts: Option<Vec<&str>> = segments[..n]
54 .iter()
55 .map(|s| std::str::from_utf8(s).ok())
56 .collect();
57 let Some(parts) = parts else {
58 continue;
59 };
60 let name = parts.join("/");
61 if let Some(repo) = index.repos.get(&name) {
62 return Match::Repo {
63 repo: repo.clone(),
64 rest: &segments[n..],
65 };
66 }
67 if let Some(stripped) = name.strip_suffix(".git")
68 && let Some(repo) = index.repos.get(stripped)
69 {
70 return Match::DotGit {
71 repo: repo.clone(),
72 rest: &segments[n..],
73 };
74 }
75 }
76 Match::None
77}
78
79/// Expensive pages share `limits.heavy_parallel` permits.
80fn is_heavy(rest: &[Vec<u8>], query: &PageQuery) -> bool {
81 match rest.first().map(Vec::as_slice) {
82 Some(b"blame" | b"archive") => true,
83 Some(b"log") => query.q.as_deref().is_some_and(|q| !q.trim().is_empty()),
84 _ => false,
85 }
86}
87
88pub async fn dispatch(State(state): State<Arc<AppState>>, req: Request) -> Response {
89 let config = state.config.load_full();
90 let index = state.index.load();
91 let Some(segments) = segments(req.uri().path()) else {
92 return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
93 };
94 let (repo, rest) = match find_repo(&index, &segments) {
95 Match::Repo { repo, rest } => (repo, rest.to_vec()),
96 Match::DotGit { repo, rest: [] } => {
97 let url = format!("{}{}", config.server.base_path, encode_path(&repo.name));
98 return Redirect::permanent(&url).into_response();
99 }
100 Match::DotGit { repo, rest } if clone::is_clone_path(rest) => {
101 return clone::serve(state.clone(), repo, rest.to_vec(), req).await;
102 }
103 Match::DotGit { .. } | Match::None => {
104 return error::page(&state, StatusCode::NOT_FOUND, "PAGE NOT FOUND");
105 }
106 };
107 if clone::is_clone_path(&rest) {
108 return clone::serve(state.clone(), repo, rest, req).await;
109 }
110 if !matches!(*req.method(), Method::GET | Method::HEAD) {
111 return error::method_not_allowed(&state);
112 }
113 let query = Query::<PageQuery>::try_from_uri(req.uri())
114 .map(|q| q.0)
115 .unwrap_or_default();
116 let heavy = if is_heavy(&rest, &query) {
117 match state.heavy_permit().await {
118 Some(permit) => Some(permit),
119 None => return error::busy(&state),
120 }
121 } else {
122 None
123 };
124 let request = RepoRequest {
125 repo,
126 heavy,
127 host: req
128 .headers()
129 .get(header::HOST)
130 .and_then(|h| h.to_str().ok())
131 .map(str::to_string),
132 segments: rest,
133 query,
134 if_none_match: req.headers().get(header::IF_NONE_MATCH).cloned(),
135 config,
136 };
137 match state.blocking(move |state| handle(state, request)).await {
138 Ok(response) => response,
139 Err(e) => {
140 tracing::error!("request failed: {e:#}");
141 error::page(&state, StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL ERROR")
142 }
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::index::RepoIndex;
150
151 #[test]
152 fn segment_parsing() {
153 assert_eq!(
154 segments("/a//b%2Fc/").map(|s| s.len()),
155 None,
156 "encoded slash is rejected"
157 );
158 assert_eq!(
159 segments("/a//b/%C3%BC/").unwrap(),
160 vec![b"a".to_vec(), b"b".to_vec(), "ü".as_bytes().to_vec()]
161 );
162 assert!(segments("/a/../b").is_none());
163 assert!(segments("/a/%2e%2e/b").is_none());
164 assert!(segments("/").unwrap().is_empty());
165 }
166
167 #[test]
168 fn no_repos_no_match() {
169 let index = RepoIndex::default();
170 let segs = segments("/-/x").unwrap();
171 assert!(matches!(find_repo(&index, &segs), Match::None));
172 }
173}