Krolyxon
Tag v0.3.0

ferreniumsrchandlersrepo.rs

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