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.rs12.5 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::{service_llms, 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("/_pkg")
50 && !path.starts_with("/v1")
51 && !path.starts_with("/mcp")
52 && path != "/healthz"
53 && path != "/openapi.json"
54}
55
56pub async fn handle(
57 request: Request<hyper::body::Incoming>,
58 state: Arc<AppState>,
59) -> Result<Response<Body>> {
60 if request.method() != Method::GET {
61 return Err(Error::MethodNotAllowed);
62 }
63
64 let path = request.uri().path().to_string();
65 let query = request.uri().query().unwrap_or("").to_string();
66
67 // The stylesheet is the one asset. Any build's URL is answered, not just this
68 // process's: during a staggered upgrade a tenant may render a page naming a
69 // different build, and a 404 there would serve an unstyled page rather than a
70 // slightly stale one.
71 if is_style_path(&path) {
72 return Ok(response::css(html::STYLE));
73 }
74
75 // A credential is honoured if presented, so an owner browsing their own private
76 // repository sees it. Sending none is not an error — that is the anonymous case
77 // and the whole point of this surface. Sending a bad one still is: a rejected
78 // token must not quietly become an anonymous visitor.
79 let offered = crate::http::auth::presented(request.headers());
80 let identity = match state.identify(request.headers(), "GET", &path) {
81 Ok(identity) => Some(identity),
82 Err(Error::Unauthorized) if !offered => None,
83 Err(e) => return Err(e),
84 };
85
86 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
87
88 // `/` shows the host's own repository when one is configured. A host that has
89 // not named one gets a short explanation rather than a 404, because an empty
90 // root reads as a broken deployment.
91 // `/` is a front door, not a second copy: it redirects to the repository's own
92 // address so every page has exactly one URL.
93 if segments.is_empty() {
94 return match state.config.home_repo() {
95 Some((account, repo)) => {
96 let (account, repo) = (validate::name(&account)?, validate::name(&repo)?);
97 Ok(response::redirect(&format!(
98 "/{}/{}",
99 account.as_str(),
100 repo.as_str()
101 )))
102 }
103 None => Ok(pages::welcome()),
104 };
105 }
106
107 // The machine-readable front door follows the same rule as `/`: it belongs to
108 // the featured repository when one is named, and to the service when not.
109 if segments.as_slice() == ["llms.txt"] {
110 return match state.config.home_repo() {
111 Some((account, repo)) => {
112 let (account, repo) = (validate::name(&account)?, validate::name(&repo)?);
113 Ok(response::redirect(&format!(
114 "/{}/{}/llms.txt",
115 account.as_str(),
116 repo.as_str()
117 )))
118 }
119 None => Ok(pages::service_llms()),
120 };
121 }
122
123 let outcome = match segments.as_slice() {
124 [account, repo, rest @ ..] => {
125 match (validate::name(account), validate::name(repo)) {
126 (Ok(account), Ok(repo)) => view(state, identity, account, repo, rest, &query).await,
127 // A name that cannot exist is indistinguishable from a page that
128 // does not: both are "nothing at this address".
129 _ => Err(Error::NotFound("page")),
130 }
131 }
132 // A single segment is ambiguous — an account with no repository named — and
133 // there is no account landing page yet.
134 _ => Err(Error::NotFound("page")),
135 };
136
137 // A person tapping a link deserves a page, not problem+json. Only "not found"
138 // is translated: a rejected credential must stay an API-shaped 401, so a bad
139 // token never quietly reads as "this repository does not exist".
140 match outcome {
141 Err(Error::NotFound(_)) => Ok(pages::not_found()),
142 other => other,
143 }
144}
145
146/// Dispatch within one repository.
147async fn view(
148 state: Arc<AppState>,
149 identity: Option<Identity>,
150 account: Name,
151 repo: Name,
152 rest: &[&str],
153 query: &str,
154) -> Result<Response<Body>> {
155 let (record, path) = state.open_repo_for_reading(identity.as_ref(), &account, &repo)?;
156
157 // Links are always canonical, even when this was reached through `/`. Serving
158 // the same repository under two prefixes would make every sub-path ambiguous —
159 // `/blob/main/x` cannot be told from an account named `blob` — and would give
160 // every page two addresses.
161 let base = format!("/{}/{}", account.as_str(), repo.as_str());
162
163 let ctx = pages::Context {
164 record,
165 base,
166 state: Arc::clone(&state),
167 };
168
169 match rest {
170 [] => pages::overview(ctx, path).await,
171 ["llms.txt"] => pages::llms(ctx, path).await,
172 ["tree", reference, sub @ ..] => {
173 let reference = (*reference).to_string();
174 pages::tree(ctx, path, reference, &sub.join("/"), pages::Tab::Files).await
175 }
176 ["blob", reference, sub @ ..] => {
177 if sub.is_empty() {
178 return Err(Error::NotFound("file"));
179 }
180 let reference = (*reference).to_string();
181 let plain = query_value(query, "plain").is_some();
182 pages::blob(ctx, path, reference, &sub.join("/"), plain).await
183 }
184 ["raw", reference, sub @ ..] => {
185 if sub.is_empty() {
186 return Err(Error::NotFound("file"));
187 }
188 let reference = (*reference).to_string();
189 pages::raw(ctx, path, reference, &sub.join("/")).await
190 }
191 ["media", reference, sub @ ..] => {
192 if sub.is_empty() {
193 return Err(Error::NotFound("image"));
194 }
195 let reference = (*reference).to_string();
196 pages::media(ctx, path, reference, &sub.join("/")).await
197 }
198 ["commits", reference] => {
199 let reference = (*reference).to_string();
200 // The page boundary must be a commit id. Anything else is refused
201 // before it can reach git as an argument.
202 let from = query_value(query, "from").filter(|v| discover::is_object_id(v));
203 pages::commits(ctx, path, reference, from).await
204 }
205 ["commit", sha] => {
206 let sha = (*sha).to_string();
207 pages::commit(ctx, path, sha).await
208 }
209 ["refs"] => pages::branches(ctx, path).await,
210 _ => Err(Error::NotFound("page")),
211 }
212}
213
214/// One value out of a query string, taken literally.
215///
216/// No percent-decoding: the two parameters this surface accepts — a commit id and
217/// a plain-text flag — have no reserved characters, so a decoder would only add a
218/// way for two spellings to name one value.
219fn query_value(query: &str, key: &str) -> Option<String> {
220 query.split('&').find_map(|pair| {
221 let (k, v) = pair.split_once('=')?;
222 (k == key && !v.is_empty()).then(|| v.to_string())
223 })
224}
225
226/// Turn a browser-friendly ref into one the git layer will accept.
227///
228/// `discover::revision` requires a full object id or a fully-qualified ref, so
229/// `main` is refused — correct for an API where ambiguity is a bug, but unusable in
230/// a URL, where `/tree/refs/heads/main/src` is not a link anyone would write.
231///
232/// The expansion is a lookup against refs that exist, never string concatenation
233/// handed to git: `refs/heads/` + attacker input is how a ref name becomes an
234/// argument. Whatever comes back still goes through `discover::revision`.
235pub fn resolve_ref(git_dir: &Path, wanted: &str) -> Result<String> {
236 if discover::is_object_id(wanted) {
237 return Ok(wanted.to_string());
238 }
239
240 // Already fully qualified.
241 if wanted.starts_with("refs/") {
242 return discover::revision(wanted);
243 }
244
245 let refs = git::discover::refs(git_dir, None)?;
246 let items = refs.get("items").and_then(|i| i.as_array());
247 let found = items.into_iter().flatten().find_map(|item| {
248 let name = item.get("name")?.as_str()?;
249 let short = name
250 .strip_prefix("refs/heads/")
251 .or_else(|| name.strip_prefix("refs/tags/"))
252 .unwrap_or(name);
253 (short == wanted).then(|| name.to_string())
254 });
255
256 match found {
257 Some(name) => discover::revision(&name),
258 None => Err(Error::NotFound("revision")),
259 }
260}
261
262/// The ref a repository opens on, as a short name for URLs.
263pub fn default_short_ref(default_branch: &str) -> &str {
264 default_branch
265 .strip_prefix("refs/heads/")
266 .unwrap_or(default_branch)
267}
268
269pub fn ok_html(body: String) -> Response<Body> {
270 response::html(StatusCode::OK, body)
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn the_viewer_never_claims_a_reserved_prefix() {
279 // If it did, an account named `v1` would shadow the API — and the shadowing
280 // would appear only once someone created that account.
281 assert!(!owns("/v1/repos"));
282 assert!(!owns("/mcp"));
283 assert!(!owns("/healthz"));
284 assert!(!owns("/openapi.json"));
285 assert!(!owns("/_pkg/alice/math@deadbeef/mod.ts"));
286
287 assert!(owns("/"));
288 assert!(owns("/worklyn/site"));
289 assert!(owns("/worklyn/site/blob/main/src/main.rs"));
290 }
291
292 #[test]
293 fn any_builds_stylesheet_url_is_answered() {
294 // A tenant one upgrade behind renders a page naming its own build. Answering
295 // only this process's URL would leave that page unstyled.
296 assert!(is_style_path(&html::style_url()));
297 assert!(is_style_path(&format!("/_{}/anything.css", brand::NAME)));
298 assert!(!is_style_path(&format!("/_{}/x.js", brand::NAME)));
299 assert!(!is_style_path("/etc/passwd.css"));
300 }
301
302 #[test]
303 fn a_default_branch_becomes_a_short_name_for_urls() {
304 assert_eq!(default_short_ref("refs/heads/main"), "main");
305 assert_eq!(default_short_ref("refs/heads/feature/x"), "feature/x");
306 // Anything not under refs/heads is left alone rather than mangled.
307 assert_eq!(default_short_ref("refs/tags/v1"), "refs/tags/v1");
308 }
309
310 #[test]
311 fn a_query_value_is_found_without_being_interpreted() {
312 assert_eq!(query_value("from=abc&plain=1", "from"), Some("abc".into()));
313 assert_eq!(query_value("from=abc&plain=1", "plain"), Some("1".into()));
314 // Empty values and absent keys read as "not supplied", never as "".
315 assert_eq!(query_value("from=", "from"), None);
316 assert_eq!(query_value("", "from"), None);
317 // A key must match exactly; `xfrom` sharing a suffix is a different key.
318 assert_eq!(query_value("xfrom=abc", "from"), None);
319 }
320}