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.rs39.0 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// The audience is deliberately not developers. The people these pages are for have
8// used a phone, a chat app and a browser, and are meeting "a repository" for the
9// first time — so the front page is the README rendered as a document, the words
10// are Files, History and Branches rather than tree, log and refs, and the one row
11// of tabs never disappears or forgets which branch the reader was on.
12//
13// Every value that reaches the output goes through `html::escape` or `url_escape`.
14
15use super::html::{self, escape, url_escape, LinkBase, Page};
16use super::{default_short_ref, ok_html, resolve_ref};
17use crate::error::{Error, Result};
18use crate::git::discover;
19use crate::http::response::Body;
20use crate::http::AppState;
21use crate::store::RepoRecord;
22use hyper::{Response, StatusCode};
23use serde_json::Value;
24use std::path::{Path, PathBuf};
25use std::sync::Arc;
26
27pub struct Context {
28 pub record: RepoRecord,
29 /// Canonical URL prefix for every link this page emits.
30 pub base: String,
31 pub state: Arc<AppState>,
32}
33
34impl Context {
35 fn link(&self, suffix: &str) -> String {
36 format!("{}{}", self.base, suffix)
37 }
38
39 fn crumbs(&self, trail: &str) -> String {
40 let mut out = format!(
41 "<a href=\"{}\">{}</a>",
42 self.link(""),
43 escape(&self.record.display_name)
44 );
45 if !trail.is_empty() {
46 out.push_str(&format!("<span class=\"sep\">/</span>{trail}"));
47 }
48 out
49 }
50
51 fn title(&self, extra: &str) -> String {
52 if extra.is_empty() {
53 format!("{}/{}", self.record.account, self.record.display_name)
54 } else {
55 format!(
56 "{} · {}/{}",
57 extra, self.record.account, self.record.display_name
58 )
59 }
60 }
61
62 fn link_base(&self, reference: &str, dir: &str) -> LinkBase {
63 LinkBase {
64 repo: self.base.clone(),
65 reference: reference.to_string(),
66 dir: dir.to_string(),
67 }
68 }
69}
70
71/// The tab a page sits under. One of these is always lit, so a reader always
72/// knows where they are — the cheapest cure for getting lost.
73#[derive(Clone, Copy, PartialEq, Eq)]
74pub enum Tab {
75 Overview,
76 Files,
77 History,
78 Branches,
79}
80
81/// Names tried, in order, when looking for a README to render.
82const README_NAMES: &[&str] = &["README.md", "readme.md", "README", "README.markdown"];
83
84/// Files that are content but not code, and would be meaningless as text.
85fn is_probably_binary(bytes: &[u8]) -> bool {
86 // A NUL in the first few KiB is what git itself uses to call a file binary.
87 bytes.iter().take(8000).any(|b| *b == 0)
88}
89
90/// Whether the viewer should render this file as a document rather than as code.
91fn is_markdown(name: &str) -> bool {
92 let lower = name.to_lowercase();
93 lower.ends_with(".md") || lower.ends_with(".markdown")
94}
95
96fn relative_time(then: u64) -> String {
97 let now = crate::account::token::now_secs();
98 let delta = now.saturating_sub(then);
99 match delta {
100 0..=59 => "just now".to_string(),
101 60..=3599 => format!("{} min ago", delta / 60),
102 3600..=86399 => format!("{} hr ago", delta / 3600),
103 86400..=2591999 => format!("{} days ago", delta / 86400),
104 2592000..=31535999 => format!("{} months ago", delta / 2592000),
105 _ => format!("{} years ago", delta / 31536000),
106 }
107}
108
109fn human_size(bytes: u64) -> String {
110 const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
111 let mut value = bytes as f64;
112 let mut unit = 0;
113 while value >= 1024.0 && unit < UNITS.len() - 1 {
114 value /= 1024.0;
115 unit += 1;
116 }
117 if unit == 0 {
118 format!("{bytes} {}", UNITS[0])
119 } else {
120 format!("{value:.1} {}", UNITS[unit])
121 }
122}
123
124fn short_sha(sha: &str) -> &str {
125 sha.get(..8).unwrap_or(sha)
126}
127
128/// An initials disc, the way a chat app shows a contact with no photo.
129///
130/// The colour is a stable function of the name so the same author always gets the
131/// same colour, from a fixed palette of classes — the CSP forbids inline styles,
132/// which is exactly the property that keeps commit messages inert.
133fn avatar(name: &str) -> String {
134 let initials: String = name
135 .split_whitespace()
136 .take(2)
137 .filter_map(|word| word.chars().next())
138 .flat_map(|c| c.to_uppercase())
139 .collect();
140 let initials = if initials.is_empty() {
141 "?".to_string()
142 } else {
143 initials
144 };
145 let mut hash: u32 = 2166136261;
146 for byte in name.bytes() {
147 hash ^= u32::from(byte);
148 hash = hash.wrapping_mul(16777619);
149 }
150 format!(
151 "<span class=\"avatar av{}\">{}</span>",
152 hash % 8,
153 escape(&initials)
154 )
155}
156
157/// The little label in front of a file name: its extension, or a dot when it has
158/// none. A word a beginner can read, where an icon font would be another asset.
159fn file_chip(name: &str) -> String {
160 let extension = name
161 .rsplit_once('.')
162 .map(|(stem, ext)| if stem.is_empty() { "" } else { ext })
163 .unwrap_or("");
164 let label: String = extension.chars().take(4).collect();
165 let label = if label.is_empty() {
166 "·".to_string()
167 } else {
168 label.to_uppercase()
169 };
170 format!("<span class=\"chip\">{}</span>", escape(&label))
171}
172
173/// A change status letter as a word a person can read without a legend.
174fn change_label(status: char) -> (&'static str, &'static str) {
175 match status {
176 'A' => ("delta-add", "Added"),
177 'D' => ("delta-del", "Removed"),
178 'R' => ("delta-mod", "Renamed"),
179 'C' => ("delta-mod", "Copied"),
180 _ => ("delta-mod", "Changed"),
181 }
182}
183
184/// The header block every repository page carries: identity, then the tabs.
185fn repo_header(ctx: &Context, active: Tab, reference: &str) -> String {
186 let mut out = String::new();
187 out.push_str("<div class=\"repo-head\">\n");
188
189 let badge = if ctx.record.visibility.is_public() {
190 "<span class=\"badge public\">public</span>"
191 } else {
192 "<span class=\"badge\">private</span>"
193 };
194 out.push_str(&format!(
195 "<h1 class=\"repo-title\"><span class=\"repo-owner\">{} /</span> {}{}</h1>\n",
196 escape(&ctx.record.account),
197 escape(&ctx.record.display_name),
198 badge
199 ));
200
201 if let Some(description) = &ctx.record.description {
202 out.push_str(&format!(
203 "<p class=\"repo-desc\">{}</p>\n",
204 escape(description)
205 ));
206 }
207
208 // Only shown for public repositories: printing a clone URL that a reader cannot
209 // use without a credential they have not got is an invitation to a failure.
210 if ctx.record.visibility.is_public() {
211 let http = format!(
212 "{}/{}/{}.git",
213 ctx.state.config.public_host(),
214 ctx.record.account,
215 ctx.record.name
216 );
217 out.push_str("<div class=\"clone\">\n");
218 out.push_str("<span class=\"clone-label\">Get a copy:</span>\n");
219 out.push_str(&format!("<code>git clone {}</code>\n", escape(&http)));
220 out.push_str("</div>\n");
221 }
222
223 out.push_str("</div>\n");
224 out.push_str(&tabs(ctx, active, reference));
225 out
226}
227
228/// The persistent tab row. Every tab carries the current ref, so switching between
229/// Files and History never silently resets the reader to the default branch.
230fn tabs(ctx: &Context, active: Tab, reference: &str) -> String {
231 let r = url_escape(reference);
232 let entries: [(Tab, String, &str); 4] = [
233 (Tab::Overview, ctx.link(""), "Overview"),
234 (Tab::Files, format!("{}/tree/{}/", ctx.base, r), "Files"),
235 (
236 Tab::History,
237 format!("{}/commits/{}", ctx.base, r),
238 "History",
239 ),
240 (Tab::Branches, ctx.link("/refs"), "Branches"),
241 ];
242 let mut out = String::from("<nav class=\"tabs\">\n");
243 for (tab, href, label) in entries {
244 let on = if tab == active { " on" } else { "" };
245 out.push_str(&format!(
246 "<a class=\"tab{on}\" href=\"{href}\">{label}</a>\n"
247 ));
248 }
249 out.push_str("</nav>\n");
250 out
251}
252
253/// Where the branch menu should send the reader on each branch.
254enum Keep<'a> {
255 Tree(&'a str),
256 Blob(&'a str),
257 History,
258}
259
260/// The branch menu: a native `<details>` dropdown, because this surface has no
261/// script. Each entry preserves the reader's place — same folder, same file, same
262/// view — on the other branch, rather than dumping them back at the root.
263fn branch_menu(ctx: &Context, refs: &[(String, String)], current: &str, keep: &Keep) -> String {
264 if refs.is_empty() {
265 return String::new();
266 }
267 let mut out = String::from("<details class=\"menu\">\n");
268 out.push_str(&format!(
269 "<summary><span class=\"menu-label\">branch</span>{} ▾</summary>\n",
270 escape(current)
271 ));
272 out.push_str("<div class=\"menu-list\">\n");
273 for (_, short) in refs.iter().take(30) {
274 let suffix = match keep {
275 Keep::Tree(path) => format!("/tree/{}/{}", url_escape(short), url_escape(path)),
276 Keep::Blob(path) => format!("/blob/{}/{}", url_escape(short), url_escape(path)),
277 Keep::History => format!("/commits/{}", url_escape(short)),
278 };
279 let on = if short == current {
280 " class=\"on\""
281 } else {
282 ""
283 };
284 out.push_str(&format!(
285 "<a{on} href=\"{}{}\">{}</a>\n",
286 ctx.base,
287 suffix,
288 escape(short)
289 ));
290 }
291 out.push_str(&format!(
292 "<a class=\"menu-all\" href=\"{}\">All branches →</a>\n",
293 ctx.link("/refs")
294 ));
295 out.push_str("</div>\n</details>\n");
296 out
297}
298
299/// Branch and tag short names, branches first.
300fn ref_list(git_dir: &Path) -> Vec<(String, String)> {
301 let Ok(value) = discover::refs(git_dir, None) else {
302 return Vec::new();
303 };
304 let mut out = Vec::new();
305 for item in value
306 .get("items")
307 .and_then(|i| i.as_array())
308 .into_iter()
309 .flatten()
310 {
311 let Some(name) = item.get("name").and_then(|n| n.as_str()) else {
312 continue;
313 };
314 if let Some(short) = name.strip_prefix("refs/heads/") {
315 out.push((name.to_string(), short.to_string()));
316 }
317 }
318 out
319}
320
321/// "Latest change" strip above a listing: the most recent commit as one line, the
322/// way a chat shows its last message — the fastest possible answer to "is this
323/// thing alive?".
324fn pulse(ctx: &Context, latest: Option<&Value>) -> String {
325 let Some(item) = latest else {
326 return String::new();
327 };
328 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
329 let subject = item
330 .get("message")
331 .and_then(|m| m.as_str())
332 .and_then(|m| m.lines().next())
333 .unwrap_or("");
334 let author = item
335 .get("author_name")
336 .and_then(|a| a.as_str())
337 .unwrap_or("unknown");
338 let at = item
339 .get("authored_at")
340 .and_then(|a| a.as_u64())
341 .unwrap_or(0);
342 format!(
343 "<a class=\"pulse\" href=\"{}/commit/{}\">{}\
344 <span class=\"pulse-subject\">{}</span>\
345 <span class=\"pulse-meta\">{} · {}</span></a>\n",
346 ctx.base,
347 url_escape(sha),
348 avatar(author),
349 escape(subject),
350 escape(author),
351 escape(&relative_time(at))
352 )
353}
354
355fn empty_repo(ctx: &Context, reference: &str) -> Response<Body> {
356 let body = format!(
357 "{}<div class=\"panel\"><div class=\"notice\"><strong>Nothing here yet</strong>\
358 This project is brand new — no files have arrived. After the first \
359 <code>git push</code>, everything shows up here.</div></div>",
360 repo_header(ctx, Tab::Overview, reference)
361 );
362 ok_html(html::document(&Page {
363 title: ctx.title(""),
364 heading: ctx.crumbs(""),
365 body,
366 }))
367}
368
369// ── pages ───────────────────────────────────────────────────────────────────
370
371/// The front page of a repository: its README, rendered as a document.
372///
373/// This is the Markdoc idea applied to a repository — the README *is* the site,
374/// and the file listing is one tab away rather than the thing a visitor must read
375/// first. A repository without a README falls back to the listing, which is the
376/// only honest page left.
377pub async fn overview(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
378 let reference = default_short_ref(&ctx.record.default_branch).to_string();
379 let dir = git_dir.clone();
380 let wanted = reference.clone();
381
382 let found = crate::git::exec::blocking(move || {
383 // An empty repository has no resolvable ref at all, which is a normal state
384 // straight after `repo_create` and must not read as an error.
385 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
386 return Ok::<_, Error>(None);
387 };
388 Ok(Some(find_readme(&dir, &resolved, "")))
389 })
390 .await?;
391
392 let readme = match found {
393 None => return Ok(empty_repo(&ctx, &reference)),
394 Some(None) => return tree(ctx, git_dir, reference, "", Tab::Overview).await,
395 Some(Some(readme)) => readme,
396 };
397
398 let (name, source) = readme;
399 let mut body = repo_header(&ctx, Tab::Overview, &reference);
400 body.push_str(&format!(
401 "<div class=\"doc-source\">from <a href=\"{}/blob/{}/{}\">{}</a></div>\n",
402 ctx.base,
403 url_escape(&reference),
404 url_escape(&name),
405 escape(&name)
406 ));
407 body.push_str(&format!(
408 "<article class=\"doc\">{}</article>\n",
409 html::readme(&source, &ctx.link_base(&reference, ""))
410 ));
411 body.push_str(&format!(
412 "<div class=\"doc-more\">\
413 <a href=\"{base}/tree/{r}/\">Browse the files →</a>\
414 <a href=\"{base}/commits/{r}\">See what changed →</a>\
415 </div>\n",
416 base = ctx.base,
417 r = url_escape(&reference)
418 ));
419
420 Ok(ok_html(html::document(&Page {
421 title: ctx.title(""),
422 heading: ctx.crumbs(""),
423 body,
424 })))
425}
426
427pub async fn tree(
428 ctx: Context,
429 git_dir: PathBuf,
430 reference: String,
431 path: &str,
432 active: Tab,
433) -> Result<Response<Body>> {
434 let path = path.trim_end_matches('/').to_string();
435 let dir = git_dir.clone();
436 let wanted = reference.clone();
437 let sub = path.clone();
438 let limits = ctx.state.config.read_limits();
439
440 let rendered = crate::git::exec::blocking(move || {
441 let refs = ref_list(&dir);
442 // An empty repository has no resolvable ref at all, which is a normal state
443 // straight after `repo_create` and must not read as an error.
444 let Ok(resolved) = resolve_ref(&dir, &wanted) else {
445 return Ok::<_, Error>(None);
446 };
447 let listing = discover::tree(
448 &dir,
449 &resolved,
450 if sub.is_empty() { None } else { Some(&sub) },
451 )?;
452 let readme = find_readme(&dir, &resolved, &sub);
453 let latest = discover::log(&dir, &resolved, 1, None, limits)
454 .ok()
455 .and_then(|log| log.get("items")?.as_array()?.first().cloned());
456 Ok(Some((refs, listing, readme, latest)))
457 })
458 .await?;
459
460 let Some((refs, listing, readme, latest)) = rendered else {
461 return Ok(empty_repo(&ctx, &reference));
462 };
463
464 let mut body = repo_header(&ctx, active, &reference);
465 body.push_str("<div class=\"toolbar\">\n");
466 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::Tree(&path)));
467 body.push_str(&path_crumbs(&ctx, &reference, &path));
468 body.push_str("</div>\n");
469 if path.is_empty() {
470 body.push_str(&pulse(&ctx, latest.as_ref()));
471 }
472
473 body.push_str("<div class=\"panel\">\n");
474 body.push_str(&format!(
475 "<div class=\"panel-head\"><span class=\"name\">{}</span></div>\n",
476 if path.is_empty() {
477 escape("All files")
478 } else {
479 escape(&path)
480 }
481 ));
482
483 let items = listing
484 .get("items")
485 .and_then(|i| i.as_array())
486 .cloned()
487 .unwrap_or_default();
488
489 if items.is_empty() {
490 body.push_str("<div class=\"empty\">This folder is empty.</div>\n");
491 } else {
492 body.push_str("<table class=\"listing\">\n");
493
494 if !path.is_empty() {
495 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
496 body.push_str(&format!(
497 "<tr><td class=\"name\" colspan=\"2\">\
498 <span class=\"chip dir\">↰</span><a href=\"{}/tree/{}/{}\">back</a></td></tr>\n",
499 ctx.link(""),
500 url_escape(&reference),
501 url_escape(parent)
502 ));
503 }
504
505 // Directories first, then files, each alphabetically — the order a person
506 // expects, which git's own output does not guarantee.
507 let mut rows: Vec<&Value> = items.iter().collect();
508 rows.sort_by_key(|item| {
509 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
510 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
511 (kind != "dir", name.to_lowercase())
512 });
513
514 for item in rows {
515 let name = item.get("name").and_then(|n| n.as_str()).unwrap_or("");
516 let kind = item.get("type").and_then(|t| t.as_str()).unwrap_or("file");
517 let full = item.get("path").and_then(|p| p.as_str()).unwrap_or(name);
518 let size = item.get("size").and_then(|s| s.as_u64());
519
520 let (verb, chip) = match kind {
521 "dir" => ("tree", "<span class=\"chip dir\">▸</span>".to_string()),
522 "submodule" => ("tree", "<span class=\"chip\">⧉</span>".to_string()),
523 _ => ("blob", file_chip(name)),
524 };
525
526 body.push_str(&format!(
527 "<tr><td class=\"name\">{}<a href=\"{}/{}/{}/{}\">{}</a></td>\
528 <td class=\"size\">{}</td></tr>\n",
529 chip,
530 ctx.link(""),
531 verb,
532 url_escape(&reference),
533 url_escape(full),
534 escape(name),
535 size.filter(|_| kind == "file")
536 .map(human_size)
537 .unwrap_or_default()
538 ));
539 }
540 body.push_str("</table>\n");
541 }
542 body.push_str("</div>\n");
543
544 if let Some((name, source)) = readme {
545 body.push_str("<div class=\"panel\">\n");
546 body.push_str(&format!(
547 "<div class=\"panel-head\"><span class=\"name\">{}</span></div>\n",
548 escape(&name)
549 ));
550 body.push_str(&format!(
551 "<article class=\"doc\">{}</article>\n",
552 html::readme(&source, &ctx.link_base(&reference, &path))
553 ));
554 body.push_str("</div>\n");
555 }
556
557 Ok(ok_html(html::document(&Page {
558 title: ctx.title(if path.is_empty() { "" } else { &path }),
559 heading: ctx.crumbs(&escape(&reference)),
560 body,
561 })))
562}
563
564fn find_readme(git_dir: &Path, resolved: &str, dir: &str) -> Option<(String, String)> {
565 // A README is rendered inline, so a huge one is a page nobody can load. This is
566 // a display cap, not the API's blob limit.
567 let limits = discover::Limits {
568 max_blob_bytes: 1024 * 1024,
569 log_walk_budget: 0,
570 };
571 for name in README_NAMES {
572 let path = if dir.is_empty() {
573 (*name).to_string()
574 } else {
575 format!("{dir}/{name}")
576 };
577 if let Ok(blob) = discover::blob(git_dir, resolved, &path, limits) {
578 if let Ok(text) = String::from_utf8(blob.bytes) {
579 return Some(((*name).to_string(), text));
580 }
581 }
582 }
583 None
584}
585
586fn path_crumbs(ctx: &Context, reference: &str, path: &str) -> String {
587 if path.is_empty() {
588 return String::new();
589 }
590 let mut out = String::from("<div class=\"crumbs\">");
591 out.push_str(&format!(
592 "<a href=\"{}/tree/{}/\">{}</a>",
593 ctx.link(""),
594 url_escape(reference),
595 escape(&ctx.record.display_name)
596 ));
597
598 let mut walked = String::new();
599 let parts: Vec<&str> = path.split('/').collect();
600 for (i, part) in parts.iter().enumerate() {
601 if !walked.is_empty() {
602 walked.push('/');
603 }
604 walked.push_str(part);
605 out.push_str("<span class=\"sep\">/</span>");
606 if i + 1 == parts.len() {
607 out.push_str(&escape(part));
608 } else {
609 out.push_str(&format!(
610 "<a href=\"{}/tree/{}/{}\">{}</a>",
611 ctx.link(""),
612 url_escape(reference),
613 url_escape(&walked),
614 escape(part)
615 ));
616 }
617 }
618 out.push_str("</div>");
619 out
620}
621
622pub async fn blob(
623 ctx: Context,
624 git_dir: PathBuf,
625 reference: String,
626 path: &str,
627 plain: bool,
628) -> Result<Response<Body>> {
629 let dir = git_dir.clone();
630 let wanted = reference.clone();
631 let target = path.to_string();
632 let limits = ctx.state.config.read_limits();
633
634 let (refs, blob) = crate::git::exec::blocking(move || {
635 let refs = ref_list(&dir);
636 let resolved = resolve_ref(&dir, &wanted)?;
637 let blob = discover::blob(&dir, &resolved, &target, limits)?;
638 Ok::<_, Error>((refs, blob))
639 })
640 .await?;
641
642 let filename = path.rsplit('/').next().unwrap_or(path);
643 let rendered_markdown = is_markdown(filename) && !plain;
644
645 let mut body = repo_header(&ctx, Tab::Files, &reference);
646 body.push_str("<div class=\"toolbar\">\n");
647 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::Blob(path)));
648 body.push_str(&path_crumbs(&ctx, &reference, path));
649 body.push_str("</div>\n");
650
651 body.push_str("<div class=\"panel\">\n");
652 body.push_str(&format!(
653 "<div class=\"panel-head\">{}<span class=\"name\">{}</span><span>{}</span>\
654 <span class=\"spacer\"></span>{}\
655 <a href=\"{}/raw/{}/{}\">Download</a></div>\n",
656 file_chip(filename),
657 escape(filename),
658 escape(&human_size(blob.bytes.len() as u64)),
659 markdown_toggle(&ctx, &reference, path, plain),
660 ctx.base,
661 url_escape(&reference),
662 url_escape(path),
663 ));
664
665 if is_probably_binary(&blob.bytes) {
666 body.push_str(
667 "<div class=\"empty\">This file can't be shown as text — \
668 use Download to save it to your computer.</div>\n</div>\n",
669 );
670 } else if rendered_markdown {
671 let parent = path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
672 let text = String::from_utf8_lossy(&blob.bytes);
673 body.push_str(&format!(
674 "<article class=\"doc\">{}</article>\n</div>\n",
675 html::readme(&text, &ctx.link_base(&reference, parent))
676 ));
677 } else {
678 let text = String::from_utf8_lossy(&blob.bytes);
679 body.push_str("<div class=\"code\">\n<table class=\"code-table\">\n");
680 for (i, line) in text.lines().enumerate() {
681 let n = i + 1;
682 body.push_str(&format!(
683 "<tr id=\"L{n}\"><td class=\"ln\"><a href=\"#L{n}\">{n}</a></td>\
684 <td class=\"src\">{}</td></tr>\n",
685 escape(line)
686 ));
687 }
688 body.push_str("</table>\n</div>\n</div>\n");
689 }
690
691 Ok(ok_html(html::document(&Page {
692 title: ctx.title(path),
693 heading: ctx.crumbs(&escape(path)),
694 body,
695 })))
696}
697
698/// The Formatted / Plain switch for markdown files. Both states are links, because
699/// state on this surface lives in the URL and nowhere else.
700fn markdown_toggle(ctx: &Context, reference: &str, path: &str, plain: bool) -> String {
701 if !is_markdown(path.rsplit('/').next().unwrap_or(path)) {
702 return String::new();
703 }
704 let href = format!(
705 "{}/blob/{}/{}",
706 ctx.base,
707 url_escape(reference),
708 url_escape(path)
709 );
710 if plain {
711 format!("<a href=\"{href}\">Formatted</a>")
712 } else {
713 format!("<a href=\"{href}?plain=1\">Plain text</a>")
714 }
715}
716
717/// Raw bytes of one file, addressed like the other viewer URLs.
718///
719/// This exists so "Download" works for an anonymous visitor and so a README's
720/// relative images have somewhere to point — the REST raw endpoint requires a
721/// credential this surface deliberately does not have. Access still goes through
722/// `open_repo_for_reading`; this is a different rendering, not a different rule.
723pub async fn raw(
724 ctx: Context,
725 git_dir: PathBuf,
726 reference: String,
727 path: &str,
728) -> Result<Response<Body>> {
729 let dir = git_dir.clone();
730 let wanted = reference.clone();
731 let target = path.to_string();
732 let limits = ctx.state.config.read_limits();
733
734 let blob = crate::git::exec::blocking(move || {
735 let resolved = resolve_ref(&dir, &wanted)?;
736 discover::blob(&dir, &resolved, &target, limits)
737 })
738 .await?;
739
740 Ok(crate::http::response::blob(
741 blob.bytes,
742 &blob.sha,
743 blob.immutable,
744 ))
745}
746
747/// How many commits one History page shows. Small enough to load instantly on a
748/// phone; the pager carries the reader further back.
749const HISTORY_PAGE: usize = 50;
750
751pub async fn commits(
752 ctx: Context,
753 git_dir: PathBuf,
754 reference: String,
755 from: Option<String>,
756) -> Result<Response<Body>> {
757 let dir = git_dir.clone();
758 let wanted = reference.clone();
759 let start = from.clone();
760 let limits = ctx.state.config.read_limits();
761
762 let (refs, log) = crate::git::exec::blocking(move || {
763 let refs = ref_list(&dir);
764 let resolved = resolve_ref(&dir, &wanted)?;
765 // Paging is "start the walk at this commit": the page boundary is itself a
766 // commit sha, so the link stays valid no matter how much history arrives
767 // after it. One extra row is fetched to learn whether an older page exists.
768 let top = match &start {
769 Some(sha) => sha.clone(),
770 None => resolved,
771 };
772 let log = discover::log(&dir, &top, HISTORY_PAGE + 1, None, limits)?;
773 Ok::<_, Error>((refs, log))
774 })
775 .await?;
776
777 let items = log
778 .get("items")
779 .and_then(|i| i.as_array())
780 .cloned()
781 .unwrap_or_default();
782
783 let mut body = repo_header(&ctx, Tab::History, &reference);
784 body.push_str("<div class=\"toolbar\">\n");
785 body.push_str(&branch_menu(&ctx, &refs, &reference, &Keep::History));
786 body.push_str("</div>\n");
787
788 body.push_str("<div class=\"panel\">\n");
789 body.push_str(
790 "<div class=\"panel-head\"><span class=\"name\">Every change, newest first</span></div>\n",
791 );
792 if items.is_empty() {
793 body.push_str("<div class=\"empty\">No changes yet.</div>\n");
794 }
795 body.push_str("<div class=\"feed\">\n");
796
797 for item in items.iter().take(HISTORY_PAGE) {
798 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
799 let message = item.get("message").and_then(|m| m.as_str()).unwrap_or("");
800 let subject = message.lines().next().unwrap_or("");
801 let author = item
802 .get("author_name")
803 .and_then(|a| a.as_str())
804 .unwrap_or("unknown");
805 let at = item
806 .get("authored_at")
807 .and_then(|a| a.as_u64())
808 .unwrap_or(0);
809
810 body.push_str(&format!(
811 "<a class=\"feed-item\" href=\"{}/commit/{}\">{}\
812 <span class=\"feed-main\"><span class=\"feed-subject\">{}</span>\
813 <span class=\"feed-meta\">{} · {}</span></span>\
814 <code class=\"sha\">{}</code></a>\n",
815 ctx.base,
816 url_escape(sha),
817 avatar(author),
818 escape(subject),
819 escape(author),
820 escape(&relative_time(at)),
821 escape(short_sha(sha))
822 ));
823 }
824
825 body.push_str("</div>\n</div>\n");
826
827 let older = items
828 .get(HISTORY_PAGE)
829 .and_then(|item| item.get("sha"))
830 .and_then(|s| s.as_str());
831 if from.is_some() || older.is_some() {
832 body.push_str("<div class=\"pager\">\n");
833 if from.is_some() {
834 body.push_str(&format!(
835 "<a href=\"{}/commits/{}\">↑ Back to the latest</a>\n",
836 ctx.base,
837 url_escape(&reference)
838 ));
839 }
840 body.push_str("<span class=\"spacer\"></span>\n");
841 if let Some(sha) = older {
842 body.push_str(&format!(
843 "<a href=\"{}/commits/{}?from={}\">Older →</a>\n",
844 ctx.base,
845 url_escape(&reference),
846 url_escape(sha)
847 ));
848 }
849 body.push_str("</div>\n");
850 }
851
852 Ok(ok_html(html::document(&Page {
853 title: ctx.title("history"),
854 heading: ctx.crumbs("history"),
855 body,
856 })))
857}
858
859pub async fn commit(ctx: Context, git_dir: PathBuf, sha: String) -> Result<Response<Body>> {
860 let dir = git_dir.clone();
861 let wanted = sha.clone();
862
863 let detail = crate::git::exec::blocking(move || {
864 let resolved = discover::revision(&wanted)?;
865 discover::commit(&dir, &resolved)
866 })
867 .await?;
868
869 let info = detail.get("commit").cloned().unwrap_or(Value::Null);
870 let message = info.get("message").and_then(|m| m.as_str()).unwrap_or("");
871 let subject = message.lines().next().unwrap_or("");
872 let author = info
873 .get("author_name")
874 .and_then(|a| a.as_str())
875 .unwrap_or("unknown");
876 let at = info
877 .get("authored_at")
878 .and_then(|a| a.as_u64())
879 .unwrap_or(0);
880
881 // The tabs carry the commit itself: Files shows the project exactly as it was
882 // at this change, History walks back from here. A commit belongs to no single
883 // branch, so pretending it was reached from one would sometimes be a lie.
884 let mut body = repo_header(&ctx, Tab::History, &sha);
885 body.push_str("<div class=\"panel\">\n");
886 body.push_str(&format!(
887 "<div class=\"panel-head\">{}<span class=\"feed-main\">\
888 <span class=\"feed-subject\">{}</span>\
889 <span class=\"feed-meta\">{} · {}</span></span>\
890 <code class=\"sha\">{}</code></div>\n",
891 avatar(author),
892 escape(subject),
893 escape(author),
894 escape(&relative_time(at)),
895 escape(short_sha(&sha))
896 ));
897
898 // The body of the message, when there is more than the subject line.
899 let rest = message
900 .strip_prefix(subject)
901 .unwrap_or("")
902 .trim_matches('\n');
903 if !rest.is_empty() {
904 body.push_str(&format!("<pre class=\"message\">{}</pre>\n", escape(rest)));
905 }
906 body.push_str("</div>\n");
907
908 let changed = detail
909 .get("changed")
910 .and_then(|c| c.as_array())
911 .cloned()
912 .unwrap_or_default();
913
914 if !changed.is_empty() {
915 body.push_str("<div class=\"panel\">\n");
916 body.push_str(&format!(
917 "<div class=\"panel-head\"><span class=\"name\">What changed</span>\
918 <span>{} file{}</span></div>\n",
919 changed.len(),
920 if changed.len() == 1 { "" } else { "s" }
921 ));
922 body.push_str("<table class=\"listing\">\n");
923 for entry in &changed {
924 let status = entry
925 .get("status")
926 .and_then(|s| s.as_str())
927 .unwrap_or("M")
928 .chars()
929 .next()
930 .unwrap_or('M');
931 let (class, word) = change_label(status);
932 let path = entry.get("path").and_then(|p| p.as_str()).unwrap_or("");
933 body.push_str(&format!(
934 "<tr><td class=\"name\"><span class=\"delta {class}\">{word}</span>\
935 <a href=\"{}/blob/{}/{}\">{}</a></td></tr>\n",
936 ctx.link(""),
937 url_escape(&sha),
938 url_escape(path),
939 escape(path)
940 ));
941 }
942 body.push_str("</table>\n</div>\n");
943 }
944
945 Ok(ok_html(html::document(&Page {
946 title: ctx.title(subject),
947 heading: ctx.crumbs(&escape(short_sha(&sha))),
948 body,
949 })))
950}
951
952/// Branches and tags, in words: what they are called, how fresh they are, and a
953/// way into each one — never a bare table of fully-qualified ref names.
954pub async fn branches(ctx: Context, git_dir: PathBuf) -> Result<Response<Body>> {
955 let dir = git_dir.clone();
956 let value = crate::git::exec::blocking(move || discover::refs(&dir, None)).await?;
957
958 let default_ref = default_short_ref(&ctx.record.default_branch).to_string();
959 let mut body = repo_header(&ctx, Tab::Branches, &default_ref);
960
961 let items: Vec<Value> = value
962 .get("items")
963 .and_then(|i| i.as_array())
964 .cloned()
965 .unwrap_or_default();
966
967 for (section, prefix, blurb) in [
968 (
969 "Branches",
970 "refs/heads/",
971 "Lines of work. Each one is a complete copy of the project.",
972 ),
973 (
974 "Tags",
975 "refs/tags/",
976 "Named moments — usually releases — frozen in time.",
977 ),
978 ] {
979 let rows: Vec<(&str, &Value)> = items
980 .iter()
981 .filter_map(|item| {
982 let name = item.get("name")?.as_str()?;
983 Some((name.strip_prefix(prefix)?, item))
984 })
985 .collect();
986 if rows.is_empty() {
987 continue;
988 }
989
990 body.push_str("<div class=\"panel\">\n");
991 body.push_str(&format!(
992 "<div class=\"panel-head\"><span class=\"name\">{section}</span>\
993 <span>{blurb}</span></div>\n"
994 ));
995 body.push_str("<table class=\"listing\">\n");
996 for (short, item) in rows {
997 let sha = item.get("sha").and_then(|s| s.as_str()).unwrap_or("");
998 let at = item.get("at").and_then(|a| a.as_u64()).unwrap_or(0);
999 let mark = if short == default_ref {
1000 " <span class=\"badge\">default</span>"
1001 } else {
1002 ""
1003 };
1004 body.push_str(&format!(
1005 "<tr><td class=\"name\"><a href=\"{base}/tree/{r}/\">{name}</a>{mark}</td>\
1006 <td class=\"size\"><a href=\"{base}/commits/{r}\">history</a></td>\
1007 <td class=\"when\">{when}</td>\
1008 <td class=\"size\"><code class=\"sha\">{sha}</code></td></tr>\n",
1009 base = ctx.base,
1010 r = url_escape(short),
1011 name = escape(short),
1012 mark = mark,
1013 when = escape(&relative_time(at)),
1014 sha = escape(short_sha(sha))
1015 ));
1016 }
1017 body.push_str("</table>\n</div>\n");
1018 }
1019
1020 if items.is_empty() {
1021 body.push_str(
1022 "<div class=\"panel\"><div class=\"empty\">No branches yet — \
1023 they appear with the first push.</div></div>\n",
1024 );
1025 }
1026
1027 Ok(ok_html(html::document(&Page {
1028 title: ctx.title("branches"),
1029 heading: ctx.crumbs("branches"),
1030 body,
1031 })))
1032}
1033
1034/// Shown at `/` when the host has not named a repository to feature.
1035pub fn welcome() -> Response<Body> {
1036 let body = format!(
1037 "<div class=\"panel welcome\"><div class=\"notice\">\
1038 <strong>{} is up and running</strong>\
1039 No project is published at this address yet. Set {}HOME_REPO to \
1040 <code>account/repo</code> to feature one here.</div></div>",
1041 escape(crate::brand::NAME),
1042 escape(crate::brand::env_prefix()),
1043 );
1044 ok_html(html::document(&Page {
1045 title: crate::brand::NAME.to_string(),
1046 heading: String::new(),
1047 body,
1048 }))
1049}
1050
1051/// A friendly 404 for the browser surface.
1052///
1053/// The API's problem+json is the right answer for a program and the wrong one for
1054/// a person who tapped a link in a chat. Deliberately identical for "does not
1055/// exist" and "exists but is private", for the same reason the status code is:
1056/// the page must not leak which one it was.
1057pub fn not_found() -> Response<Body> {
1058 let body = "<div class=\"panel welcome\"><div class=\"notice\">\
1059 <strong>There's nothing at this address</strong>\
1060 The link may be mistyped, or the project may be private. \
1061 If someone sent you here, ask them to check the link — \
1062 or head to the <a href=\"/\">front page</a>.</div></div>"
1063 .to_string();
1064 crate::http::response::html(
1065 StatusCode::NOT_FOUND,
1066 html::document(&Page {
1067 title: "Not found".to_string(),
1068 heading: String::new(),
1069 body,
1070 }),
1071 )
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn a_file_with_a_nul_byte_is_treated_as_binary() {
1080 assert!(is_probably_binary(b"\x7fELF\0\0\0"));
1081 assert!(!is_probably_binary(b"fn main() {}\n"));
1082 // Only the head is inspected, so a large text file stays cheap to classify.
1083 let mut long = vec![b'a'; 9000];
1084 long.push(0);
1085 assert!(!is_probably_binary(&long));
1086 }
1087
1088 #[test]
1089 fn sizes_read_the_way_a_person_expects() {
1090 assert_eq!(human_size(0), "0 B");
1091 assert_eq!(human_size(999), "999 B");
1092 assert_eq!(human_size(1024), "1.0 KB");
1093 assert_eq!(human_size(1536), "1.5 KB");
1094 assert_eq!(human_size(1024 * 1024 * 3), "3.0 MB");
1095 }
1096
1097 #[test]
1098 fn a_short_sha_never_panics_on_an_odd_length() {
1099 assert_eq!(short_sha("abcdef0123456789"), "abcdef01");
1100 // Slicing would panic here; a truncated value must degrade, not crash.
1101 assert_eq!(short_sha("abc"), "abc");
1102 assert_eq!(short_sha(""), "");
1103 }
1104
1105 #[test]
1106 fn markdown_is_recognised_by_extension_alone() {
1107 assert!(is_markdown("README.md"));
1108 assert!(is_markdown("GUIDE.MARKDOWN"));
1109 assert!(!is_markdown("main.rs"));
1110 // A file literally named `md` has no extension and is not markdown.
1111 assert!(!is_markdown("md"));
1112 }
1113
1114 #[test]
1115 fn an_avatar_is_stable_and_always_printable() {
1116 // The same author must get the same colour on every page load, and a name
1117 // that is all whitespace must still render something.
1118 assert_eq!(avatar("Ada Lovelace"), avatar("Ada Lovelace"));
1119 assert!(avatar("Ada Lovelace").contains("AL"));
1120 assert!(avatar(" ").contains('?'));
1121 // The class index stays inside the palette.
1122 for name in ["a", "b", "Grace Hopper", "линус"] {
1123 let out = avatar(name);
1124 assert!(
1125 (0..8).any(|i| out.contains(&format!("av{i}"))),
1126 "no palette class in {out}"
1127 );
1128 }
1129 }
1130
1131 #[test]
1132 fn a_file_chip_is_short_and_never_carries_markup() {
1133 assert_eq!(file_chip("main.rs"), "<span class=\"chip\">RS</span>");
1134 assert_eq!(file_chip("notes"), "<span class=\"chip\">·</span>");
1135 // A dotfile's "extension" is its name; the stem check keeps the dot label.
1136 assert_eq!(file_chip(".gitignore"), "<span class=\"chip\">·</span>");
1137 // Long extensions are truncated, hostile ones are escaped.
1138 let hostile = file_chip("a.<script>");
1139 assert!(
1140 !hostile.contains("<scr") && !hostile.contains("<SCR"),
1141 "{hostile}"
1142 );
1143 }
1144
1145 #[test]
1146 fn change_letters_become_words() {
1147 assert_eq!(change_label('A').1, "Added");
1148 assert_eq!(change_label('D').1, "Removed");
1149 assert_eq!(change_label('M').1, "Changed");
1150 assert_eq!(change_label('R').1, "Renamed");
1151 assert_eq!(change_label('X').1, "Changed");
1152 }
1153}