Parent [>]
1//! Route handlers for repository pages.
2//!
3//! Covers two routes: the repository list (`/`) and the repository detail
4//! page (`/{repo}`).  The detail handler opens the repository with `gix`,
5//! collects branch and tag summaries by peeling each ref to its commit, and
6//! reads the description from `.git/description`.  Refs that cannot be read
7//! are silently skipped rather than treated as fatal errors.
8
9use askama::Template;
10use askama_web::WebTemplate;
11use axum::extract::State;
12use axum::response::IntoResponse;
13
14use crate::error::{Error, Result};
15use crate::routes::{AppState, RepoName, commit_date};
16
17/// Reads the repository description from `.git/description`, returning `None`
18/// if the file is missing, empty, or contains the default git placeholder text.
19fn repo_description(git_repo: &gix::Repository) -> Option<String> {
20    std::fs::read_to_string(git_repo.path().join("description"))
21        .ok()
22        .map(|s| s.trim().to_string())
23        .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository"))
24}
25
26/// Returns `true` if `url` is a relative path that should be rewritten to
27/// point at the raw file serving route.
28fn is_relative_url(url: &str) -> bool {
29    !url.starts_with("http://")
30        && !url.starts_with("https://")
31        && !url.starts_with('#')
32        && !url.starts_with("mailto:")
33        && !url.starts_with("data:")
34}
35
36/// Template context for a single branch.
37struct BranchSummary {
38    name: String,
39    date: String,
40    /// Whether this branch is the repository's HEAD.
41    is_default: bool,
42}
43
44/// Template context for a single tag.
45struct TagSummary {
46    name: String,
47    date: String,
48}
49
50/// Template context for the repository detail page.
51#[derive(Template, WebTemplate)]
52#[template(path = "repo.html")]
53struct Repo {
54    name: String,
55    description: Option<String>,
56    branches: Vec<BranchSummary>,
57    tags: Vec<TagSummary>,
58    commit_count: usize,
59    head_sha: Option<String>,
60    /// README rendered to HTML, or `None` if no README was found.
61    readme: Option<String>,
62}
63
64/// Renders the detail page for a single repository, including its branches,
65/// tags, and description.  Returns [`Error::RepoNotFound`] if no repository
66/// exists at the given name, or [`Error::GitCorrupt`] if the ref database
67/// cannot be read.
68pub async fn detail(
69    State(state): State<AppState>,
70    RepoName(name): RepoName,
71) -> Result<impl IntoResponse> {
72    let path = state.root.join(&name);
73    let git_repo = gix::open(path).map_err(|_| Error::RepoNotFound(name.clone()))?;
74
75    let platform = git_repo
76        .references()
77        .map_err(|e| Error::GitCorrupt(e.to_string()))?;
78
79    let head_name = git_repo.head_name().ok().flatten();
80
81    // `.tags()` returns a `Result<impl Iterator<Item = Result<Reference>>>`.
82    // The first `.flatten()` unwraps the outer `Result`, the second unwraps
83    // each `Result<Reference>`, silently discarding any errors.
84    let tags: Vec<TagSummary> = platform
85        .tags()
86        .into_iter()
87        .flatten()
88        .flatten()
89        .filter_map(|mut tag| {
90            let commit = tag.peel_to_commit().ok()?;
91            let date = commit_date(&commit)?;
92            let name = tag.name().shorten().to_string();
93
94            Some(TagSummary { name, date })
95        })
96        .collect();
97
98    // Same double-flatten pattern as tags above.
99    let branches: Vec<BranchSummary> = platform
100        .local_branches()
101        .into_iter()
102        .flatten()
103        .flatten()
104        .filter_map(|mut branch| {
105            let commit = branch.peel_to_commit().ok()?;
106            let date = commit_date(&commit)?;
107            let name = branch.name().shorten().to_string();
108            let is_default = head_name
109                .as_ref()
110                .is_some_and(|h| h.as_ref() == branch.name());
111
112            Some(BranchSummary {
113                name,
114                date,
115                is_default,
116            })
117        })
118        .collect();
119
120    let description = repo_description(&git_repo);
121    let head_id = git_repo.head_id().ok();
122    let commit_count = head_id
123        .as_ref()
124        .and_then(|id| id.ancestors().all().ok())
125        .map(|walk| walk.count())
126        .unwrap_or(0);
127
128    // Resolve HEAD to a SHA so we can build stable raw URLs for images.
129    let head_sha = head_id.map(|id| id.to_string());
130
131    // Try each candidate filename in order, stopping at the first one found.
132    let readme = ["HEAD:README.md", "HEAD:README", "HEAD:readme.md"]
133        .iter()
134        .find_map(|&spec| {
135            // Resolve the rev-spec to a blob, discarding any that don't exist
136            // or aren't blobs (e.g. submodules).
137            let blob = git_repo
138                .rev_parse_single(spec)
139                .ok()
140                .and_then(|id| id.object().ok())
141                .and_then(|obj| obj.try_into_blob().ok())?;
142
143            // Silently skip non-UTF-8 content.
144            let text = std::str::from_utf8(&blob.data).ok()?;
145
146            use pulldown_cmark::{Event, Options, Parser, Tag, html};
147
148            // Rewrite relative image URLs to the raw file serving route so
149            // that images committed alongside the README render correctly.
150            let mut rendered = String::new();
151            let events = Parser::new_ext(text, Options::all()).map(|event| match event {
152                Event::Start(Tag::Image {
153                    link_type,
154                    dest_url,
155                    title,
156                    id,
157                }) => {
158                    let dest_url = match &head_sha {
159                        Some(sha) if is_relative_url(&dest_url) => {
160                            format!("/{}/commits/{}/raw/{}", name, sha, dest_url).into()
161                        }
162                        _ => dest_url,
163                    };
164                    Event::Start(Tag::Image {
165                        link_type,
166                        dest_url,
167                        title,
168                        id,
169                    })
170                }
171                _ => event,
172            });
173            html::push_html(&mut rendered, events);
174
175            Some(rendered)
176        });
177
178    Ok(Repo {
179        name,
180        description,
181        branches,
182        tags,
183        commit_count,
184        head_sha,
185        readme,
186    })
187}
188
189/// Template context for a single repository in the list.
190struct RepoSummary {
191    name: String,
192    description: Option<String>,
193    commit_count: usize,
194    last_commit: Option<String>,
195}
196
197/// Template context for the repository list page.
198#[derive(Template, WebTemplate)]
199#[template(path = "repo_list.html")]
200struct RepoList {
201    repos: Vec<RepoSummary>,
202}
203
204/// Renders the repository list page by scanning the root directory for git
205/// repositories.  Entries that cannot be opened as a git repository are
206/// skipped.  The list is sorted by latest commit first.
207pub async fn list(State(state): State<AppState>) -> Result<impl IntoResponse> {
208    let mut repos: Vec<RepoSummary> = std::fs::read_dir(&state.root)?
209        .filter_map(|entry| {
210            let name = entry.ok()?.file_name().to_string_lossy().into_owned();
211            let git_repo = gix::open(state.root.join(&name)).ok()?;
212            let description = repo_description(&git_repo);
213
214            let head_id = git_repo.head_id().ok();
215
216            let commit_count = head_id
217                .as_ref()
218                .and_then(|id| id.ancestors().all().ok())
219                .map(|walk| walk.count())
220                .unwrap_or(0);
221
222            let last_commit = head_id
223                .and_then(|id| id.object().ok())
224                .and_then(|obj| obj.try_into_commit().ok())
225                .and_then(|commit| commit_date(&commit));
226
227            Some(RepoSummary {
228                name,
229                description,
230                commit_count,
231                last_commit,
232            })
233        })
234        .collect();
235
236    repos.sort_by(|a, b| b.last_commit.cmp(&a.last_commit));
237
238    Ok(RepoList { repos })
239}