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 mut opts = Options::empty();
160            opts.insert(Options::ENABLE_TABLES);
161            opts.insert(Options::ENABLE_FOOTNOTES);
162            opts.insert(Options::ENABLE_STRIKETHROUGH);
163            opts.insert(Options::ENABLE_TASKLISTS);
164            opts.insert(Options::ENABLE_SMART_PUNCTUATION);
165            opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
166            opts.insert(Options::ENABLE_GFM);
167            opts.insert(Options::ENABLE_DEFINITION_LIST);
168            opts.insert(Options::ENABLE_SUPERSCRIPT);
169            opts.insert(Options::ENABLE_MATH);
170            let events = Parser::new_ext(text, opts).map(|event| match event {
171                Event::Start(Tag::Image {
172                    link_type,
173                    dest_url,
174                    title,
175                    id,
176                }) => {
177                    let dest_url = match &head_sha {
178                        Some(sha) if is_relative_url(&dest_url) => format!(
179                            "{}/{}/commits/{}/raw/{}",
180                            crate::config::base_url(),
181                            name,
182                            sha,
183                            dest_url
184                        )
185                        .into(),
186                        _ => dest_url,
187                    };
188                    Event::Start(Tag::Image {
189                        link_type,
190                        dest_url,
191                        title,
192                        id,
193                    })
194                }
195                Event::Start(Tag::Link {
196                    link_type,
197                    dest_url,
198                    title,
199                    id,
200                }) => {
201                    let dest_url = match &head_sha {
202                        Some(sha) if is_relative_url(&dest_url) => format!(
203                            "{}/{}/commits/{}/tree/{}",
204                            crate::config::base_url(),
205                            name,
206                            sha,
207                            dest_url
208                        )
209                        .into(),
210                        _ => dest_url,
211                    };
212                    Event::Start(Tag::Link {
213                        link_type,
214                        dest_url,
215                        title,
216                        id,
217                    })
218                }
219                _ => event,
220            });
221            html::push_html(&mut rendered, events);
222
223            Some(rendered)
224        });
225
226    let default_branch = head_name.map(|h| h.shorten().to_string());
227
228    Ok(Repo {
229        name,
230        description,
231        branches,
232        tags,
233        commit_count,
234        head_sha,
235        default_branch,
236        readme,
237    })
238}
239
240/// Template context for a single repository in the list.
241struct RepoSummary {
242    name: String,
243    description: Option<String>,
244    commit_count: usize,
245    last_commit: Option<String>,
246}
247
248/// Template context for the repository list page.
249#[derive(Template, WebTemplate)]
250#[template(path = "repo_list.html")]
251struct RepoList {
252    repos: Vec<RepoSummary>,
253}
254
255/// Renders the repository list page by scanning the root directory for git
256/// repositories.  Entries that cannot be opened as a git repository are
257/// skipped.  The list is sorted by latest commit first.
258pub async fn list(State(state): State<AppState>) -> Result<impl IntoResponse> {
259    let mut repos: Vec<RepoSummary> = std::fs::read_dir(&state.root)?
260        .filter_map(|entry| {
261            let name = entry.ok()?.file_name().to_string_lossy().into_owned();
262            let git_repo = git::open_repo(&state.root, &name).ok()?;
263            let description = repo_description(&git_repo);
264
265            let head_id = git_repo.head_id().ok();
266
267            let commit_count = head_id
268                .as_ref()
269                .and_then(|id| id.ancestors().all().ok())
270                .map_or(0, std::iter::Iterator::count);
271
272            let last_commit = head_id
273                .and_then(|id| id.object().ok())
274                .and_then(|obj| obj.try_into_commit().ok())
275                .and_then(|commit| commit_date(&commit));
276
277            Some(RepoSummary {
278                name,
279                description,
280                commit_count,
281                last_commit,
282            })
283        })
284        .collect();
285
286    repos.sort_by(|a, b| b.last_commit.cmp(&a.last_commit));
287
288    Ok(RepoList { repos })
289}