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