samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
747 lines· 22.3 KB ·Rust
Raw
1//! Browsing code: repository home, trees, files, raw blobs and history.
2
3use std::process::Stdio;
4
5use askama::Template;
6use axum::body::Body;
7use axum::extract::{Path, Query, State};
8use axum::http::{HeaderValue, header};
9use axum::response::IntoResponse;
10use git2::{BranchType, ObjectType, Oid, Repository};
11use serde::Deserialize;
12use tokio::process::Command;
13use tokio_util::io::ReaderStream;
14
15use super::fmt::urlpath;
16use super::repo::{RepoCtx, load_repo};
17use super::{AppError, Ctx, WebResult, blocking, redirect, render};
18use crate::db;
19use crate::git::{self, CommitInfo, DiffSummary, RefKind, Resolved, TreeEntry};
20use crate::highlight;
21use crate::markdown::{self, LinkBase};
22use crate::state::AppState;
23
24const COMMITS_PER_PAGE: usize = 30;
25const MAX_DISPLAY_BYTES: usize = 1024 * 1024;
26
27// ---------------------------------------------------------------------------
28// Shared view pieces
29// ---------------------------------------------------------------------------
30
31pub struct RefLink {
32 pub name: String,
33 pub href: String,
34 pub selected: bool,
35}
36
37/// The branch/tag switcher shown above code listings.
38pub struct RefSelector {
39 pub current: String,
40 pub kind: RefKind,
41 pub branches: Vec<RefLink>,
42 pub tags: Vec<RefLink>,
43}
44
45impl RefSelector {
46 /// `view` is `tree`, `blob` or `commits`; `path` is kept when switching.
47 fn build(repo: &Repository, base: &str, view: &str, current: &Resolved, path: &str) -> Self {
48 let link = |name: &str, selected: bool| {
49 let mut href = format!("{base}/{view}/{}", urlpath(name));
50 if !path.is_empty() {
51 href.push('/');
52 href.push_str(&urlpath(path));
53 }
54 RefLink {
55 name: name.to_string(),
56 href,
57 selected,
58 }
59 };
60 let branches = git::branch_names(repo)
61 .unwrap_or_default()
62 .into_iter()
63 .map(|b| link(&b, current.kind == RefKind::Branch && current.name == b))
64 .collect();
65 let tags = git::tag_names(repo)
66 .unwrap_or_default()
67 .into_iter()
68 .map(|t| link(&t, current.kind == RefKind::Tag && current.name == t))
69 .collect();
70 let current_label = match current.kind {
71 RefKind::Commit => current.name.chars().take(7).collect(),
72 _ => current.name.clone(),
73 };
74 RefSelector {
75 current: current_label,
76 kind: current.kind,
77 branches,
78 tags,
79 }
80 }
81
82 pub fn icon(&self) -> &'static str {
83 match self.kind {
84 RefKind::Tag => "tag",
85 _ => "git-branch",
86 }
87 }
88}
89
90pub struct Crumb {
91 pub name: String,
92 pub href: String,
93}
94
95fn breadcrumbs(base: &str, refname: &str, path: &str) -> Vec<Crumb> {
96 let mut crumbs = Vec::new();
97 let mut acc = String::new();
98 for seg in path.split('/').filter(|s| !s.is_empty()) {
99 if !acc.is_empty() {
100 acc.push('/');
101 }
102 acc.push_str(seg);
103 crumbs.push(Crumb {
104 name: seg.to_string(),
105 href: format!("{base}/tree/{}/{}", urlpath(refname), urlpath(&acc)),
106 });
107 }
108 crumbs
109}
110
111pub struct EntryRow {
112 pub entry: TreeEntry,
113 pub href: String,
114}
115
116/// Resolves `{ref}/{path}` or returns 404.
117fn resolve(repo: &Repository, rest: &str) -> Result<Resolved, AppError> {
118 git::resolve(repo, rest).ok_or(AppError::NotFound)
119}
120
121fn link_base(base: &str, refname: &str, dir: &str) -> LinkBase {
122 LinkBase {
123 blob_root: format!("{base}/blob/{}", urlpath(refname)),
124 raw_root: format!("{base}/raw/{}", urlpath(refname)),
125 dir: dir.to_string(),
126 }
127}
128
129// ---------------------------------------------------------------------------
130// Repository home and trees
131// ---------------------------------------------------------------------------
132
133pub struct Readme {
134 pub name: String,
135 pub html: String,
136 pub is_markdown: bool,
137}
138
139pub struct TreeView {
140 pub refsel: RefSelector,
141 pub refname: String,
142 pub path: String,
143 pub crumbs: Vec<Crumb>,
144 pub entries: Vec<EntryRow>,
145 pub parent_href: Option<String>,
146 pub latest: Option<CommitInfo>,
147 pub commit_count: usize,
148 pub branch_count: usize,
149 pub tag_count: usize,
150 pub readme: Option<Readme>,
151 pub history_href: String,
152 pub recent_branch: Option<(String, i64)>,
153}
154
155#[derive(Template)]
156#[template(path = "repo/code.html")]
157struct CodePage {
158 ctx: Ctx,
159 r: RepoCtx,
160 tab: &'static str,
161 view: TreeView,
162 is_root: bool,
163}
164
165#[derive(Template)]
166#[template(path = "repo/empty.html")]
167struct EmptyPage {
168 ctx: Ctx,
169 r: RepoCtx,
170 tab: &'static str,
171}
172
173fn build_tree_view(
174 repo: &Repository,
175 base: &str,
176 resolved: &Resolved,
177 with_stats: bool,
178 default_branch: &str,
179) -> Result<Option<TreeView>, AppError> {
180 let path = resolved.path.trim_matches('/').to_string();
181 let Some(entries) = git::list_tree(repo, resolved.commit, &path)? else {
182 return Ok(None);
183 };
184 let refname = resolved.name.clone();
185 let entries = entries
186 .into_iter()
187 .map(|e| {
188 let view = if e.is_dir() { "tree" } else { "blob" };
189 let href = format!("{base}/{view}/{}/{}", urlpath(&refname), urlpath(&e.path));
190 EntryRow { entry: e, href }
191 })
192 .collect();
193 let latest = if path.is_empty() {
194 Some(git::commit_info(repo, resolved.commit)?)
195 } else {
196 git::log(repo, resolved.commit, Some(&path), 0, 1)?
197 .0
198 .into_iter()
199 .next()
200 };
201 let readme = git::find_readme(repo, resolved.commit, &path)?.map(|(name, text)| {
202 let lower = name.to_ascii_lowercase();
203 let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown");
204 let html = if is_markdown {
205 markdown::render(&text, Some(&link_base(base, &refname, &path)))
206 } else {
207 highlight::escape(&text)
208 };
209 Readme {
210 name,
211 html,
212 is_markdown,
213 }
214 });
215 let parent_href = (!path.is_empty()).then(|| match path.rsplit_once('/') {
216 Some((parent, _)) => format!("{base}/tree/{}/{}", urlpath(&refname), urlpath(parent)),
217 None => format!("{base}/tree/{}", urlpath(&refname)),
218 });
219 let (commit_count, branch_count, tag_count) = if with_stats {
220 (
221 git::commit_count(repo, resolved.commit)?,
222 git::branch_names(repo)?.len(),
223 git::tag_names(repo)?.len(),
224 )
225 } else {
226 (0, 0, 0)
227 };
228 let recent_branch = if with_stats {
229 recently_pushed_branch(repo, default_branch)
230 } else {
231 None
232 };
233 let mut history_href = format!("{base}/commits/{}", urlpath(&refname));
234 if !path.is_empty() {
235 history_href.push('/');
236 history_href.push_str(&urlpath(&path));
237 }
238 Ok(Some(TreeView {
239 refsel: RefSelector::build(repo, base, "tree", resolved, &path),
240 crumbs: breadcrumbs(base, &refname, &path),
241 refname,
242 path,
243 entries,
244 parent_href,
245 latest,
246 commit_count,
247 branch_count,
248 tag_count,
249 readme,
250 history_href,
251 recent_branch,
252 }))
253}
254
255/// The most recently updated non-default branch, if it changed in the last hour.
256fn recently_pushed_branch(repo: &Repository, default_branch: &str) -> Option<(String, i64)> {
257 let cutoff = db::now() - 3600;
258 repo.branches(Some(BranchType::Local))
259 .ok()?
260 .flatten()
261 .filter_map(|(b, _)| {
262 let name = b.name().ok()??.to_string();
263 let time = b.get().peel_to_commit().ok()?.committer().when().seconds();
264 (name != default_branch && time > cutoff).then_some((name, time))
265 })
266 .max_by_key(|(_, t)| *t)
267}
268
269pub async fn repo_home(
270 State(state): State<AppState>,
271 ctx: Ctx,
272 Path((owner, name)): Path<(String, String)>,
273) -> WebResult {
274 if let Some(stripped) = name.strip_suffix(".git") {
275 return Ok(redirect(&format!("/{owner}/{stripped}")));
276 }
277 let r = load_repo(&state, &ctx, &owner, &name).await?;
278 let (path, base, default) = (r.path.clone(), r.base(), r.repo.default_branch.clone());
279 let view = blocking(move || {
280 let repo = git::open(&path)?;
281 if git::is_empty(&repo) {
282 return Ok(None);
283 }
284 let head = git::resolve(&repo, &default).or_else(|| {
285 let branch = git::branch_names(&repo).ok()?.into_iter().next()?;
286 git::resolve(&repo, &branch)
287 });
288 let Some(resolved) = head else {
289 return Ok(None);
290 };
291 build_tree_view(&repo, &base, &resolved, true, &default)
292 })
293 .await?;
294 let Some(mut view) = view else {
295 return render(&EmptyPage {
296 ctx,
297 r,
298 tab: "code",
299 });
300 };
301 // Only suggest opening a pull request to people who can push, and only once.
302 if let Some((branch, _)) = &view.recent_branch {
303 let has_pr = db::list_pulls(&state.db, r.repo.id, true)
304 .await?
305 .iter()
306 .any(|p| &p.head_branch == branch);
307 if has_pr || !r.access.write {
308 view.recent_branch = None;
309 }
310 }
311 render(&CodePage {
312 ctx,
313 r,
314 tab: "code",
315 view,
316 is_root: true,
317 })
318}
319
320pub async fn tree(
321 State(state): State<AppState>,
322 ctx: Ctx,
323 Path((owner, name, rest)): Path<(String, String, String)>,
324) -> WebResult {
325 let r = load_repo(&state, &ctx, &owner, &name).await?;
326 let (path, base, default) = (r.path.clone(), r.base(), r.repo.default_branch.clone());
327 let view = blocking(move || {
328 let repo = git::open(&path)?;
329 let resolved = resolve(&repo, &rest)?;
330 if let Some(ObjectType::Blob) = git::path_type(&repo, resolved.commit, &resolved.path)? {
331 return Ok(Err(format!("{base}/blob/{rest}")));
332 }
333 let is_root = resolved.path.is_empty();
334 Ok(Ok((
335 build_tree_view(&repo, &base, &resolved, is_root, &default)?,
336 is_root,
337 )))
338 })
339 .await?;
340 match view {
341 Err(blob_url) => Ok(redirect(&urlpath(&blob_url))),
342 Ok((Some(view), is_root)) => render(&CodePage {
343 ctx,
344 r,
345 tab: "code",
346 view,
347 is_root,
348 }),
349 Ok((None, _)) => Err(AppError::NotFound),
350 }
351}
352
353// ---------------------------------------------------------------------------
354// Files
355// ---------------------------------------------------------------------------
356
357pub enum BlobContent {
358 Code { lines: Vec<String> },
359 Markdown { html: String },
360 Image { src: String },
361 Binary,
362 TooLarge,
363}
364
365pub struct BlobView {
366 pub refsel: RefSelector,
367 pub refname: String,
368 pub path: String,
369 pub file_name: String,
370 pub crumbs: Vec<Crumb>,
371 pub size: usize,
372 pub line_count: usize,
373 pub language: Option<&'static str>,
374 pub content: BlobContent,
375 pub latest: Option<CommitInfo>,
376 pub raw_href: String,
377 pub history_href: String,
378 pub is_markdown: bool,
379 pub plain: bool,
380}
381
382#[derive(Template)]
383#[template(path = "repo/blob.html")]
384struct BlobPage {
385 ctx: Ctx,
386 r: RepoCtx,
387 tab: &'static str,
388 view: BlobView,
389}
390
391#[derive(Deserialize)]
392pub struct BlobQuery {
393 plain: Option<String>,
394}
395
396fn is_image(path: &str) -> bool {
397 let lower = path.to_ascii_lowercase();
398 [
399 ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".svg",
400 ]
401 .iter()
402 .any(|ext| lower.ends_with(ext))
403}
404
405pub async fn blob(
406 State(state): State<AppState>,
407 ctx: Ctx,
408 Path((owner, name, rest)): Path<(String, String, String)>,
409 Query(q): Query<BlobQuery>,
410) -> WebResult {
411 let r = load_repo(&state, &ctx, &owner, &name).await?;
412 let plain = q.plain.is_some_and(|p| p == "1");
413 let (path, base) = (r.path.clone(), r.base());
414 let view = blocking(move || {
415 let repo = git::open(&path)?;
416 let resolved = resolve(&repo, &rest)?;
417 let file_path = resolved.path.clone();
418 if file_path.is_empty() {
419 return Ok(Err(format!("{base}/tree/{}", resolved.name)));
420 }
421 match git::path_type(&repo, resolved.commit, &file_path)? {
422 Some(ObjectType::Tree) => return Ok(Err(format!("{base}/tree/{rest}"))),
423 Some(ObjectType::Blob) => {}
424 _ => return Err(AppError::NotFound),
425 }
426 let blob = git::read_blob(&repo, resolved.commit, &file_path)?.ok_or(AppError::NotFound)?;
427 let refname = resolved.name.clone();
428 let raw_href = format!("{base}/raw/{}/{}", urlpath(&refname), urlpath(&file_path));
429 let lower = file_path.to_ascii_lowercase();
430 let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown");
431 let dir = file_path.rsplit_once('/').map(|(d, _)| d).unwrap_or("");
432
433 let mut line_count = 0;
434 let mut language = None;
435 let content = if is_image(&file_path) {
436 BlobContent::Image {
437 src: raw_href.clone(),
438 }
439 } else if blob.is_binary {
440 BlobContent::Binary
441 } else if blob.size > MAX_DISPLAY_BYTES {
442 BlobContent::TooLarge
443 } else {
444 let text = String::from_utf8_lossy(&blob.data);
445 let lines: Vec<&str> = text.lines().collect();
446 line_count = lines.len();
447 language = highlight::language_name(&file_path, lines.first().copied().unwrap_or(""));
448 if is_markdown && !plain {
449 BlobContent::Markdown {
450 html: markdown::render(&text, Some(&link_base(&base, &refname, dir))),
451 }
452 } else {
453 BlobContent::Code {
454 lines: highlight::highlight_lines(&file_path, &lines),
455 }
456 }
457 };
458 let latest = git::log(&repo, resolved.commit, Some(&file_path), 0, 1)?
459 .0
460 .into_iter()
461 .next();
462 Ok(Ok(BlobView {
463 refsel: RefSelector::build(&repo, &base, "blob", &resolved, &file_path),
464 crumbs: breadcrumbs(&base, &refname, dir),
465 file_name: file_path
466 .rsplit('/')
467 .next()
468 .unwrap_or(&file_path)
469 .to_string(),
470 history_href: format!(
471 "{base}/commits/{}/{}",
472 urlpath(&refname),
473 urlpath(&file_path)
474 ),
475 refname,
476 path: file_path,
477 size: blob.size,
478 line_count,
479 language,
480 content,
481 latest,
482 raw_href,
483 is_markdown,
484 plain,
485 }))
486 })
487 .await?;
488 match view {
489 Ok(view) => render(&BlobPage {
490 ctx,
491 r,
492 tab: "code",
493 view,
494 }),
495 Err(url) => Ok(redirect(&urlpath(&url))),
496 }
497}
498
499pub async fn raw(
500 State(state): State<AppState>,
501 ctx: Ctx,
502 Path((owner, name, rest)): Path<(String, String, String)>,
503) -> WebResult {
504 let r = load_repo(&state, &ctx, &owner, &name).await?;
505 let path = r.path.clone();
506 let (file_path, blob) = blocking(move || {
507 let repo = git::open(&path)?;
508 let resolved = resolve(&repo, &rest)?;
509 let blob =
510 git::read_blob(&repo, resolved.commit, &resolved.path)?.ok_or(AppError::NotFound)?;
511 Ok((resolved.path, blob))
512 })
513 .await?;
514
515 let mime = mime_guess::from_path(&file_path).first_or_octet_stream();
516 let content_type = if mime.type_() == "image" {
517 mime.essence_str().to_string()
518 } else if !blob.is_binary {
519 "text/plain; charset=utf-8".to_string()
520 } else {
521 "application/octet-stream".to_string()
522 };
523 let file_name = file_path
524 .rsplit('/')
525 .next()
526 .unwrap_or("file")
527 .replace('"', "");
528 let disposition = if content_type == "application/octet-stream" {
529 format!("attachment; filename=\"{file_name}\"")
530 } else {
531 format!("inline; filename=\"{file_name}\"")
532 };
533 let mut resp = blob.data.into_response();
534 let headers = resp.headers_mut();
535 if let Ok(v) = HeaderValue::from_str(&content_type) {
536 headers.insert(header::CONTENT_TYPE, v);
537 }
538 if let Ok(v) = HeaderValue::from_str(&disposition) {
539 headers.insert(header::CONTENT_DISPOSITION, v);
540 }
541 // User content is never allowed to run script on this origin.
542 headers.insert(
543 header::CONTENT_SECURITY_POLICY,
544 HeaderValue::from_static("default-src 'none'; style-src 'unsafe-inline'; sandbox"),
545 );
546 Ok(resp)
547}
548
549// ---------------------------------------------------------------------------
550// History
551// ---------------------------------------------------------------------------
552
553pub struct CommitGroup {
554 pub date: String,
555 pub commits: Vec<CommitInfo>,
556}
557
558pub struct CommitsView {
559 pub refsel: RefSelector,
560 pub refname: String,
561 pub path: String,
562 pub groups: Vec<CommitGroup>,
563 pub newer_href: Option<String>,
564 pub older_href: Option<String>,
565}
566
567#[derive(Template)]
568#[template(path = "repo/commits.html")]
569struct CommitsPage {
570 ctx: Ctx,
571 r: RepoCtx,
572 tab: &'static str,
573 view: CommitsView,
574}
575
576#[derive(Deserialize)]
577pub struct PageQuery {
578 page: Option<usize>,
579}
580
581pub async fn commits_default(
582 State(state): State<AppState>,
583 ctx: Ctx,
584 Path((owner, name)): Path<(String, String)>,
585) -> WebResult {
586 let r = load_repo(&state, &ctx, &owner, &name).await?;
587 Ok(redirect(&format!(
588 "{}/commits/{}",
589 r.base(),
590 urlpath(&r.repo.default_branch)
591 )))
592}
593
594pub async fn commits(
595 State(state): State<AppState>,
596 ctx: Ctx,
597 Path((owner, name, rest)): Path<(String, String, String)>,
598 Query(q): Query<PageQuery>,
599) -> WebResult {
600 let r = load_repo(&state, &ctx, &owner, &name).await?;
601 let page = q.page.unwrap_or(1).max(1);
602 let (path, base) = (r.path.clone(), r.base());
603 let view = blocking(move || {
604 let repo = git::open(&path)?;
605 let resolved = resolve(&repo, &rest)?;
606 let file_path = resolved.path.trim_matches('/').to_string();
607 let (list, more) = git::log(
608 &repo,
609 resolved.commit,
610 Some(&file_path),
611 (page - 1) * COMMITS_PER_PAGE,
612 COMMITS_PER_PAGE,
613 )?;
614 let mut groups: Vec<CommitGroup> = Vec::new();
615 for c in list {
616 let date = super::fmt::date(c.committer_time);
617 match groups.last_mut() {
618 Some(g) if g.date == date => g.commits.push(c),
619 _ => groups.push(CommitGroup {
620 date,
621 commits: vec![c],
622 }),
623 }
624 }
625 let mut here = format!("{base}/commits/{}", urlpath(&resolved.name));
626 if !file_path.is_empty() {
627 here.push('/');
628 here.push_str(&urlpath(&file_path));
629 }
630 Ok(CommitsView {
631 refsel: RefSelector::build(&repo, &base, "commits", &resolved, &file_path),
632 refname: resolved.name.clone(),
633 path: file_path,
634 groups,
635 newer_href: (page > 1).then(|| format!("{here}?page={}", page - 1)),
636 older_href: more.then(|| format!("{here}?page={}", page + 1)),
637 })
638 })
639 .await?;
640 render(&CommitsPage {
641 ctx,
642 r,
643 tab: "commits",
644 view,
645 })
646}
647
648#[derive(Template)]
649#[template(path = "repo/commit.html")]
650struct CommitPage {
651 ctx: Ctx,
652 r: RepoCtx,
653 tab: &'static str,
654 commit: CommitInfo,
655 diff: DiffSummary,
656}
657
658pub async fn commit(
659 State(state): State<AppState>,
660 ctx: Ctx,
661 Path((owner, name, sha)): Path<(String, String, String)>,
662) -> WebResult {
663 let r = load_repo(&state, &ctx, &owner, &name).await?;
664 if !sha.chars().all(|c| c.is_ascii_hexdigit()) || sha.len() < 4 || sha.len() > 40 {
665 return Err(AppError::NotFound);
666 }
667 let path = r.path.clone();
668 let (commit, diff) = blocking(move || {
669 let repo = git::open(&path)?;
670 let oid: Oid = repo
671 .revparse_single(&sha)
672 .and_then(|o| o.peel_to_commit())
673 .map_err(|_| AppError::NotFound)?
674 .id();
675 Ok((git::commit_info(&repo, oid)?, git::commit_diff(&repo, oid)?))
676 })
677 .await?;
678 render(&CommitPage {
679 ctx,
680 r,
681 tab: "commits",
682 commit,
683 diff,
684 })
685}
686
687// ---------------------------------------------------------------------------
688// Archives
689// ---------------------------------------------------------------------------
690
691/// Streams `git archive` output for `{ref}.zip` or `{ref}.tar.gz`.
692pub async fn archive(
693 State(state): State<AppState>,
694 ctx: Ctx,
695 Path((owner, name, file)): Path<(String, String, String)>,
696) -> WebResult {
697 let r = load_repo(&state, &ctx, &owner, &name).await?;
698 let (refname, format, content_type) = if let Some(r) = file.strip_suffix(".zip") {
699 (r.to_string(), "zip", "application/zip")
700 } else if let Some(r) = file.strip_suffix(".tar.gz") {
701 (r.to_string(), "tar.gz", "application/gzip")
702 } else {
703 return Err(AppError::NotFound);
704 };
705 let path = r.path.clone();
706 let lookup = refname.clone();
707 let commit = blocking(move || {
708 let repo = git::open(&path)?;
709 let resolved = resolve(&repo, &lookup)?;
710 if !resolved.path.is_empty() {
711 return Err(AppError::NotFound);
712 }
713 Ok(resolved.commit)
714 })
715 .await?;
716
717 let stem = format!("{}-{}", r.repo.name, refname.replace('/', "-"));
718 let mut child = Command::new(&state.config.git_bin)
719 .arg("--git-dir")
720 .arg(&r.path)
721 .arg("archive")
722 .arg(format!("--format={format}"))
723 .arg(format!("--prefix={stem}/"))
724 .arg(commit.to_string())
725 .stdin(Stdio::null())
726 .stdout(Stdio::piped())
727 .stderr(Stdio::null())
728 .kill_on_drop(true)
729 .spawn()?;
730 let stdout = child
731 .stdout
732 .take()
733 .ok_or_else(|| anyhow::anyhow!("git archive stdout"))?;
734 tokio::spawn(async move {
735 let _ = child.wait().await;
736 });
737 let mut resp = Body::from_stream(ReaderStream::new(stdout)).into_response();
738 let headers = resp.headers_mut();
739 headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
740 if let Ok(v) = HeaderValue::from_str(&format!(
741 "attachment; filename=\"{}.{format}\"",
742 stem.replace('"', "")
743 )) {
744 headers.insert(header::CONTENT_DISPOSITION, v);
745 }
746 Ok(resp)
747}