Krolyxon
Commit 55c1225

ferreniumsrchandlersrepo.rs

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