| 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::cell::Cell; |
| 7 | use std::hash::{Hash, Hasher}; |
| 8 | use std::sync::Arc; |
| 9 | use std::sync::atomic::Ordering; |
| 10 | |
| 11 | use axum::http::{HeaderValue, StatusCode, header}; |
| 12 | use axum::response::{IntoResponse, Redirect, Response}; |
| 13 | use serde::Deserialize; |
| 14 | use tokio::sync::OwnedSemaphorePermit; |
| 15 | |
| 16 | use super::{archive, blame, blob, commit, error, feed, find, log, raw, refs, summary, tree}; |
| 17 | use crate::cache::Key; |
| 18 | use crate::config::Config; |
| 19 | use crate::git::refs::{RefKind, RefList, Rev}; |
| 20 | use crate::git::{self, Handle, Mailmap, ObjectId}; |
| 21 | use crate::index::Repo; |
| 22 | use crate::render::{encode_bytes, encode_path}; |
| 23 | use crate::state::AppState; |
| 24 | |
| 25 | /// Tags listed in the ref picker; the rest are on the refs page. |
| 26 | const PICKER_TAGS: usize = 20; |
| 27 | |
| 28 | #[derive(Debug, Default, Deserialize)] |
| 29 | pub 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 | |
| 46 | pub 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. |
| 60 | pub 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 | |
| 71 | pub 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 | |
| 101 | impl 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. |
| 296 | pub 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. |
| 310 | pub fn redirect(url: &str) -> Response { |
| 311 | Redirect::temporary(url).into_response() |
| 312 | } |
| 313 | |
| 314 | pub fn short(oid: &str) -> String { |
| 315 | oid.chars().take(7).collect() |
| 316 | } |
| 317 | |
| 318 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 319 | pub enum Active { |
| 320 | Summary, |
| 321 | Tree, |
| 322 | Log, |
| 323 | Refs, |
| 324 | Find, |
| 325 | } |
| 326 | |
| 327 | impl 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 | |
| 339 | pub 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 | |
| 351 | pub 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 | |
| 360 | pub 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. |
| 367 | pub 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). |
| 374 | pub 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 | } |