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