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