zuka
zuka/src/web/mod.rs

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