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