worklyn / zukapublic
Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.
Get a copy:
git clone https://zuka.worklyn.com/worklyn/zuka.git
| 1 | // The browser surface: a read-only repository viewer. |
| 2 | // |
| 3 | // The service is agent-first and this does not change that — nothing here |
| 4 | // writes, and there is no session, no cookie and no form. It exists because a |
| 5 | // repository people are asked to trust has to be readable by a person, and because |
| 6 | // "public repository" that cannot be opened in a browser is not a public repository. |
| 7 | // |
| 8 | // The person it is built for has never used a code host. The front page of a |
| 9 | // repository is its README rendered as a document (`pages::overview`), state lives |
| 10 | // only in the URL, and every page keeps the same row of tabs so nobody has to |
| 11 | // remember how they got somewhere in order to get back. |
| 12 | // |
| 13 | // Everything is server-rendered from this binary. No bundler, no CDN, no client |
| 14 | // framework: the product ships as one file a stranger drops on a VM, and a viewer |
| 15 | // that needed `npm install` at release time would end that. |
| 16 | // |
| 17 | // Reads go through `AppState::open_repo_for_reading`, so the rule about who may see |
| 18 | // what is the same one the API uses and is not restated here. |
| 19 | |
| 20 | mod html; |
| 21 | mod pages; |
| 22 | |
| 23 | pub use html::STYLE; |
| 24 | pub use pages::welcome; |
| 25 | |
| 26 | use crate::account::Identity; |
| 27 | use crate::brand; |
| 28 | use crate::error::{Error, Result}; |
| 29 | use crate::git::validate::{self, Name}; |
| 30 | use crate::git::{self, discover}; |
| 31 | use crate::http::response::{self, Body}; |
| 32 | use crate::http::AppState; |
| 33 | use hyper::{Method, Request, Response, StatusCode}; |
| 34 | use std::path::Path; |
| 35 | use std::sync::Arc; |
| 36 | |
| 37 | /// Does this path belong to the viewer? |
| 38 | /// |
| 39 | /// Called after the API, git and health routes have had their chance, so this only |
| 40 | /// has to recognise what is left. It deliberately does not match anything beginning |
| 41 | /// with a reserved prefix, so adding an API route can never be shadowed by a |
| 42 | /// repository named after it. |
| 43 | /// Whether this is a request for the stylesheet, from any build. |
| 44 | pub fn is_style_path(path: &str) -> bool { |
| 45 | path.starts_with(&format!("/_{}/", brand::NAME)) && path.ends_with(".css") |
| 46 | } |
| 47 | |
| 48 | pub fn owns(path: &str) -> bool { |
| 49 | !path.starts_with("/v1") |
| 50 | && !path.starts_with("/mcp") |
| 51 | && path != "/healthz" |
| 52 | && path != "/openapi.json" |
| 53 | } |
| 54 | |
| 55 | pub async fn handle( |
| 56 | request: Request<hyper::body::Incoming>, |
| 57 | state: Arc<AppState>, |
| 58 | ) -> Result<Response<Body>> { |
| 59 | if request.method() != Method::GET { |
| 60 | return Err(Error::MethodNotAllowed); |
| 61 | } |
| 62 | |
| 63 | let path = request.uri().path().to_string(); |
| 64 | let query = request.uri().query().unwrap_or("").to_string(); |
| 65 | |
| 66 | // The stylesheet is the one asset. Any build's URL is answered, not just this |
| 67 | // process's: during a staggered upgrade a tenant may render a page naming a |
| 68 | // different build, and a 404 there would serve an unstyled page rather than a |
| 69 | // slightly stale one. |
| 70 | if is_style_path(&path) { |
| 71 | return Ok(response::css(html::STYLE)); |
| 72 | } |
| 73 | |
| 74 | // A credential is honoured if presented, so an owner browsing their own private |
| 75 | // repository sees it. Sending none is not an error — that is the anonymous case |
| 76 | // and the whole point of this surface. Sending a bad one still is: a rejected |
| 77 | // token must not quietly become an anonymous visitor. |
| 78 | let offered = crate::http::auth::presented(request.headers()); |
| 79 | let identity = match state.identify(request.headers(), "GET", &path) { |
| 80 | Ok(identity) => Some(identity), |
| 81 | Err(Error::Unauthorized) if !offered => None, |
| 82 | Err(e) => return Err(e), |
| 83 | }; |
| 84 | |
| 85 | let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); |
| 86 | |
| 87 | // `/` shows the host's own repository when one is configured. A host that has |
| 88 | // not named one gets a short explanation rather than a 404, because an empty |
| 89 | // root reads as a broken deployment. |
| 90 | // `/` is a front door, not a second copy: it redirects to the repository's own |
| 91 | // address so every page has exactly one URL. |
| 92 | if segments.is_empty() { |
| 93 | return match state.config.home_repo() { |
| 94 | Some((account, repo)) => { |
| 95 | let (account, repo) = (validate::name(&account)?, validate::name(&repo)?); |
| 96 | Ok(response::redirect(&format!( |
| 97 | "/{}/{}", |
| 98 | account.as_str(), |
| 99 | repo.as_str() |
| 100 | ))) |
| 101 | } |
| 102 | None => Ok(pages::welcome()), |
| 103 | }; |
| 104 | } |
| 105 | |
| 106 | let outcome = match segments.as_slice() { |
| 107 | [account, repo, rest @ ..] => { |
| 108 | match (validate::name(account), validate::name(repo)) { |
| 109 | (Ok(account), Ok(repo)) => view(state, identity, account, repo, rest, &query).await, |
| 110 | // A name that cannot exist is indistinguishable from a page that |
| 111 | // does not: both are "nothing at this address". |
| 112 | _ => Err(Error::NotFound("page")), |
| 113 | } |
| 114 | } |
| 115 | // A single segment is ambiguous — an account with no repository named — and |
| 116 | // there is no account landing page yet. |
| 117 | _ => Err(Error::NotFound("page")), |
| 118 | }; |
| 119 | |
| 120 | // A person tapping a link deserves a page, not problem+json. Only "not found" |
| 121 | // is translated: a rejected credential must stay an API-shaped 401, so a bad |
| 122 | // token never quietly reads as "this repository does not exist". |
| 123 | match outcome { |
| 124 | Err(Error::NotFound(_)) => Ok(pages::not_found()), |
| 125 | other => other, |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /// Dispatch within one repository. |
| 130 | async fn view( |
| 131 | state: Arc<AppState>, |
| 132 | identity: Option<Identity>, |
| 133 | account: Name, |
| 134 | repo: Name, |
| 135 | rest: &[&str], |
| 136 | query: &str, |
| 137 | ) -> Result<Response<Body>> { |
| 138 | let (record, path) = state.open_repo_for_reading(identity.as_ref(), &account, &repo)?; |
| 139 | |
| 140 | // Links are always canonical, even when this was reached through `/`. Serving |
| 141 | // the same repository under two prefixes would make every sub-path ambiguous — |
| 142 | // `/blob/main/x` cannot be told from an account named `blob` — and would give |
| 143 | // every page two addresses. |
| 144 | let base = format!("/{}/{}", account.as_str(), repo.as_str()); |
| 145 | |
| 146 | let ctx = pages::Context { |
| 147 | record, |
| 148 | base, |
| 149 | state: Arc::clone(&state), |
| 150 | }; |
| 151 | |
| 152 | match rest { |
| 153 | [] => pages::overview(ctx, path).await, |
| 154 | ["tree", reference, sub @ ..] => { |
| 155 | let reference = (*reference).to_string(); |
| 156 | pages::tree(ctx, path, reference, &sub.join("/"), pages::Tab::Files).await |
| 157 | } |
| 158 | ["blob", reference, sub @ ..] => { |
| 159 | if sub.is_empty() { |
| 160 | return Err(Error::NotFound("file")); |
| 161 | } |
| 162 | let reference = (*reference).to_string(); |
| 163 | let plain = query_value(query, "plain").is_some(); |
| 164 | pages::blob(ctx, path, reference, &sub.join("/"), plain).await |
| 165 | } |
| 166 | ["raw", reference, sub @ ..] => { |
| 167 | if sub.is_empty() { |
| 168 | return Err(Error::NotFound("file")); |
| 169 | } |
| 170 | let reference = (*reference).to_string(); |
| 171 | pages::raw(ctx, path, reference, &sub.join("/")).await |
| 172 | } |
| 173 | ["commits", reference] => { |
| 174 | let reference = (*reference).to_string(); |
| 175 | // The page boundary must be a commit id. Anything else is refused |
| 176 | // before it can reach git as an argument. |
| 177 | let from = query_value(query, "from").filter(|v| discover::is_object_id(v)); |
| 178 | pages::commits(ctx, path, reference, from).await |
| 179 | } |
| 180 | ["commit", sha] => { |
| 181 | let sha = (*sha).to_string(); |
| 182 | pages::commit(ctx, path, sha).await |
| 183 | } |
| 184 | ["refs"] => pages::branches(ctx, path).await, |
| 185 | _ => Err(Error::NotFound("page")), |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// One value out of a query string, taken literally. |
| 190 | /// |
| 191 | /// No percent-decoding: the two parameters this surface accepts — a commit id and |
| 192 | /// a plain-text flag — have no reserved characters, so a decoder would only add a |
| 193 | /// way for two spellings to name one value. |
| 194 | fn query_value(query: &str, key: &str) -> Option<String> { |
| 195 | query.split('&').find_map(|pair| { |
| 196 | let (k, v) = pair.split_once('=')?; |
| 197 | (k == key && !v.is_empty()).then(|| v.to_string()) |
| 198 | }) |
| 199 | } |
| 200 | |
| 201 | /// Turn a browser-friendly ref into one the git layer will accept. |
| 202 | /// |
| 203 | /// `discover::revision` requires a full object id or a fully-qualified ref, so |
| 204 | /// `main` is refused — correct for an API where ambiguity is a bug, but unusable in |
| 205 | /// a URL, where `/tree/refs/heads/main/src` is not a link anyone would write. |
| 206 | /// |
| 207 | /// The expansion is a lookup against refs that exist, never string concatenation |
| 208 | /// handed to git: `refs/heads/` + attacker input is how a ref name becomes an |
| 209 | /// argument. Whatever comes back still goes through `discover::revision`. |
| 210 | pub fn resolve_ref(git_dir: &Path, wanted: &str) -> Result<String> { |
| 211 | if discover::is_object_id(wanted) { |
| 212 | return Ok(wanted.to_string()); |
| 213 | } |
| 214 | |
| 215 | // Already fully qualified. |
| 216 | if wanted.starts_with("refs/") { |
| 217 | return discover::revision(wanted); |
| 218 | } |
| 219 | |
| 220 | let refs = git::discover::refs(git_dir, None)?; |
| 221 | let items = refs.get("items").and_then(|i| i.as_array()); |
| 222 | let found = items.into_iter().flatten().find_map(|item| { |
| 223 | let name = item.get("name")?.as_str()?; |
| 224 | let short = name |
| 225 | .strip_prefix("refs/heads/") |
| 226 | .or_else(|| name.strip_prefix("refs/tags/")) |
| 227 | .unwrap_or(name); |
| 228 | (short == wanted).then(|| name.to_string()) |
| 229 | }); |
| 230 | |
| 231 | match found { |
| 232 | Some(name) => discover::revision(&name), |
| 233 | None => Err(Error::NotFound("revision")), |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | /// The ref a repository opens on, as a short name for URLs. |
| 238 | pub fn default_short_ref(default_branch: &str) -> &str { |
| 239 | default_branch |
| 240 | .strip_prefix("refs/heads/") |
| 241 | .unwrap_or(default_branch) |
| 242 | } |
| 243 | |
| 244 | pub fn ok_html(body: String) -> Response<Body> { |
| 245 | response::html(StatusCode::OK, body) |
| 246 | } |
| 247 | |
| 248 | #[cfg(test)] |
| 249 | mod tests { |
| 250 | use super::*; |
| 251 | |
| 252 | #[test] |
| 253 | fn the_viewer_never_claims_a_reserved_prefix() { |
| 254 | // If it did, an account named `v1` would shadow the API — and the shadowing |
| 255 | // would appear only once someone created that account. |
| 256 | assert!(!owns("/v1/repos")); |
| 257 | assert!(!owns("/mcp")); |
| 258 | assert!(!owns("/healthz")); |
| 259 | assert!(!owns("/openapi.json")); |
| 260 | |
| 261 | assert!(owns("/")); |
| 262 | assert!(owns("/worklyn/site")); |
| 263 | assert!(owns("/worklyn/site/blob/main/src/main.rs")); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn any_builds_stylesheet_url_is_answered() { |
| 268 | // A tenant one upgrade behind renders a page naming its own build. Answering |
| 269 | // only this process's URL would leave that page unstyled. |
| 270 | assert!(is_style_path(&html::style_url())); |
| 271 | assert!(is_style_path(&format!("/_{}/anything.css", brand::NAME))); |
| 272 | assert!(!is_style_path(&format!("/_{}/x.js", brand::NAME))); |
| 273 | assert!(!is_style_path("/etc/passwd.css")); |
| 274 | } |
| 275 | |
| 276 | #[test] |
| 277 | fn a_default_branch_becomes_a_short_name_for_urls() { |
| 278 | assert_eq!(default_short_ref("refs/heads/main"), "main"); |
| 279 | assert_eq!(default_short_ref("refs/heads/feature/x"), "feature/x"); |
| 280 | // Anything not under refs/heads is left alone rather than mangled. |
| 281 | assert_eq!(default_short_ref("refs/tags/v1"), "refs/tags/v1"); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn a_query_value_is_found_without_being_interpreted() { |
| 286 | assert_eq!(query_value("from=abc&plain=1", "from"), Some("abc".into())); |
| 287 | assert_eq!(query_value("from=abc&plain=1", "plain"), Some("1".into())); |
| 288 | // Empty values and absent keys read as "not supplied", never as "". |
| 289 | assert_eq!(query_value("from=", "from"), None); |
| 290 | assert_eq!(query_value("", "from"), None); |
| 291 | // A key must match exactly; `xfrom` sharing a suffix is a different key. |
| 292 | assert_eq!(query_value("xfrom=abc", "from"), None); |
| 293 | } |
| 294 | } |