Krolyxon
Commit 4e844bb

ferreniumsrchandlersrepo.rs

348 lines · 10.8 KB · Rust

1//! Shared plumbing for repository pages: routing by action, revision
2//! resolution, ETags and the repository nav.
3//!
4//! Everything here runs on the blocking pool (see [`AppState::blocking`]).
5
6use std::hash::{Hash, Hasher};
7use std::sync::Arc;
8use std::sync::atomic::Ordering;
9
10use axum::http::{HeaderValue, StatusCode, header};
11use axum::response::{IntoResponse, Redirect, Response};
12use serde::Deserialize;
13
14use super::{blob, commit, error, log, raw, refs, summary, tree};
15use crate::cache::Key;
16use crate::config::Config;
17use crate::git::refs::{RefKind, RefList, Rev};
18use crate::git::{self, Handle, Mailmap, ObjectId};
19use crate::index::Repo;
20use crate::render::{encode_bytes, encode_path};
21use crate::state::AppState;
22
23/// Tags listed in the ref picker; the rest are on the refs page.
24const PICKER_TAGS: usize = 20;
25
26#[derive(Debug, Default, Deserialize)]
27pub struct PageQuery {
28 /// Blob view: `source` shows Markdown as code.
29 pub view: Option<String>,
30 /// Raw: serve as an attachment.
31 pub download: Option<String>,
32 /// Log: cursor (the last commit of the previous page).
33 pub after: Option<String>,
34 /// Log: message/author filter.
35 pub q: Option<String>,
36 /// Commit: lines of context.
37 pub context: Option<String>,
38 /// Commit: `ignore` compares lines ignoring whitespace.
39 pub ws: Option<String>,
40}
41
42pub struct RepoRequest {
43 pub repo: Arc<Repo>,
44 /// Path segments after the repository name.
45 pub segments: Vec<Vec<u8>>,
46 pub query: PageQuery,
47 pub if_none_match: Option<HeaderValue>,
48 pub config: Arc<Config>,
49}
50
51/// Everything a repository page handler needs.
52pub struct Ctx<'a> {
53 pub state: &'a AppState,
54 pub config: &'a Config,
55 pub repo: &'a Repo,
56 pub git: Handle,
57 pub query: &'a PageQuery,
58 if_none_match: Option<&'a HeaderValue>,
59}
60
61pub fn handle(state: &AppState, req: RepoRequest) -> Response {
62 let ctx = Ctx {
63 state,
64 config: &req.config,
65 repo: &req.repo,
66 git: git::handle(&req.repo.gix),
67 query: &req.query,
68 if_none_match: req.if_none_match.as_ref(),
69 };
70 let Some((action, rest)) = req.segments.split_first() else {
71 return summary::page(&ctx);
72 };
73 match action.as_slice() {
74 b"tree" => tree::page(&ctx, rest),
75 b"blob" => blob::page(&ctx, rest),
76 b"raw" => raw::serve(&ctx, rest),
77 b"log" => log::page(&ctx, rest),
78 b"commit" => commit::page(&ctx, rest),
79 b"refs" if rest.is_empty() => refs::page(&ctx),
80 _ => ctx.not_found("PAGE NOT FOUND"),
81 }
82}
83
84impl Ctx<'_> {
85 /// `{base}{repo}` without a trailing slash.
86 pub fn repo_url(&self) -> String {
87 format!(
88 "{}{}",
89 self.config.server.base_path,
90 encode_path(&self.repo.name)
91 )
92 }
93
94 /// `{repo}/{action}/{rev}/{path}` (no trailing slash for the root).
95 pub fn url(&self, action: &str, rev: &str, path: &[u8]) -> String {
96 let mut url = format!("{}/{action}/{}", self.repo_url(), encode_path(rev));
97 if !path.is_empty() {
98 url.push('/');
99 url.push_str(&encode_bytes(path));
100 }
101 url
102 }
103
104 pub fn commit_url(&self, id: &ObjectId) -> String {
105 format!("{}/commit/{id}", self.repo_url())
106 }
107
108 /// The `.mailmap` on the default branch (empty when there is none).
109 pub fn mailmap(&self) -> Arc<Mailmap> {
110 let blob = self
111 .repo
112 .meta
113 .head
114 .and_then(|head| git::mailmap_blob(&self.git, head));
115 let Some(blob) = blob else {
116 return Arc::default();
117 };
118 self.state
119 .cache
120 .mailmap(blob, || {
121 git::tree::blob(&self.git, blob).map(|data| Mailmap::parse(&data))
122 })
123 .unwrap_or_default()
124 }
125
126 pub fn not_found(&self, message: &str) -> Response {
127 error::page(self.state, StatusCode::NOT_FOUND, message)
128 }
129
130 pub fn internal_error(&self, e: anyhow::Error) -> Response {
131 tracing::error!("{}: {e:#}", self.repo.name);
132 error::page(
133 self.state,
134 StatusCode::INTERNAL_SERVER_ERROR,
135 "INTERNAL ERROR",
136 )
137 }
138
139 /// The revision and path named by `segments`; an empty list means the
140 /// default branch at the root.
141 pub fn resolve(&self, segments: &[Vec<u8>]) -> Result<(Rev, Vec<u8>), Box<Response>> {
142 if segments.is_empty() {
143 let (Some(branch), Some(head)) = (&self.repo.meta.default_branch, self.repo.meta.head)
144 else {
145 return Err(Box::new(self.not_found("REPOSITORY IS EMPTY")));
146 };
147 let rev = Rev {
148 name: branch.clone(),
149 kind: RefKind::Branch,
150 commit: head,
151 };
152 return Ok((rev, Vec::new()));
153 }
154 match git::refs::resolve_prefix(&self.git, segments) {
155 Some((rev, path)) => Ok((rev, path.join(&b'/'))),
156 None => Err(Box::new(self.not_found("UNKNOWN REVISION"))),
157 }
158 }
159
160 /// Weak ETag over everything a page depends on besides its URL: the
161 /// content (`parts`), the repository's refs and metadata, the config and
162 /// the build.
163 pub fn etag(&self, parts: impl Hash) -> String {
164 let mut hasher = std::hash::DefaultHasher::new();
165 parts.hash(&mut hasher);
166 self.repo.generation.hash(&mut hasher);
167 self.state
168 .config_generation
169 .load(Ordering::Relaxed)
170 .hash(&mut hasher);
171 self.state.assets.hash().hash(&mut hasher);
172 format!("W/\"{:016x}\"", hasher.finish())
173 }
174
175 /// `Some(304)` when the client already has `etag`.
176 pub fn not_modified(&self, etag: &str, immutable: bool) -> Option<Response> {
177 let header = self.if_none_match?.to_str().ok()?;
178 let bare = |t: &str| t.trim().trim_start_matches("W/").to_string();
179 let matches = header
180 .split(',')
181 .any(|t| t.trim() == "*" || bare(t) == bare(etag));
182 matches.then(|| {
183 let mut res = StatusCode::NOT_MODIFIED.into_response();
184 set_cache_headers(&mut res, etag, immutable);
185 res
186 })
187 }
188
189 /// Branches and tags (with tip commits), cached per refs generation.
190 pub fn refs(&self) -> anyhow::Result<Arc<RefList>> {
191 let key = Key::Refs {
192 repo: self.repo.name.clone(),
193 generation: self.repo.generation,
194 };
195 self.state.cache.refs(key, || git::refs::list(&self.git))
196 }
197
198 /// Nav for this repository; `picker` builds ref-picker links for a
199 /// revision name.
200 pub fn nav(
201 &self,
202 active: Active,
203 current: Option<&Rev>,
204 picker: impl Fn(&str) -> String,
205 ) -> RepoNav {
206 let (tree_url, log_url) = match current {
207 Some(rev) => (
208 self.url("tree", &rev.name, b""),
209 self.url("log", &rev.name, b""),
210 ),
211 None => (
212 format!("{}/tree", self.repo_url()),
213 format!("{}/log", self.repo_url()),
214 ),
215 };
216 let picker = current.and_then(|rev| {
217 let refs = self.refs().ok()?;
218 let item = |name: &str| PickItem {
219 name: name.to_string(),
220 url: picker(name),
221 active: rev.kind != RefKind::Commit && rev.name == name,
222 };
223 Some(Picker {
224 current: match rev.kind {
225 RefKind::Commit => short(&rev.commit.to_string()),
226 _ => rev.name.clone(),
227 },
228 kind: match rev.kind {
229 RefKind::Branch => "Branch",
230 RefKind::Tag => "Tag",
231 RefKind::Commit => "Commit",
232 },
233 branches: refs.branches.iter().map(|r| item(&r.name)).collect(),
234 tags: refs
235 .tags
236 .iter()
237 .take(PICKER_TAGS)
238 .map(|r| item(&r.name))
239 .collect(),
240 more_tags: refs.tags.len() > PICKER_TAGS,
241 })
242 });
243 RepoNav {
244 name: self.repo.name.clone(),
245 url: self.repo_url(),
246 tree_url,
247 log_url,
248 refs_url: format!("{}/refs", self.repo_url()),
249 active: active.as_str(),
250 picker,
251 }
252 }
253}
254
255/// Set `ETag` and `Cache-Control` for a repository page.
256pub fn set_cache_headers(res: &mut Response, etag: &str, immutable: bool) {
257 if let Ok(value) = HeaderValue::from_str(etag) {
258 res.headers_mut().insert(header::ETAG, value);
259 }
260 let cache = if immutable {
261 "public, max-age=86400"
262 } else {
263 "no-cache"
264 };
265 res.headers_mut()
266 .insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
267}
268
269/// Redirect between the tree and blob views of the same path.
270pub fn redirect(url: &str) -> Response {
271 Redirect::temporary(url).into_response()
272}
273
274pub fn short(oid: &str) -> String {
275 oid.chars().take(7).collect()
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum Active {
280 Summary,
281 Tree,
282 Log,
283 Refs,
284}
285
286impl Active {
287 pub fn as_str(self) -> &'static str {
288 match self {
289 Active::Summary => "summary",
290 Active::Tree => "tree",
291 Active::Log => "log",
292 Active::Refs => "refs",
293 }
294 }
295}
296
297pub struct RepoNav {
298 pub name: String,
299 pub url: String,
300 pub tree_url: String,
301 pub log_url: String,
302 pub refs_url: String,
303 /// `summary`, `tree`, `log` or `refs`.
304 pub active: &'static str,
305 pub picker: Option<Picker>,
306}
307
308pub struct Picker {
309 /// Label of the current revision (short id for commits).
310 pub current: String,
311 pub kind: &'static str,
312 pub branches: Vec<PickItem>,
313 pub tags: Vec<PickItem>,
314 pub more_tags: bool,
315}
316
317pub struct PickItem {
318 pub name: String,
319 pub url: String,
320 pub active: bool,
321}
322
323/// One breadcrumb segment: the repository name, then each directory.
324pub struct Crumb {
325 pub name: String,
326 pub url: String,
327}
328
329/// Breadcrumbs for `path` at `rev`; the last segment is returned separately
330/// (it is the current page and not a link).
331pub fn crumbs(ctx: &Ctx, rev: &str, path: &[u8]) -> (Vec<Crumb>, Option<String>) {
332 let mut crumbs = vec![Crumb {
333 name: ctx.repo.name.clone(),
334 url: ctx.url("tree", rev, b""),
335 }];
336 if path.is_empty() {
337 return (crumbs, None);
338 }
339 let parts: Vec<&[u8]> = path.split(|b| *b == b'/').collect();
340 let (last, dirs) = parts.split_last().expect("non-empty path");
341 for (i, part) in dirs.iter().enumerate() {
342 crumbs.push(Crumb {
343 name: String::from_utf8_lossy(part).into_owned(),
344 url: ctx.url("tree", rev, &parts[..=i].join(&b'/')),
345 });
346 }
347 (crumbs, Some(String::from_utf8_lossy(last).into_owned()))
348}