| 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, error, 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}; |
| 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 | } |
| 33 | |
| 34 | pub 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. |
| 44 | pub 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 | |
| 53 | pub 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 | |
| 74 | impl 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. |
| 217 | pub 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. |
| 231 | pub fn redirect(url: &str) -> Response { |
| 232 | Redirect::temporary(url).into_response() |
| 233 | } |
| 234 | |
| 235 | pub fn short(oid: &str) -> String { |
| 236 | oid.chars().take(7).collect() |
| 237 | } |
| 238 | |
| 239 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 240 | pub enum Active { |
| 241 | Summary, |
| 242 | Tree, |
| 243 | Refs, |
| 244 | } |
| 245 | |
| 246 | impl 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 | |
| 256 | pub 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 | |
| 266 | pub 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 | |
| 275 | pub 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. |
| 282 | pub 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). |
| 289 | pub 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 | } |