Sam Jeston
History
747 lines·
22.3 KB
·Rust
| 1 | //! Browsing code: repository home, trees, files, raw blobs and history. |
| 2 | |
| 3 | use std::process::Stdio; |
| 4 | |
| 5 | use askama::Template; |
| 6 | use axum::body::Body; |
| 7 | use axum::extract::{Path, Query, State}; |
| 8 | use axum::http::{HeaderValue, header}; |
| 9 | use axum::response::IntoResponse; |
| 10 | use git2::{BranchType, ObjectType, Oid, Repository}; |
| 11 | use serde::Deserialize; |
| 12 | use tokio::process::Command; |
| 13 | use tokio_util::io::ReaderStream; |
| 14 | |
| 15 | use super::fmt::urlpath; |
| 16 | use super::repo::{RepoCtx, load_repo}; |
| 17 | use super::{AppError, Ctx, WebResult, blocking, redirect, render}; |
| 18 | use crate::db; |
| 19 | use crate::git::{self, CommitInfo, DiffSummary, RefKind, Resolved, TreeEntry}; |
| 20 | use crate::highlight; |
| 21 | use crate::markdown::{self, LinkBase}; |
| 22 | use crate::state::AppState; |
| 23 | |
| 24 | const COMMITS_PER_PAGE: usize = 30; |
| 25 | const MAX_DISPLAY_BYTES: usize = 1024 * 1024; |
| 26 | |
| 27 | // --------------------------------------------------------------------------- |
| 28 | // Shared view pieces |
| 29 | // --------------------------------------------------------------------------- |
| 30 | |
| 31 | pub 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. |
| 38 | pub struct RefSelector { |
| 39 | pub current: String, |
| 40 | pub kind: RefKind, |
| 41 | pub branches: Vec<RefLink>, |
| 42 | pub tags: Vec<RefLink>, |
| 43 | } |
| 44 | |
| 45 | impl 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 | |
| 90 | pub struct Crumb { |
| 91 | pub name: String, |
| 92 | pub href: String, |
| 93 | } |
| 94 | |
| 95 | fn 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 | |
| 111 | pub struct EntryRow { |
| 112 | pub entry: TreeEntry, |
| 113 | pub href: String, |
| 114 | } |
| 115 | |
| 116 | /// Resolves `{ref}/{path}` or returns 404. |
| 117 | fn resolve(repo: &Repository, rest: &str) -> Result<Resolved, AppError> { |
| 118 | git::resolve(repo, rest).ok_or(AppError::NotFound) |
| 119 | } |
| 120 | |
| 121 | fn 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 | |
| 133 | pub struct Readme { |
| 134 | pub name: String, |
| 135 | pub html: String, |
| 136 | pub is_markdown: bool, |
| 137 | } |
| 138 | |
| 139 | pub 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")] |
| 157 | struct 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")] |
| 167 | struct EmptyPage { |
| 168 | ctx: Ctx, |
| 169 | r: RepoCtx, |
| 170 | tab: &'static str, |
| 171 | } |
| 172 | |
| 173 | fn 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. |
| 256 | fn 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 | |
| 269 | pub 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 | |
| 320 | pub 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 | |
| 357 | pub enum BlobContent { |
| 358 | Code { lines: Vec<String> }, |
| 359 | Markdown { html: String }, |
| 360 | Image { src: String }, |
| 361 | Binary, |
| 362 | TooLarge, |
| 363 | } |
| 364 | |
| 365 | pub 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")] |
| 384 | struct BlobPage { |
| 385 | ctx: Ctx, |
| 386 | r: RepoCtx, |
| 387 | tab: &'static str, |
| 388 | view: BlobView, |
| 389 | } |
| 390 | |
| 391 | #[derive(Deserialize)] |
| 392 | pub struct BlobQuery { |
| 393 | plain: Option<String>, |
| 394 | } |
| 395 | |
| 396 | fn 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 | |
| 405 | pub 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 | |
| 499 | pub 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 | |
| 553 | pub struct CommitGroup { |
| 554 | pub date: String, |
| 555 | pub commits: Vec<CommitInfo>, |
| 556 | } |
| 557 | |
| 558 | pub 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")] |
| 569 | struct CommitsPage { |
| 570 | ctx: Ctx, |
| 571 | r: RepoCtx, |
| 572 | tab: &'static str, |
| 573 | view: CommitsView, |
| 574 | } |
| 575 | |
| 576 | #[derive(Deserialize)] |
| 577 | pub struct PageQuery { |
| 578 | page: Option<usize>, |
| 579 | } |
| 580 | |
| 581 | pub 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 | |
| 594 | pub 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")] |
| 650 | struct CommitPage { |
| 651 | ctx: Ctx, |
| 652 | r: RepoCtx, |
| 653 | tab: &'static str, |
| 654 | commit: CommitInfo, |
| 655 | diff: DiffSummary, |
| 656 | } |
| 657 | |
| 658 | pub 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`. |
| 692 | pub 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 | } |