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.rs9.0 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
18pub use html::STYLE;
19pub use pages::welcome;
20
21use crate::account::Identity;
22use crate::brand;
23use crate::error::{Error, Result};
24use crate::git::validate::{self, Name};
25use crate::git::{self, discover};
26use crate::http::response::{self, Body};
27use crate::http::AppState;
28use hyper::{Method, Request, Response, StatusCode};
29use std::path::Path;
30use 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.
39pub fn is_style_path(path: &str) -> bool {
40 path.starts_with(&format!("/_{}/", brand::NAME)) && path.ends_with(".css")
41}
42
43pub fn owns(path: &str) -> bool {
44 !path.starts_with("/v1")
45 && !path.starts_with("/mcp")
46 && path != "/healthz"
47 && path != "/openapi.json"
48}
49
50pub 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.
113async 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`.
173pub 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.
201pub fn default_short_ref(default_branch: &str) -> &str {
202 default_branch
203 .strip_prefix("refs/heads/")
204 .unwrap_or(default_branch)
205}
206
207pub fn ok_html(body: String) -> Response<Body> {
208 response::html(StatusCode::OK, body)
209}
210
211#[cfg(test)]
212mod 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}