zuka
zuka/src/web/pages.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/pages.rs
RSpages.rs21.5 KBDownload
1// The individual views.
2//
3// Each page reads through `git::discover`, which is the same code the REST API and
4// the MCP tools use — so the browser cannot show something the API disagrees with,
5// and a fix to a git query lands everywhere at once.
6//
7// Every value that reaches the output goes through `html::escape` or `url_escape`.
8
9use super::html::{self, escape, url_escape, Page};
10use super::{default_short_ref, ok_html, resolve_ref};
11use crate::error::{Error, Result};
12use crate::git::discover;
13use crate::http::response::Body;
14use crate::http::AppState;
15use crate::store::RepoRecord;
16use hyper::{Response, StatusCode};
17use serde_json::Value;
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21pub struct Context {
22 pub record: RepoRecord,
23 /// Canonical URL prefix for every link this page emits.
24 pub base: String,
25 pub state: Arc<AppState>,
26}
27
28impl Context {
29 fn link(&self, suffix: &str) -> String {
30 format!("{}{}", self.base, suffix)
31 }
32
33 fn crumbs(&self, trail: &str) -> String {
34 let mut out = format!(
35 "<a href=\"{}\">{}</a>",
36 self.link(""),
37 escape(&self.record.display_name)
38 );
39 if !trail.is_empty() {
40 out.push_str(&format!("<span class=\"sep\">/</span>{trail}"));
41 }
42 out
43 }
44
45 fn title(&self, extra: &str) -> String {
46 if extra.is_empty() {
47 format!("{}/{}", self.record.account, self.record.display_name)
48 } else {
49 format!(
50 "{} · {}/{}",
51 extra, self.record.account, self.record.display_name
52 )
53 }
54 }
55}
56
57/// Names tried, in order, when looking for a README to render.
58const README_NAMES: &[&str] = &["README.md", "readme.md", "README", "README.markdown"];
59
60/// Files that are content but not code, and would be meaningless as text.
61fn is_probably_binary(bytes: &[u8]) -> bool {
62 // A NUL in the first few KiB is what git itself uses to call a file binary.
63 bytes.iter().take(8000).any(|b| *b == 0)
64}
65
66fn relative_time(then: u64) -> String {
67 let now = crate::account::token::now_secs();
68 let delta = now.saturating_sub(then);
69 match delta {
70 0..=59 => "just now".to_string(),
71 60..=3599 => format!("{} min ago", delta / 60),
72 3600..=86399 => format!("{} hr ago", delta / 3600),
73 86400..=2591999 => format!("{} days ago", delta / 86400),
74 2592000..=31535999 => format!("{} months ago", delta / 2592000),
75 _ => format!("{} years ago", delta / 31536000),
76 }
77}
78
79fn human_size(bytes: u64) -> String {
80 const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
81 let mut value = bytes as f64;
82 let mut unit = 0;
83 while value >= 1024.0 && unit < UNITS.len() - 1 {
84 value /= 1024.0;
85 unit += 1;
86 }
87 if unit == 0 {
88 format!("{bytes} {}", UNITS[0])
89 } else {
90 format!("{value:.1} {}", UNITS[unit])
91 }
92}
93
94fn short_sha(sha: &str) -> &str {
95 sha.get(..8).unwrap_or(sha)
96}
97
98/// The header block every repository page carries.
99fn repo_header(ctx: &Context, refs: &[(String, String)], current: &str) -> String {
100 let mut out = String::new();
101 out.push_str("<div class=\"repo-head\">\n");
102
103 let badge = if ctx.record.visibility.is_public() {
104 "<span class=\"badge public\">public</span>"
105 } else {
106 "<span class=\"badge\">private</span>"
107 };
108 out.push_str(&format!(
109 "<h1 class=\"repo-title\">{}{}</h1>\n",
110 escape(&ctx.record.display_name),
111 badge
112 ));
113
114 if let Some(description) = &ctx.record.description {
115 out.push_str(&format!(
116 "<p class=\"repo-desc\">{}</p>\n",
117 escape(description)
118 ));
119 }
120
121 // Only shown for public repositories: printing a clone URL that a reader cannot
122 // use without a credential they have not got is an invitation to a failure.
123 if ctx.record.visibility.is_public() {
124 let http = format!(
125 "{}/{}/{}.git",
126 ctx.state.config.public_host(),
127 ctx.record.account,
128 ctx.record.name
129 );
130 out.push_str("<div class=\"clone\">\n");
131 out.push_str(&format!("<code>git clone {}</code>\n", escape(&http)));
132 out.push_str("</div>\n");
133 }
134
135 out.push_str("</div>\n");
136
137 // Ref picker. A plain link list rather than a control, because there is no
138 // JavaScript on this surface and a <select> without it does nothing.
139 if !refs.is_empty() {
140 out.push_str("<div class=\"toolbar\">\n");
141 for (name, short) in refs.iter().take(12) {
142 let _ = name;
143 let mark = if short == current { " badge" } else { "" };
144 out.push_str(&format!(
145 "<a class=\"picker{}\" href=\"{}/tree/{}/\">{}</a>\n",
146 mark,
147 ctx.link(""),
148 url_escape(short),
149 escape(short)
150 ));
151 }
152 out.push_str("<span class=\"spacer\"></span>\n");
153 out.push_str(&format!(
154 "<a class=\"picker\" href=\"{}/commits/{}\">history</a>\n",
155 ctx.link(""),
156 url_escape(current)
157 ));
158 out.push_str("</div>\n");
159 }
160 out
161}
162
163/// Branch and tag short names, branches first.
164fn ref_list(git_dir: &Path) -> Vec<(String, String)> {
165 let Ok(value) = discover::refs(git_dir, None) else {
166 return Vec::new();
167 };
168 let mut out = Vec::new();
169 for item in value
170 .get("items")
171 .and_then(|i| i.as_array())
172 .into_iter()
173 .flatten()
174 {
175 let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
176 continue;
177 };
178 if let Some(short) = name.strip_prefix("refs/heads/") {
179 out.push((name.to_string(), short.to_string()));
180 }
181 }
182 out
183}
184
185// ── pages ───────────────────────────────────────────────────────────────────
186
187pub async fn home(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
188 let reference = default_short_ref(&ctx.record.default_branch).to_string();
189 tree(ctx, git_dir, reference, "").await
190}
191
192pub async fn tree(
193 ctx: Context,
194 git_dir: PathBuf,
195 reference: String,
196 path: &str,
197) -> Result<Response<Body>> {
198 let path = path.trim_end_matches('/').to_string();
199 let dir = git_dir.clone();
200 let wanted = reference.clone();
201 let sub = path.clone();
202
203 let rendered = crate::git::exec::blocking(move || {
204 let refs = ref_list(&dir);
205 // An empty repository has no resolvable ref at all, which is a normal state
206 // straight after `repo_create` and must not read as an error.
207 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
208 return Ok::<_, Error>(None);
209 };
210 let listing = discover::tree(
211 &dir,
212 &resolved,
213 if sub.is_empty() { None } else { Some(&sub) },
214 )?;
215 let readme = find_readme(&dir, &resolved, &sub);
216 Ok(Some((refs, listing, readme)))
217 })
218 .await?;
219
220 let Some((refs, listing, readme)) = rendered else {
221 return Ok(ok_html(html::document(&Page {
222 title: ctx.title(""),
223 heading: ctx.crumbs(""),
224 body: format!(
225 "{}<div class=\"panel\"><div class=\"notice\"><strong>Nothing here yet</strong>\
226 This repository has no commits.</div></div>",
227 repo_header(&ctx, &[], &reference)
228 ),
229 })));
230 };
231
232 let mut body = repo_header(&ctx, &refs, &reference);
233 body.push_str(&path_crumbs(&ctx, &reference, &path));
234 body.push_str("<div class=\"panel\">\n");
235 body.push_str(&format!(
236 "<div class=\"panel-head\">{}</div>\n",
237 if path.is_empty() {
238 escape(&reference)
239 } else {
240 escape(&path)
241 }
242 ));
243
244 let items = listing
245 .get("items")
246 .and_then(|i| i.as_array())
247 .cloned()
248 .unwrap_or_default();
249
250 if items.is_empty() {
251 body.push_str("<div class=\"empty\">This directory is empty.</div>\n");
252 } else {
253 body.push_str("<table class=\"listing\">\n");
254
255 if !path.is_empty() {
256 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
257 body.push_str(&format!(
258 "<tr><td class=\"name\" colspan=\"2\">\
259 <span class=\"icon dir\">↰</span><a href=\"{}/tree/{}/{}\">..</a></td></tr>\n",
260 ctx.link(""),
261 url_escape(&reference),
262 url_escape(parent)
263 ));
264 }
265
266 // Directories first, then files, each alphabetically — the order a person
267 // expects, which git's own output does not guarantee.
268 let mut rows: Vec<&Value> = items.iter().collect();
269 rows.sort_by_key(|item| {
270 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
271 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
272 (kind != "dir", name.to_lowercase())
273 });
274
275 for item in rows {
276 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
277 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
278 let full = item.get("path").and_then(|p| p.as_str()).unwrap_or(name);
279 let size = item.get("size").and_then(|s| s.as_u64());
280
281 let (verb, icon) = match kind {
282 "dir" => ("tree", "<span class=\"icon dir\">▸</span>"),
283 "submodule" => ("tree", "<span class=\"icon\">⧉</span>"),
284 _ => ("blob", "<span class=\"icon\">·</span>"),
285 };
286
287 body.push_str(&format!(
288 "<tr><td class=\"name\">{}<a href=\"{}/{}/{}/{}\">{}</a></td>\
289 <td class=\"size\">{}</td></tr>\n",
290 icon,
291 ctx.link(""),
292 verb,
293 url_escape(&reference),
294 url_escape(full),
295 escape(name),
296 size.filter(|_| kind == "file")
297 .map(human_size)
298 .unwrap_or_default()
299 ));
300 }
301 body.push_str("</table>\n");
302 }
303 body.push_str("</div>\n");
304
305 if let Some((name, source)) = readme {
306 body.push_str("<div class=\"panel\">\n");
307 body.push_str(&format!(
308 "<div class=\"panel-head\">{}</div>\n",
309 escape(&name)
310 ));
311 body.push_str(&format!(
312 "<div class=\"prose\">{}</div>\n",
313 html::markdown(&source)
314 ));
315 body.push_str("</div>\n");
316 }
317
318 Ok(ok_html(html::document(&Page {
319 title: ctx.title(if path.is_empty() { "" } else { &path }),
320 heading: ctx.crumbs(&escape(&reference)),
321 body,
322 })))
323}
324
325fn find_readme(git_dir: &Path, resolved: &str, dir: &str) -> Option<(String, String)> {
326 // A README is rendered inline, so a huge one is a page nobody can load. This is
327 // a display cap, not the API's blob limit.
328 let limits = discover::Limits {
329 max_blob_bytes: 1024 * 1024,
330 log_walk_budget: 0,
331 };
332 for name in README_NAMES {
333 let path = if dir.is_empty() {
334 (*name).to_string()
335 } else {
336 format!("{dir}/{name}")
337 };
338 if let Ok(blob) = discover::blob(git_dir, resolved, &path, limits) {
339 if let Ok(text) = String::from_utf8(blob.bytes) {
340 return Some(((*name).to_string(), text));
341 }
342 }
343 }
344 None
345}
346
347fn path_crumbs(ctx: &Context, reference: &str, path: &str) -> String {
348 if path.is_empty() {
349 return String::new();
350 }
351 let mut out = String::from("<div class=\"toolbar\"><div class=\"crumbs\">");
352 out.push_str(&format!(
353 "<a href=\"{}/tree/{}/\">{}</a>",
354 ctx.link(""),
355 url_escape(reference),
356 escape(&ctx.record.display_name)
357 ));
358
359 let mut walked = String::new();
360 let parts: Vec<&str> = path.split('/').collect();
361 for (i, part) in parts.iter().enumerate() {
362 if !walked.is_empty() {
363 walked.push('/');
364 }
365 walked.push_str(part);
366 out.push_str("<span class=\"sep\">/</span>");
367 if i + 1 == parts.len() {
368 out.push_str(&escape(part));
369 } else {
370 out.push_str(&format!(
371 "<a href=\"{}/tree/{}/{}\">{}</a>",
372 ctx.link(""),
373 url_escape(reference),
374 url_escape(&walked),
375 escape(part)
376 ));
377 }
378 }
379 out.push_str("</div></div>");
380 out
381}
382
383pub async fn blob(
384 ctx: Context,
385 git_dir: PathBuf,
386 reference: String,
387 path: &str,
388) -> Result<Response<Body>> {
389 let dir = git_dir.clone();
390 let wanted = reference.clone();
391 let target = path.to_string();
392 let limits = ctx.state.config.read_limits();
393
394 let (refs, blob) = crate::git::exec::blocking(move || {
395 let refs = ref_list(&dir);
396 let resolved = resolve_ref(&dir, &wanted)?;
397 let blob = discover::blob(&dir, &resolved, &target, limits)?;
398 Ok::<_, Error>((refs, blob))
399 })
400 .await?;
401
402 let mut body = repo_header(&ctx, &refs, &reference);
403 body.push_str(&path_crumbs(&ctx, &reference, path));
404 body.push_str("<div class=\"panel\">\n");
405 body.push_str(&format!(
406 "<div class=\"panel-head\"><span>{}</span><span class=\"spacer\"></span>\
407 <code class=\"sha\">{}</code>\
408 <a href=\"/v1/repos/{}/{}/raw/{}?ref={}\">raw</a></div>\n",
409 escape(&human_size(blob.bytes.len() as u64)),
410 escape(short_sha(&blob.sha)),
411 url_escape(&ctx.record.account),
412 url_escape(&ctx.record.name),
413 url_escape(path),
414 url_escape(&reference),
415 ));
416
417 if is_probably_binary(&blob.bytes) {
418 body.push_str(
419 "<div class=\"empty\">This is a binary file and is not shown.</div>\n</div>\n",
420 );
421 } else {
422 let text = String::from_utf8_lossy(&blob.bytes);
423 body.push_str("<div class=\"code\">\n<table class=\"code-table\">\n");
424 for (i, line) in text.lines().enumerate() {
425 let n = i + 1;
426 body.push_str(&format!(
427 "<tr id=\"L{n}\"><td class=\"ln\"><a href=\"#L{n}\">{n}</a></td>\
428 <td class=\"src\">{}</td></tr>\n",
429 escape(line)
430 ));
431 }
432 body.push_str("</table>\n</div>\n</div>\n");
433 }
434
435 Ok(ok_html(html::document(&Page {
436 title: ctx.title(path),
437 heading: ctx.crumbs(&escape(path)),
438 body,
439 })))
440}
441
442pub async fn commits(ctx: Context, git_dir: PathBuf, reference: String) -> Result<Response<Body>> {
443 let dir = git_dir.clone();
444 let wanted = reference.clone();
445 let limits = ctx.state.config.read_limits();
446
447 let (refs, log) = crate::git::exec::blocking(move || {
448 let refs = ref_list(&dir);
449 let resolved = resolve_ref(&dir, &wanted)?;
450 let log = discover::log(&dir, &resolved, 100, None, limits)?;
451 Ok::<_, Error>((refs, log))
452 })
453 .await?;
454
455 let mut body = repo_header(&ctx, &refs, &reference);
456 body.push_str("<div class=\"panel\">\n<div class=\"panel-head\">commits</div>\n");
457 body.push_str("<table class=\"listing\">\n");
458
459 for item in log
460 .get("items")
461 .and_then(|i| i.as_array())
462 .into_iter()
463 .flatten()
464 {
465 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
466 let message = item.get("message").and_then(|m| m.as_str()).unwrap_or("");
467 let subject = message.lines().next().unwrap_or("");
468 let author = item
469 .get("author_name")
470 .and_then(|a| a.as_str())
471 .unwrap_or("unknown");
472 let at = item
473 .get("authored_at")
474 .and_then(|a| a.as_u64())
475 .unwrap_or(0);
476
477 body.push_str(&format!(
478 "<tr><td class=\"name\">\
479 <a class=\"commit-subject\" href=\"{}/commit/{}\">{}</a>\
480 <div class=\"commit-meta\">{} · <code class=\"sha\">{}</code></div></td>\
481 <td class=\"when\">{}</td></tr>\n",
482 ctx.link(""),
483 url_escape(sha),
484 escape(subject),
485 escape(author),
486 escape(short_sha(sha)),
487 escape(&relative_time(at))
488 ));
489 }
490
491 body.push_str("</table>\n</div>\n");
492
493 Ok(ok_html(html::document(&Page {
494 title: ctx.title("commits"),
495 heading: ctx.crumbs("commits"),
496 body,
497 })))
498}
499
500pub async fn commit(ctx: Context, git_dir: PathBuf, sha: String) -> Result<Response<Body>> {
501 let dir = git_dir.clone();
502 let wanted = sha.clone();
503
504 let detail = crate::git::exec::blocking(move || {
505 let resolved = discover::revision(&wanted)?;
506 discover::commit(&dir, &resolved)
507 })
508 .await?;
509
510 let info = detail.get("commit").cloned().unwrap_or(Value::Null);
511 let message = info.get("message").and_then(|m| m.as_str()).unwrap_or("");
512 let subject = message.lines().next().unwrap_or("");
513 let author = info
514 .get("author_name")
515 .and_then(|a| a.as_str())
516 .unwrap_or("unknown");
517 let at = info
518 .get("authored_at")
519 .and_then(|a| a.as_u64())
520 .unwrap_or(0);
521
522 let mut body = repo_header(&ctx, &[], default_short_ref(&ctx.record.default_branch));
523 body.push_str("<div class=\"panel\">\n");
524 body.push_str(&format!(
525 "<div class=\"panel-head\"><code class=\"sha\">{}</code>\
526 <span class=\"spacer\"></span><span>{} · {}</span></div>\n",
527 escape(short_sha(&sha)),
528 escape(author),
529 escape(&relative_time(at))
530 ));
531 body.push_str(&format!(
532 "<pre class=\"message\">{}</pre>\n</div>\n",
533 escape(message)
534 ));
535
536 let changed = detail
537 .get("changed")
538 .and_then(|c| c.as_array())
539 .cloned()
540 .unwrap_or_default();
541
542 if !changed.is_empty() {
543 body.push_str("<div class=\"panel\">\n");
544 body.push_str(&format!(
545 "<div class=\"panel-head\">{} changed</div>\n",
546 changed.len()
547 ));
548 body.push_str("<table class=\"listing\">\n");
549 for entry in &changed {
550 let status = entry
551 .get("status")
552 .and_then(|s| s.as_str())
553 .unwrap_or("M")
554 .chars()
555 .next()
556 .unwrap_or('M');
557 let path = entry.get("path").and_then(|p| p.as_str()).unwrap_or("");
558 body.push_str(&format!(
559 "<tr><td class=\"name\"><span class=\"status {status}\">{status}</span>\
560 <a href=\"{}/blob/{}/{}\">{}</a></td></tr>\n",
561 ctx.link(""),
562 url_escape(&sha),
563 url_escape(path),
564 escape(path)
565 ));
566 }
567 body.push_str("</table>\n</div>\n");
568 }
569
570 Ok(ok_html(html::document(&Page {
571 title: ctx.title(subject),
572 heading: ctx.crumbs(&escape(short_sha(&sha))),
573 body,
574 })))
575}
576
577pub async fn refs(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
578 let dir = git_dir.clone();
579 let value = crate::git::exec::blocking(move || discover::refs(&dir, None)).await?;
580
581 let mut body = repo_header(&ctx, &[], "");
582 body.push_str("<div class=\"panel\">\n<div class=\"panel-head\">refs</div>\n");
583 body.push_str("<table class=\"listing\">\n");
584 for item in value
585 .get("items")
586 .and_then(|i| i.as_array())
587 .into_iter()
588 .flatten()
589 {
590 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
591 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
592 body.push_str(&format!(
593 "<tr><td class=\"name\">{}</td><td class=\"size\"><code class=\"sha\">{}</code></td></tr>\n",
594 escape(name),
595 escape(short_sha(sha))
596 ));
597 }
598 body.push_str("</table>\n</div>\n");
599
600 Ok(ok_html(html::document(&Page {
601 title: ctx.title("refs"),
602 heading: ctx.crumbs("refs"),
603 body,
604 })))
605}
606
607/// Shown at `/` when the host has not named a repository to feature.
608pub fn welcome() -> Response<Body> {
609 let body = format!(
610 "<div class=\"panel\" style=\"margin-top:40px\"><div class=\"notice\">\
611 <strong>{} is running</strong>\
612 No repository is published at this address. Set {}HOME_REPO to \
613 <code>account/repo</code> to feature one here.</div></div>",
614 escape(crate::brand::NAME),
615 escape(crate::brand::env_prefix()),
616 );
617 response_html(html::document(&Page {
618 title: crate::brand::NAME.to_string(),
619 heading: String::new(),
620 body,
621 }))
622}
623
624fn response_html(body: String) -> Response<Body> {
625 crate::http::response::html(StatusCode::OK, body)
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631
632 #[test]
633 fn a_file_with_a_nul_byte_is_treated_as_binary() {
634 assert!(is_probably_binary(b"\x7fELF\0\0\0"));
635 assert!(!is_probably_binary(b"fn main() {}\n"));
636 // Only the head is inspected, so a large text file stays cheap to classify.
637 let mut long = vec![b'a'; 9000];
638 long.push(0);
639 assert!(!is_probably_binary(&long));
640 }
641
642 #[test]
643 fn sizes_read_the_way_a_person_expects() {
644 assert_eq!(human_size(0), "0 B");
645 assert_eq!(human_size(999), "999 B");
646 assert_eq!(human_size(1024), "1.0 KB");
647 assert_eq!(human_size(1536), "1.5 KB");
648 assert_eq!(human_size(1024 * 1024 * 3), "3.0 MB");
649 }
650
651 #[test]
652 fn a_short_sha_never_panics_on_an_odd_length() {
653 assert_eq!(short_sha("abcdef0123456789"), "abcdef01");
654 // Slicing would panic here; a truncated value must degrade, not crash.
655 assert_eq!(short_sha("abc"), "abc");
656 assert_eq!(short_sha(""), "");
657 }
658}