5bbde888
Fix review issues: SSH push, security hardening, split repo handlers, strip CSS
a73x 2026-03-31 14:09
- Fix SSH git-receive-pack by forwarding client stdin via mpsc channel - Fix broken commit link in overview template (/commits/ → /diff/) - Set host key file permissions to 0600 on generation - Add canonicalize check in resolve_repo_path to prevent symlink escapes - Split monolithic repo.rs (940 lines) into focused handler modules - Add explicit 10 MiB body size limit on git-upload-pack POST endpoint - Add caching trade-off comments on per-request scanning helpers - Extract open_repo helper to reduce handler boilerplate - Strip all CSS and footer for raw HTML output Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
diff --git a/Cargo.lock b/Cargo.lock index 9c2233b..9380d22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3199,6 +3199,15 @@ dependencies = [ ] [[package]] name = "serde_spanned" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] [[package]] name = "serde_urlencoded" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3706,6 +3715,9 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_edit" @@ -3714,6 +3726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned", "toml_datetime", "toml_write", "winnow 0.7.15", diff --git a/src/server/http/git_http.rs b/src/server/http/git_http.rs index fba17f7..d3eb6ce 100644 --- a/src/server/http/git_http.rs +++ b/src/server/http/git_http.rs @@ -9,6 +9,11 @@ use tokio::process::Command; use super::AppState; /// Max request body for git upload-pack negotiation (10 MiB). /// Git smart HTTP negotiation payloads are typically small (a few KiB of want/have lines), /// but we allow headroom for repos with many refs. pub const UPLOAD_PACK_BODY_LIMIT: usize = 10 * 1024 * 1024; #[derive(serde::Deserialize)] pub struct InfoRefsQuery { pub service: Option<String>, diff --git a/src/server/http/mod.rs b/src/server/http/mod.rs index 3db51a2..1c6c189 100644 --- a/src/server/http/mod.rs +++ b/src/server/http/mod.rs @@ -4,6 +4,7 @@ pub mod repo; use std::path::PathBuf; use std::sync::Arc; use axum::extract::DefaultBodyLimit; use axum::Router; #[derive(Debug, Clone)] @@ -27,6 +28,7 @@ pub fn router(state: AppState) -> Router { .route("/{repo_name}/issues", axum::routing::get(repo::issues)) .route("/{repo_name}/issues/{id}", axum::routing::get(repo::issue_detail)) .route("/{repo_dot_git}/info/refs", axum::routing::get(git_http::info_refs)) .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack)) .route("/{repo_dot_git}/git-upload-pack", axum::routing::post(git_http::upload_pack) .layer(DefaultBodyLimit::max(git_http::UPLOAD_PACK_BODY_LIMIT))) .with_state(shared) } diff --git a/src/server/http/repo.rs b/src/server/http/repo.rs deleted file mode 100644 index 0be0628..0000000 --- a/src/server/http/repo.rs +++ /dev/null @@ -1,943 +0,0 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use chrono::{TimeZone, Utc}; use super::AppState; #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "error.html")] pub struct ErrorTemplate { pub site_title: String, pub status_code: u16, pub message: String, } fn not_found(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 404, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::NOT_FOUND; response } fn internal_error(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 500, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; response } #[derive(Debug)] pub struct OverviewCommit { pub id: String, pub short_id: String, pub summary: String, pub author: String, pub date: String, } #[derive(Debug)] pub struct OverviewPatch { pub id: String, pub title: String, pub author: String, } #[derive(Debug)] pub struct OverviewIssue { pub id: String, pub title: String, pub author: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "repo_overview.html")] pub struct OverviewTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub commits: Vec<OverviewCommit>, pub patches: Vec<OverviewPatch>, pub issues: Vec<OverviewIssue>, } fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> { let mut revwalk = match repo.revwalk() { Ok(rw) => rw, Err(_) => return Vec::new(), }; if revwalk.push_head().is_err() { return Vec::new(); } revwalk .take(limit) .filter_map(|oid| { let oid = oid.ok()?; let commit = repo.find_commit(oid).ok()?; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = Utc .timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_default(); Some(OverviewCommit { id, short_id, summary, author, date }) }) .collect() } fn collab_counts(repo: &git2::Repository) -> (usize, usize) { let open_patches = git_collab::state::list_patches(repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .count(); let open_issues = git_collab::state::list_issues(repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .count(); (open_patches, open_issues) } pub async fn overview( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let commits = recent_commits(&repo, 10); let (open_patches, open_issues) = collab_counts(&repo); let patches = git_collab::state::list_patches(&repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .map(|p| OverviewPatch { id: p.id, title: p.title, author: p.author.name, }) .collect(); let issues = git_collab::state::list_issues(&repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .map(|i| OverviewIssue { id: i.id, title: i.title, author: i.author.name, }) .collect(); OverviewTemplate { site_title: state.site_title.clone(), repo_name, active_section: "overview".to_string(), open_patches, open_issues, commits, patches, issues, } .into_response() } // ── Task 8: Commits page ────────────────────────────────────────────────────── #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "commits.html")] pub struct CommitsTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub commits: Vec<OverviewCommit>, } pub async fn commits( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let commits = recent_commits(&repo, 200); let (open_patches, open_issues) = collab_counts(&repo); CommitsTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, commits, } .into_response() } // ── Task 9: Tree and blob pages ─────────────────────────────────────────────── #[derive(Debug)] pub struct TreeEntry { pub name: String, pub full_path: String, pub is_dir: bool, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "tree.html")] pub struct TreeTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub path_display: String, pub show_parent: bool, pub parent_path: String, pub entries: Vec<TreeEntry>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "blob.html")] pub struct BlobTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub file_path: String, pub content: String, pub is_binary: bool, pub size_display: String, } /// Split "ref/path/to/file" — first segment is ref, rest joined is path. /// Returns ("HEAD", "") for empty input. fn split_ref_path(input: &str) -> (String, String) { if input.is_empty() { return ("HEAD".to_string(), String::new()); } let parts: Vec<&str> = input.splitn(2, '/').collect(); let ref_name = parts[0].to_string(); let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() }; (ref_name, path) } fn build_tree_response( state: &AppState, repo_name: String, repo: &git2::Repository, ref_name: String, path: String, ) -> Response { let (open_patches, open_issues) = collab_counts(repo); // Resolve the ref to a tree let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(state, "Could not resolve ref to a commit."), }; let root_tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(state, "Failed to get commit tree."), }; // Navigate into subdirectory if path is non-empty let tree = if path.is_empty() { root_tree } else { let entry = match root_tree.get_path(std::path::Path::new(&path)) { Ok(e) => e, Err(_) => return not_found(state, format!("Path '{}' not found.", path)), }; let obj = match entry.to_object(repo) { Ok(o) => o, Err(_) => return internal_error(state, "Failed to resolve path object."), }; match obj.into_tree() { Ok(t) => t, Err(_) => return not_found(state, "Path is not a directory."), } }; let mut entries: Vec<TreeEntry> = tree .iter() .map(|e| { let name = e.name().unwrap_or("").to_string(); let full_path = if path.is_empty() { name.clone() } else { format!("{}/{}", path, name) }; let is_dir = e.kind() == Some(git2::ObjectType::Tree); TreeEntry { name, full_path, is_dir } }) .collect(); // Sort dirs first, then files, alphabetically within each group entries.sort_by(|a, b| { match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), } }); let (show_parent, parent_path) = if path.is_empty() { (false, String::new()) } else { let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string(); (true, parent) }; TreeTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, path_display: path, show_parent, parent_path, entries, } .into_response() } pub async fn tree_root( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new()) } pub async fn tree( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (ref_name, path) = split_ref_path(&rest); build_tree_response(&state, repo_name, &repo, ref_name, path) } pub async fn blob( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, file_path) = split_ref_path(&rest); let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(&state, "Could not resolve ref to a commit."), }; let tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(&state, "Failed to get commit tree."), }; let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) { Ok(e) => e, Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)), }; let obj = match entry_obj.to_object(&repo) { Ok(o) => o, Err(_) => return internal_error(&state, "Failed to resolve file object."), }; let blob = match obj.into_blob() { Ok(b) => b, Err(_) => return not_found(&state, "Path is not a file."), }; let is_binary = blob.is_binary(); let size = blob.size(); let size_display = if size < 1024 { format!("{} B", size) } else if size < 1024 * 1024 { format!("{:.1} KiB", size as f64 / 1024.0) } else { format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0)) }; let content = if is_binary { String::new() } else { String::from_utf8_lossy(blob.content()).into_owned() }; BlobTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, file_path, content, is_binary, size_display, } .into_response() } // ── Task 10: Diff page ──────────────────────────────────────────────────────── #[derive(Debug)] pub struct DiffLine { pub kind: String, pub text: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "diff.html")] pub struct DiffTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub short_id: String, pub summary: String, pub body: String, pub author: String, pub date: String, pub diff_lines: Vec<DiffLine>, } fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return Vec::new(), }; let new_tree = match commit.tree() { Ok(t) => t, Err(_) => return Vec::new(), }; let old_tree = commit .parent(0) .ok() .and_then(|p| p.tree().ok()); let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { Ok(d) => d, Err(_) => return Vec::new(), }; let mut lines: Vec<DiffLine> = Vec::new(); let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { let text = String::from_utf8_lossy(line.content()).into_owned(); let kind = match line.origin() { '+' => "add", '-' => "del", '@' => "hunk", 'F' => "file", _ => "ctx", } .to_string(); lines.push(DiffLine { kind, text }); true }); lines } pub async fn diff( Path((repo_name, oid_str)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let oid = match git2::Oid::from_str(&oid_str) { Ok(o) => o, Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)), }; let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)), }; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = Utc .timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_default(); // Body = message minus the first line (summary) let body = commit .message() .map(|m| { let lines: Vec<&str> = m.splitn(2, '\n').collect(); if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() } }) .unwrap_or_default(); let diff_lines = compute_diff_lines(&repo, oid); DiffTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, short_id, summary, body, author, date, diff_lines, } .into_response() } // ── Task 11: Patches list and detail ───────────────────────────────────────── #[derive(Debug)] pub struct PatchListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub branch: String, pub updated: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patches.html")] pub struct PatchesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patches: Vec<PatchListItem>, } #[derive(Debug)] pub struct RevisionView { pub number: u32, pub commit: String, pub timestamp: String, pub body: Option<String>, } #[derive(Debug)] pub struct ReviewView { pub author: String, pub verdict: String, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct InlineCommentView { pub author: String, pub file: String, pub line: u32, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct CommentView { pub author: String, pub body: String, pub timestamp: String, } #[derive(Debug)] pub struct PatchDetailView { pub id: String, pub title: String, pub body: String, pub status: String, pub author: String, pub branch: String, pub base_ref: String, pub revisions: Vec<RevisionView>, pub reviews: Vec<ReviewView>, pub inline_comments: Vec<InlineCommentView>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patch_detail.html")] pub struct PatchDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patch: PatchDetailView, } pub async fn patches( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default(); let patches = all_patches .into_iter() .map(|p| { let id = p.id.clone(); let short_id = id[..8.min(id.len())].to_string(); PatchListItem { short_id, id, status: p.status.as_str().to_string(), title: p.title, author: p.author.name, branch: p.branch, updated: p.last_updated, } }) .collect(); PatchesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patches, } .into_response() } pub async fn patch_detail( Path((repo_name, patch_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)), }; let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load patch state."), }; let patch = PatchDetailView { id: ps.id, title: ps.title, body: ps.body, status: ps.status.as_str().to_string(), author: ps.author.name, branch: ps.branch, base_ref: ps.base_ref, revisions: ps.revisions.into_iter().map(|r| RevisionView { number: r.number, commit: r.commit, timestamp: r.timestamp, body: r.body, }).collect(), reviews: ps.reviews.into_iter().map(|r| ReviewView { author: r.author.name, verdict: r.verdict.as_str().to_string(), body: r.body, timestamp: r.timestamp, revision: r.revision, }).collect(), inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView { author: ic.author.name, file: ic.file, line: ic.line, body: ic.body, timestamp: ic.timestamp, revision: ic.revision, }).collect(), comments: ps.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; PatchDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patch, } .into_response() } // ── Task 12: Issues list and detail ────────────────────────────────────────── #[derive(Debug)] pub struct IssueListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub labels: String, pub updated: String, } #[derive(Debug)] pub struct IssueDetailView { pub id: String, pub title: String, pub body: String, pub status: String, pub author: String, pub labels: String, pub assignees: String, pub close_reason: Option<String>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issues.html")] pub struct IssuesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issues: Vec<IssueListItem>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issue_detail.html")] pub struct IssueDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issue: IssueDetailView, } pub async fn issues( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default(); let issues = all_issues .into_iter() .map(|i| { let id = i.id.clone(); let short_id = id[..8.min(id.len())].to_string(); IssueListItem { short_id, id, status: i.status.as_str().to_string(), title: i.title, author: i.author.name, labels: i.labels.join(", "), updated: i.last_updated, } }) .collect(); IssuesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issues, } .into_response() } pub async fn issue_detail( Path((repo_name, issue_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let entry = match crate::repos::resolve(&state.repos_dir, &repo_name) { Some(e) => e, None => return not_found(&state, format!("Repository '{}' not found.", repo_name)), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return internal_error(&state, "Failed to open repository."), }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)), }; let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load issue state."), }; let issue = IssueDetailView { id: is.id, title: is.title, body: is.body, status: is.status.as_str().to_string(), author: is.author.name, labels: is.labels.join(", "), assignees: is.assignees.join(", "), close_reason: is.close_reason, comments: is.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; IssueDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issue, } .into_response() } diff --git a/src/server/http/repo/commits.rs b/src/server/http/repo/commits.rs new file mode 100644 index 0000000..5b6a68b --- /dev/null +++ b/src/server/http/repo/commits.rs @@ -0,0 +1,40 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "commits.html")] pub struct CommitsTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub commits: Vec<OverviewCommit>, } pub async fn commits( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let commits = recent_commits(&repo, 200); let (open_patches, open_issues) = collab_counts(&repo); CommitsTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, commits, } .into_response() } diff --git a/src/server/http/repo/diff.rs b/src/server/http/repo/diff.rs new file mode 100644 index 0000000..e1aad67 --- /dev/null +++ b/src/server/http/repo/diff.rs @@ -0,0 +1,127 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use chrono::{TimeZone, Utc}; use super::{AppState, collab_counts, not_found, open_repo}; #[derive(Debug)] pub struct DiffLine { pub kind: String, pub text: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "diff.html")] pub struct DiffTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub short_id: String, pub summary: String, pub body: String, pub author: String, pub date: String, pub diff_lines: Vec<DiffLine>, } fn compute_diff_lines(repo: &git2::Repository, oid: git2::Oid) -> Vec<DiffLine> { let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return Vec::new(), }; let new_tree = match commit.tree() { Ok(t) => t, Err(_) => return Vec::new(), }; let old_tree = commit .parent(0) .ok() .and_then(|p| p.tree().ok()); let diff = match repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None) { Ok(d) => d, Err(_) => return Vec::new(), }; let mut lines: Vec<DiffLine> = Vec::new(); let _ = diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| { let text = String::from_utf8_lossy(line.content()).into_owned(); let kind = match line.origin() { '+' => "add", '-' => "del", '@' => "hunk", 'F' => "file", _ => "ctx", } .to_string(); lines.push(DiffLine { kind, text }); true }); lines } pub async fn diff( Path((repo_name, oid_str)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let oid = match git2::Oid::from_str(&oid_str) { Ok(o) => o, Err(_) => return not_found(&state, format!("Invalid OID: {}", oid_str)), }; let commit = match repo.find_commit(oid) { Ok(c) => c, Err(_) => return not_found(&state, format!("Commit '{}' not found.", oid_str)), }; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = Utc .timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string()) .unwrap_or_default(); let body = commit .message() .map(|m| { let lines: Vec<&str> = m.splitn(2, '\n').collect(); if lines.len() > 1 { lines[1].trim_start_matches('\n').to_string() } else { String::new() } }) .unwrap_or_default(); let diff_lines = compute_diff_lines(&repo, oid); DiffTemplate { site_title: state.site_title.clone(), repo_name, active_section: "commits".to_string(), open_patches, open_issues, short_id, summary, body, author, date, diff_lines, } .into_response() } diff --git a/src/server/http/repo/issues.rs b/src/server/http/repo/issues.rs new file mode 100644 index 0000000..c01727f --- /dev/null +++ b/src/server/http/repo/issues.rs @@ -0,0 +1,139 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo}; #[derive(Debug)] pub struct IssueListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub labels: String, pub updated: String, } #[derive(Debug)] pub struct IssueDetailView { pub title: String, pub body: String, pub status: String, pub author: String, pub labels: String, pub assignees: String, pub close_reason: Option<String>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issues.html")] pub struct IssuesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issues: Vec<IssueListItem>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "issue_detail.html")] pub struct IssueDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub issue: IssueDetailView, } pub async fn issues( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let all_issues = git_collab::state::list_issues_with_archived(&repo).unwrap_or_default(); let issues = all_issues .into_iter() .map(|i| { let id = i.id.clone(); let short_id = id[..8.min(id.len())].to_string(); IssueListItem { short_id, id, status: i.status.as_str().to_string(), title: i.title, author: i.author.name, labels: i.labels.join(", "), updated: i.last_updated, } }) .collect(); IssuesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issues, } .into_response() } pub async fn issue_detail( Path((repo_name, issue_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_issue_ref(&repo, &issue_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Issue '{}' not found.", issue_id)), }; let is = match git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load issue state."), }; let issue = IssueDetailView { title: is.title, body: is.body, status: is.status.as_str().to_string(), author: is.author.name, labels: is.labels.join(", "), assignees: is.assignees.join(", "), close_reason: is.close_reason, comments: is.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; IssueDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "issues".to_string(), open_patches, open_issues, issue, } .into_response() } diff --git a/src/server/http/repo/mod.rs b/src/server/http/repo/mod.rs new file mode 100644 index 0000000..b0d0b4b --- /dev/null +++ b/src/server/http/repo/mod.rs @@ -0,0 +1,130 @@ mod overview; mod commits; mod tree; mod diff; mod patches; mod issues; pub use overview::overview; pub use commits::commits; pub use tree::{tree_root, tree, blob}; pub use diff::diff; pub use patches::{patches, patch_detail}; pub use issues::{issues, issue_detail}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use chrono::{TimeZone, Utc}; use super::AppState; #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "error.html")] pub struct ErrorTemplate { pub site_title: String, pub status_code: u16, pub message: String, } fn not_found(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 404, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::NOT_FOUND; response } fn internal_error(state: &AppState, message: impl Into<String>) -> Response { let mut response = ErrorTemplate { site_title: state.site_title.clone(), status_code: 500, message: message.into(), } .into_response(); *response.status_mut() = StatusCode::INTERNAL_SERVER_ERROR; response } /// Count open patches and issues for sidebar badges. /// Note: this does a full state read on each call. For repos with many collab /// objects this could be slow — consider caching with a short TTL if needed. fn collab_counts(repo: &git2::Repository) -> (usize, usize) { let open_patches = git_collab::state::list_patches(repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .count(); let open_issues = git_collab::state::list_issues(repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .count(); (open_patches, open_issues) } #[derive(Debug)] pub struct OverviewCommit { pub id: String, pub short_id: String, pub summary: String, pub author: String, pub date: String, } fn recent_commits(repo: &git2::Repository, limit: usize) -> Vec<OverviewCommit> { let mut revwalk = match repo.revwalk() { Ok(rw) => rw, Err(_) => return Vec::new(), }; if revwalk.push_head().is_err() { return Vec::new(); } revwalk .take(limit) .filter_map(|oid| { let oid = oid.ok()?; let commit = repo.find_commit(oid).ok()?; let id = oid.to_string(); let short_id = id[..8.min(id.len())].to_string(); let summary = commit.summary().unwrap_or("").to_string(); let author = commit.author().name().unwrap_or("").to_string(); let secs = commit.time().seconds(); let date = Utc .timestamp_opt(secs, 0) .single() .map(|dt| dt.format("%Y-%m-%d").to_string()) .unwrap_or_default(); Some(OverviewCommit { id, short_id, summary, author, date }) }) .collect() } #[derive(Debug)] pub struct CommentView { pub author: String, pub body: String, pub timestamp: String, } /// Open a repo by name, returning the entry and git2::Repository or an error Response. #[allow(clippy::result_large_err)] fn open_repo(state: &AppState, repo_name: &str) -> Result<(crate::repos::RepoEntry, git2::Repository), Response> { let entry = match crate::repos::resolve(&state.repos_dir, repo_name) { Some(e) => e, None => return Err(not_found(state, format!("Repository '{}' not found.", repo_name))), }; let repo = match crate::repos::open(&entry) { Ok(r) => r, Err(_) => return Err(internal_error(state, "Failed to open repository.")), }; Ok((entry, repo)) } diff --git a/src/server/http/repo/overview.rs b/src/server/http/repo/overview.rs new file mode 100644 index 0000000..f774a9d --- /dev/null +++ b/src/server/http/repo/overview.rs @@ -0,0 +1,80 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, OverviewCommit, collab_counts, open_repo, recent_commits}; #[derive(Debug)] pub struct OverviewPatch { pub id: String, pub title: String, pub author: String, } #[derive(Debug)] pub struct OverviewIssue { pub id: String, pub title: String, pub author: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "repo_overview.html")] pub struct OverviewTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub commits: Vec<OverviewCommit>, pub patches: Vec<OverviewPatch>, pub issues: Vec<OverviewIssue>, } pub async fn overview( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let commits = recent_commits(&repo, 10); let (open_patches, open_issues) = collab_counts(&repo); let patches = git_collab::state::list_patches(&repo) .unwrap_or_default() .into_iter() .filter(|p| p.status == git_collab::state::PatchStatus::Open) .map(|p| OverviewPatch { id: p.id, title: p.title, author: p.author.name, }) .collect(); let issues = git_collab::state::list_issues(&repo) .unwrap_or_default() .into_iter() .filter(|i| i.status == git_collab::state::IssueStatus::Open) .map(|i| OverviewIssue { id: i.id, title: i.title, author: i.author.name, }) .collect(); OverviewTemplate { site_title: state.site_title.clone(), repo_name, active_section: "overview".to_string(), open_patches, open_issues, commits, patches, issues, } .into_response() } diff --git a/src/server/http/repo/patches.rs b/src/server/http/repo/patches.rs new file mode 100644 index 0000000..544d059 --- /dev/null +++ b/src/server/http/repo/patches.rs @@ -0,0 +1,188 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, CommentView, collab_counts, internal_error, not_found, open_repo}; #[derive(Debug)] pub struct PatchListItem { pub id: String, pub short_id: String, pub status: String, pub title: String, pub author: String, pub branch: String, pub updated: String, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patches.html")] pub struct PatchesTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patches: Vec<PatchListItem>, } #[derive(Debug)] pub struct RevisionView { pub number: u32, pub commit: String, pub timestamp: String, pub body: Option<String>, } #[derive(Debug)] pub struct ReviewView { pub author: String, pub verdict: String, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct InlineCommentView { pub author: String, pub file: String, pub line: u32, pub body: String, pub timestamp: String, pub revision: Option<u32>, } #[derive(Debug)] pub struct PatchDetailView { pub title: String, pub body: String, pub status: String, pub author: String, pub branch: String, pub base_ref: String, pub revisions: Vec<RevisionView>, pub reviews: Vec<ReviewView>, pub inline_comments: Vec<InlineCommentView>, pub comments: Vec<CommentView>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "patch_detail.html")] pub struct PatchDetailTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub patch: PatchDetailView, } pub async fn patches( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let all_patches = git_collab::state::list_patches_with_archived(&repo).unwrap_or_default(); let patches = all_patches .into_iter() .map(|p| { let id = p.id.clone(); let short_id = id[..8.min(id.len())].to_string(); PatchListItem { short_id, id, status: p.status.as_str().to_string(), title: p.title, author: p.author.name, branch: p.branch, updated: p.last_updated, } }) .collect(); PatchesTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patches, } .into_response() } pub async fn patch_detail( Path((repo_name, patch_id)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, full_id) = match git_collab::state::resolve_patch_ref(&repo, &patch_id) { Ok(r) => r, Err(_) => return not_found(&state, format!("Patch '{}' not found.", patch_id)), }; let ps = match git_collab::state::PatchState::from_ref(&repo, &ref_name, &full_id) { Ok(s) => s, Err(_) => return internal_error(&state, "Failed to load patch state."), }; let patch = PatchDetailView { title: ps.title, body: ps.body, status: ps.status.as_str().to_string(), author: ps.author.name, branch: ps.branch, base_ref: ps.base_ref, revisions: ps.revisions.into_iter().map(|r| RevisionView { number: r.number, commit: r.commit, timestamp: r.timestamp, body: r.body, }).collect(), reviews: ps.reviews.into_iter().map(|r| ReviewView { author: r.author.name, verdict: r.verdict.as_str().to_string(), body: r.body, timestamp: r.timestamp, revision: r.revision, }).collect(), inline_comments: ps.inline_comments.into_iter().map(|ic| InlineCommentView { author: ic.author.name, file: ic.file, line: ic.line, body: ic.body, timestamp: ic.timestamp, revision: ic.revision, }).collect(), comments: ps.comments.into_iter().map(|c| CommentView { author: c.author.name, body: c.body, timestamp: c.timestamp, }).collect(), }; PatchDetailTemplate { site_title: state.site_title.clone(), repo_name, active_section: "patches".to_string(), open_patches, open_issues, patch, } .into_response() } diff --git a/src/server/http/repo/tree.rs b/src/server/http/repo/tree.rs new file mode 100644 index 0000000..a2554f4 --- /dev/null +++ b/src/server/http/repo/tree.rs @@ -0,0 +1,238 @@ use std::sync::Arc; use axum::extract::{Path, State}; use axum::response::{IntoResponse, Response}; use super::{AppState, collab_counts, internal_error, not_found, open_repo}; #[derive(Debug)] pub struct TreeEntry { pub name: String, pub full_path: String, pub is_dir: bool, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "tree.html")] pub struct TreeTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub path_display: String, pub show_parent: bool, pub parent_path: String, pub entries: Vec<TreeEntry>, } #[derive(askama::Template, askama_web::WebTemplate)] #[template(path = "blob.html")] pub struct BlobTemplate { pub site_title: String, pub repo_name: String, pub active_section: String, pub open_patches: usize, pub open_issues: usize, pub ref_name: String, pub file_path: String, pub content: String, pub is_binary: bool, pub size_display: String, } /// Split "ref/path/to/file" — first segment is ref, rest joined is path. /// Returns ("HEAD", "") for empty input. fn split_ref_path(input: &str) -> (String, String) { if input.is_empty() { return ("HEAD".to_string(), String::new()); } let parts: Vec<&str> = input.splitn(2, '/').collect(); let ref_name = parts[0].to_string(); let path = if parts.len() > 1 { parts[1].to_string() } else { String::new() }; (ref_name, path) } fn build_tree_response( state: &AppState, repo_name: String, repo: &git2::Repository, ref_name: String, path: String, ) -> Response { let (open_patches, open_issues) = collab_counts(repo); let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(state, "Could not resolve ref to a commit."), }; let root_tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(state, "Failed to get commit tree."), }; let tree = if path.is_empty() { root_tree } else { let entry = match root_tree.get_path(std::path::Path::new(&path)) { Ok(e) => e, Err(_) => return not_found(state, format!("Path '{}' not found.", path)), }; let obj = match entry.to_object(repo) { Ok(o) => o, Err(_) => return internal_error(state, "Failed to resolve path object."), }; match obj.into_tree() { Ok(t) => t, Err(_) => return not_found(state, "Path is not a directory."), } }; let mut entries: Vec<TreeEntry> = tree .iter() .map(|e| { let name = e.name().unwrap_or("").to_string(); let full_path = if path.is_empty() { name.clone() } else { format!("{}/{}", path, name) }; let is_dir = e.kind() == Some(git2::ObjectType::Tree); TreeEntry { name, full_path, is_dir } }) .collect(); entries.sort_by(|a, b| { match (a.is_dir, b.is_dir) { (true, false) => std::cmp::Ordering::Less, (false, true) => std::cmp::Ordering::Greater, _ => a.name.cmp(&b.name), } }); let (show_parent, parent_path) = if path.is_empty() { (false, String::new()) } else { let parent = path.rfind('/').map(|i| &path[..i]).unwrap_or("").to_string(); (true, parent) }; TreeTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, path_display: path, show_parent, parent_path, entries, } .into_response() } pub async fn tree_root( Path(repo_name): Path<String>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; build_tree_response(&state, repo_name, &repo, "HEAD".to_string(), String::new()) } pub async fn tree( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (ref_name, path) = split_ref_path(&rest); build_tree_response(&state, repo_name, &repo, ref_name, path) } pub async fn blob( Path((repo_name, rest)): Path<(String, String)>, State(state): State<Arc<AppState>>, ) -> Response { let (_entry, repo) = match open_repo(&state, &repo_name) { Ok(r) => r, Err(resp) => return resp, }; let (open_patches, open_issues) = collab_counts(&repo); let (ref_name, file_path) = split_ref_path(&rest); let obj = match repo.revparse_single(&ref_name) { Ok(o) => o, Err(_) => return not_found(&state, format!("Ref '{}' not found.", ref_name)), }; let commit = match obj.peel_to_commit() { Ok(c) => c, Err(_) => return not_found(&state, "Could not resolve ref to a commit."), }; let tree = match commit.tree() { Ok(t) => t, Err(_) => return internal_error(&state, "Failed to get commit tree."), }; let entry_obj = match tree.get_path(std::path::Path::new(&file_path)) { Ok(e) => e, Err(_) => return not_found(&state, format!("File '{}' not found.", file_path)), }; let obj = match entry_obj.to_object(&repo) { Ok(o) => o, Err(_) => return internal_error(&state, "Failed to resolve file object."), }; let blob = match obj.into_blob() { Ok(b) => b, Err(_) => return not_found(&state, "Path is not a file."), }; let is_binary = blob.is_binary(); let size = blob.size(); let size_display = if size < 1024 { format!("{} B", size) } else if size < 1024 * 1024 { format!("{:.1} KiB", size as f64 / 1024.0) } else { format!("{:.1} MiB", size as f64 / (1024.0 * 1024.0)) }; let content = if is_binary { String::new() } else { String::from_utf8_lossy(blob.content()).into_owned() }; BlobTemplate { site_title: state.site_title.clone(), repo_name, active_section: "tree".to_string(), open_patches, open_issues, ref_name, file_path, content, is_binary, size_display, } .into_response() } diff --git a/src/server/http/templates/base.html b/src/server/http/templates/base.html index 4d27da3..6718f03 100644 --- a/src/server/http/templates/base.html +++ b/src/server/http/templates/base.html @@ -4,156 +4,6 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>{% block title %}{{ site_title }}{% endblock %}</title> <style> :root { --bg: #0d1117; --bg-secondary: #161b22; --bg-tertiary: #21262d; --border: #30363d; --text: #e6edf3; --text-muted: #8b949e; --text-link: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d29922; } *, *::before, *::after { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.5; } a { color: var(--text-link); text-decoration: none; } a:hover { text-decoration: underline; } /* Header */ .header { background: var(--bg-secondary); border-bottom: 1px solid var(--border); padding: 0 24px; display: flex; align-items: center; justify-content: space-between; height: 48px; } .header a { color: var(--text); font-weight: 600; } /* Layout */ .layout { display: flex; min-height: calc(100vh - 48px - 36px); } /* Sidebar */ .sidebar { width: 180px; background: var(--bg-secondary); border-right: 1px solid var(--border); padding: 16px 0; flex-shrink: 0; } .sidebar a { display: block; padding: 6px 16px; color: var(--text); } .sidebar a:hover { background: var(--bg-tertiary); text-decoration: none; } .sidebar a.active { background: var(--bg-tertiary); border-left: 2px solid var(--text-link); color: var(--text-link); padding-left: 14px; } .sidebar .divider { height: 1px; background: var(--border); margin: 8px 16px; } .sidebar .badge { display: inline-block; background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 20px; padding: 0 6px; font-size: 11px; color: var(--text-muted); float: right; } /* Content */ .content { flex: 1; padding: 24px; overflow: auto; } /* Footer */ .footer { background: var(--bg-secondary); border-top: 1px solid var(--border); padding: 0 24px; height: 36px; display: flex; align-items: center; color: var(--text-muted); font-size: 12px; } /* Status badges */ .status-open { color: var(--green); } .status-closed { color: var(--red); } .status-merged { color: var(--yellow); } /* Diff */ .diff-add { background: rgba(63,185,80,0.1); color: var(--green); } .diff-del { background: rgba(248,81,73,0.1); color: var(--red); } .diff-hunk { color: var(--text-muted); } .diff-file { font-weight: 600; border-top: 1px solid var(--border); padding: 8px 0 4px; } /* Mono */ .mono, pre, code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; font-size: 12px; } pre { background: var(--bg-tertiary); border: 1px solid var(--border); border-radius: 6px; padding: 12px 16px; overflow-x: auto; } code { background: var(--bg-tertiary); border-radius: 3px; padding: 0 4px; } pre code { background: none; padding: 0; } /* Tables */ table { width: 100%; border-collapse: collapse; } th { text-align: left; color: var(--text-muted); font-weight: 400; border-bottom: 1px solid var(--border); padding: 8px 12px; } td { padding: 10px 12px; border-bottom: 1px solid var(--border); } tr:last-child td { border-bottom: none; } tr:hover td { background: var(--bg-secondary); } </style> </head> <body> <div class="header"> @@ -167,6 +17,5 @@ </div> </div> <div class="footer">git-collab-server</div> </body> </html> diff --git a/src/server/http/templates/repo_overview.html b/src/server/http/templates/repo_overview.html index d2ac0d1..5afd167 100644 --- a/src/server/http/templates/repo_overview.html +++ b/src/server/http/templates/repo_overview.html @@ -74,7 +74,7 @@ <tbody> {% for commit in commits %} <tr> <td class="mono"><a href="/{{ repo_name }}/commits/{{ commit.id }}">{{ commit.short_id }}</a></td> <td class="mono"><a href="/{{ repo_name }}/diff/{{ commit.id }}">{{ commit.short_id }}</a></td> <td>{{ commit.summary }}</td> <td style="color: var(--text-muted);">{{ commit.author }}</td> <td class="mono" style="color: var(--text-muted);">{{ commit.date }}</td> diff --git a/src/server/repos.rs b/src/server/repos.rs index 9b23aec..191809c 100644 --- a/src/server/repos.rs +++ b/src/server/repos.rs @@ -35,6 +35,8 @@ pub fn discover(repos_dir: &Path) -> Result<Vec<RepoEntry>, std::io::Error> { } /// Resolve a repo name to its on-disk entry. /// Note: this performs a full directory scan on each call. Fine for a small /// number of repos; consider caching with a short TTL if this becomes a bottleneck. pub fn resolve(repos_dir: &Path, name: &str) -> Option<RepoEntry> { let entries = discover(repos_dir).ok()?; entries.into_iter().find(|e| e.name == name) diff --git a/src/server/ssh/auth.rs b/src/server/ssh/auth.rs index be26001..86bcc9a 100644 --- a/src/server/ssh/auth.rs +++ b/src/server/ssh/auth.rs @@ -1,6 +1,7 @@ use std::path::Path; #[derive(Debug, Clone)] #[allow(dead_code)] pub struct AuthorizedKey { pub key_type: String, pub key_data: String, diff --git a/src/server/ssh/mod.rs b/src/server/ssh/mod.rs index 317ec7f..fd3e57a 100644 --- a/src/server/ssh/mod.rs +++ b/src/server/ssh/mod.rs @@ -26,6 +26,12 @@ pub fn load_or_generate_host_key(key_path: &Path) -> Result<KeyPair, Box<dyn std std::fs::create_dir_all(parent)?; } let file = std::fs::File::create(key_path)?; // Restrict host key file permissions to owner-only #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; } russh_keys::encode_pkcs8_pem(&key, file)?; Ok(key) } diff --git a/src/server/ssh/session.rs b/src/server/ssh/session.rs index 2048a70..8cf95c1 100644 --- a/src/server/ssh/session.rs +++ b/src/server/ssh/session.rs @@ -7,6 +7,7 @@ use russh::{Channel, ChannelId, CryptoVec}; use russh_keys::key::PublicKey; use russh_keys::PublicKeyBase64; use tokio::process::Command; use tokio::sync::mpsc; use tracing::{debug, error, info, warn}; use super::auth::{is_authorized, load_authorized_keys}; @@ -22,6 +23,8 @@ pub struct SshServerConfig { pub struct SshHandler { config: Arc<SshServerConfig>, authenticated: bool, /// Sender for forwarding client data (stdin) to the spawned git subprocess. stdin_tx: Option<mpsc::Sender<Vec<u8>>>, } impl SshHandler { @@ -29,6 +32,7 @@ impl SshHandler { Self { config, authenticated: false, stdin_tx: None, } } } @@ -58,7 +62,7 @@ pub fn parse_git_command(data: &str) -> Option<(&str, &str)> { } /// Resolve a requested repo path to a safe absolute path under repos_dir. /// Returns None if the path escapes repos_dir (e.g. via `..`). /// Returns None if the path escapes repos_dir (e.g. via `..` or symlinks). pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { let requested = requested.trim_start_matches('/'); // Reject any path component that is ".." @@ -73,11 +77,20 @@ pub fn resolve_repo_path(repos_dir: &Path, requested: &str) -> Option<PathBuf> { } } let full = repos_dir.join(requested); // Double-check via canonicalize-like logic: the joined path must start with repos_dir // We use a simple prefix check on the cleaned path components // Prefix check on the joined path if !full.starts_with(repos_dir) { return None; } // If the path exists, canonicalize both to catch symlink escapes if full.exists() { if let (Ok(canon_repos), Ok(canon_full)) = (repos_dir.canonicalize(), full.canonicalize()) { if !canon_full.starts_with(&canon_repos) { return None; } } } Some(full) } @@ -177,11 +190,15 @@ impl Handler for SshHandler { return Ok(()); } // Create a channel for forwarding client stdin data to the git subprocess let (tx, rx) = mpsc::channel::<Vec<u8>>(64); self.stdin_tx = Some(tx); // Spawn the git subprocess let handle = session.handle(); let git_cmd_owned = git_cmd; tokio::spawn(async move { if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path).await { if let Err(e) = run_git_command(handle, channel, &git_cmd_owned, &resolved_path, rx).await { error!("Git subprocess error: {}", e); } }); @@ -191,17 +208,16 @@ impl Handler for SshHandler { async fn data( &mut self, channel: ChannelId, _channel: ChannelId, data: &[u8], session: &mut Session, _session: &mut Session, ) -> Result<(), Self::Error> { // Forward client data to the channel (handled by russh internally for exec) // For git push, the client sends pack data via stdin — we need to forward it. // In the current design, the spawned task reads from the channel handle, // but russh's exec model means data arrives here. We just ignore it for now // since git-upload-pack (clone/fetch) doesn't need stdin from client in the // basic case, and git-receive-pack gets its data via the SSH channel. let _ = (channel, data, session); // Forward client data to the git subprocess's stdin if let Some(ref tx) = self.stdin_tx { if tx.send(data.to_vec()).await.is_err() { debug!("stdin channel closed, dropping data"); } } Ok(()) } } @@ -211,17 +227,31 @@ async fn run_git_command( channel: ChannelId, git_cmd: &str, repo_path: &Path, mut stdin_rx: mpsc::Receiver<Vec<u8>>, ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { use tokio::io::AsyncReadExt; use tokio::io::{AsyncReadExt, AsyncWriteExt}; let mut child = Command::new(git_cmd) .arg(repo_path) .stdin(std::process::Stdio::null()) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn()?; let mut child_stdin = child.stdin.take().expect("stdin piped"); let mut stdout = child.stdout.take().expect("stdout piped"); // Spawn a task to forward client data to the child's stdin tokio::spawn(async move { while let Some(data) = stdin_rx.recv().await { if child_stdin.write_all(&data).await.is_err() { break; } } // EOF: close stdin so the child knows we're done drop(child_stdin); }); let mut buf = vec![0u8; 32768]; loop {