| 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 | |
| 6 | use std::hash::{Hash, Hasher}; |
| 7 | use std::sync::Arc; |
| 8 | use std::sync::atomic::Ordering; |
| 9 | |
| 10 | use axum::http::{HeaderValue, StatusCode, header}; |
| 11 | use axum::response::{IntoResponse, Redirect, Response}; |
| 12 | use serde::Deserialize; |
| 13 | |
| 14 | use super::{blob, commit, error, log, raw, refs, summary, tree}; |
| 15 | use crate::cache::Key; |
| 16 | use crate::config::Config; |
| 17 | use crate::git::refs::{RefKind, RefList, Rev}; |
| 18 | use crate::git::{self, Handle, Mailmap, ObjectId}; |
| 19 | use crate::index::Repo; |
| 20 | use crate::render::{encode_bytes, encode_path}; |
| 21 | use crate::state::AppState; |
| 22 | |
| 23 | /// Tags listed in the ref picker; the rest are on the refs page. |
| 24 | const PICKER_TAGS: usize = 20; |
| 25 | |
| 26 | #[derive(Debug, Default, Deserialize)] |
| 27 | pub 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 | |
| 42 | pub 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. |
| 52 | pub 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 | |
| 61 | pub 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 | |
| 84 | impl 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. |
| 256 | pub 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. |
| 270 | pub fn redirect(url: &str) -> Response { |
| 271 | Redirect::temporary(url).into_response() |
| 272 | } |
| 273 | |
| 274 | pub fn short(oid: &str) -> String { |
| 275 | oid.chars().take(7).collect() |
| 276 | } |
| 277 | |
| 278 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 279 | pub enum Active { |
| 280 | Summary, |
| 281 | Tree, |
| 282 | Log, |
| 283 | Refs, |
| 284 | } |
| 285 | |
| 286 | impl 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 | |
| 297 | pub 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 | |
| 308 | pub 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 | |
| 317 | pub 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. |
| 324 | pub 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). |
| 331 | pub 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 | } |