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.rs11.4 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// 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
20mod html;
21mod pages;
22
23pub use html::STYLE;
24pub use pages::welcome;
25
26use crate::account::Identity;
27use crate::brand;
28use crate::error::{Error, Result};
29use crate::git::validate::{self, Name};
30use crate::git::{self, discover};
31use crate::http::response::{self, Body};
32use crate::http::AppState;
33use hyper::{Method, Request, Response, StatusCode};
34use std::path::Path;
35use 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.
44pub fn is_style_path(path: &str) -> bool {
45 path.starts_with(&format!("/_{}/", brand::NAME)) && path.ends_with(".css")
46}
47
48pub fn owns(path: &str) -> bool {
49 !path.starts_with("/v1")
50 && !path.starts_with("/mcp")
51 && path != "/healthz"
52 && path != "/openapi.json"
53}
54
55pub 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.
130async 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.
194fn 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`.
210pub 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.
238pub fn default_short_ref(default_branch: &str) -> &str {
239 default_branch
240 .strip_prefix("refs/heads/")
241 .unwrap_or(default_branch)
242}
243
244pub fn ok_html(body: String) -> Response<Body> {
245 response::html(StatusCode::OK, body)
246}
247
248#[cfg(test)]
249mod 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}