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 via the service layer, reads the
6//! description from `.git/description`, and renders the README as HTML.
7
8use askama::Template;
9use askama_web::WebTemplate;
10use axum::extract::State;
11use axum::response::IntoResponse;
12
13use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, html};
14
15use crate::config::SiteConfig;
16use crate::error::Result;
17use crate::filters;
18use crate::git::{self, commit_date};
19use crate::routes::{AppState, RepoName};
20
21/// Reads the repository description from `.git/description`, returning `None`
22/// if the file is missing, empty, or contains the default git placeholder text.
23fn repo_description(git_repo: &gix::Repository) -> Option<String> {
24    std::fs::read_to_string(git_repo.path().join("description"))
25        .ok()
26        .map(|s| s.trim().to_string())
27        .filter(|s| !s.is_empty() && !s.starts_with("Unnamed repository"))
28}
29
30/// Returns `true` if `url` is a relative path that should be rewritten to
31/// point at the raw file serving route.
32fn is_relative_url(url: &str) -> bool {
33    !url.starts_with("http://")
34        && !url.starts_with("https://")
35        && !url.starts_with('#')
36        && !url.starts_with("mailto:")
37        && !url.starts_with("data:")
38}
39
40/// Template context for the repository detail page.
41#[derive(Template, WebTemplate)]
42#[template(path = "repo.html")]
43struct Repo {
44    name: String,
45    description: Option<String>,
46    branches: Vec<git::BranchInfo>,
47    tags: Vec<git::TagInfo>,
48    commit_count: usize,
49    head_sha: Option<String>,
50    default_branch: Option<String>,
51    readme: Option<String>,
52    site: std::sync::Arc<SiteConfig>,
53}
54
55/// Renders the detail page for a single repository, including its branches,
56/// tags, and description.  Returns [`Error::RepoNotFound`] if no repository
57/// exists at the given name, or [`Error::GitCorrupt`] if the ref database
58/// cannot be read.
59pub async fn detail(
60    State(state): State<AppState>,
61    RepoName(name): RepoName,
62) -> Result<impl IntoResponse> {
63    let git_repo = git::open_repo(&state.root, &name)?;
64
65    let branches = git::load_branches(&git_repo);
66    let tags = git::load_tags(&git_repo);
67    let description = repo_description(&git_repo);
68    let commit_count = git::commit_count(&git_repo);
69    let head_id = git_repo.head_id().ok();
70
71    let head_sha = head_id.map(|id| id.to_string());
72
73    let base_url = state.site.base_url.clone();
74    let site = state.site;
75
76    let readme = ["HEAD:README.md", "HEAD:README", "HEAD:readme.md"]
77        .iter()
78        .find_map(|&spec| {
79            let blob = git_repo
80                .rev_parse_single(spec)
81                .ok()
82                .and_then(|id| id.object().ok())
83                .and_then(|obj| obj.try_into_blob().ok())?;
84
85            let text = std::str::from_utf8(&blob.data).ok()?;
86
87            let mut rendered = String::new();
88            let mut opts = Options::empty();
89            opts.insert(Options::ENABLE_TABLES);
90            opts.insert(Options::ENABLE_FOOTNOTES);
91            opts.insert(Options::ENABLE_STRIKETHROUGH);
92            opts.insert(Options::ENABLE_TASKLISTS);
93            opts.insert(Options::ENABLE_SMART_PUNCTUATION);
94            opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
95            opts.insert(Options::ENABLE_GFM);
96            opts.insert(Options::ENABLE_DEFINITION_LIST);
97            opts.insert(Options::ENABLE_SUPERSCRIPT);
98            opts.insert(Options::ENABLE_MATH);
99            let events = Parser::new_ext(text, opts)
100                .filter(|event| {
101                    !matches!(event,
102                        Event::Html(_)
103                        | Event::InlineHtml(_)
104                        | Event::Start(Tag::HtmlBlock)
105                        | Event::End(TagEnd::HtmlBlock)
106                    )
107                })
108                .map(|event| match event {
109                Event::Start(Tag::Image {
110                    link_type,
111                    dest_url,
112                    title,
113                    id,
114                }) => {
115                    let dest_url = match &head_sha {
116                        Some(sha) if is_relative_url(&dest_url) => format!(
117                            "{}/{}/commits/{}/raw/{}",
118                            base_url,
119                            name,
120                            sha,
121                            dest_url
122                        )
123                        .into(),
124                        _ => dest_url,
125                    };
126                    Event::Start(Tag::Image {
127                        link_type,
128                        dest_url,
129                        title,
130                        id,
131                    })
132                }
133                Event::Start(Tag::Link {
134                    link_type,
135                    dest_url,
136                    title,
137                    id,
138                }) => {
139                    let dest_url = match &head_sha {
140                        Some(sha) if is_relative_url(&dest_url) => format!(
141                            "{}/{}/commits/{}/tree/{}",
142                            base_url,
143                            name,
144                            sha,
145                            dest_url
146                        )
147                        .into(),
148                        _ => dest_url,
149                    };
150                    Event::Start(Tag::Link {
151                        link_type,
152                        dest_url,
153                        title,
154                        id,
155                    })
156                }
157                _ => event,
158            });
159            html::push_html(&mut rendered, events);
160
161            Some(rendered)
162        });
163
164    let default_branch = git_repo.head_name().ok().flatten().map(|h| h.shorten().to_string());
165
166    Ok(Repo {
167        name,
168        description,
169        branches,
170        tags,
171        commit_count,
172        head_sha,
173        default_branch,
174        readme,
175        site,
176    })
177}
178
179/// Template context for a single repository in the list.
180struct RepoSummary {
181    name: String,
182    description: Option<String>,
183    commit_count: usize,
184    last_commit: Option<String>,
185}
186
187/// Template context for the repository list page.
188#[derive(Template, WebTemplate)]
189#[template(path = "repo_list.html")]
190struct RepoList {
191    repos: Vec<RepoSummary>,
192    site: std::sync::Arc<SiteConfig>,
193}
194
195/// Renders the repository list page by scanning the root directory for git
196/// repositories.  Entries that cannot be opened as a git repository are
197/// skipped.  The list is sorted by latest commit first.
198pub async fn list(State(state): State<AppState>) -> Result<impl IntoResponse> {
199    let mut repos: Vec<RepoSummary> = std::fs::read_dir(&state.root)?
200        .filter_map(|entry| {
201            let name = entry.ok()?.file_name().to_string_lossy().into_owned();
202            let git_repo = git::open_repo(&state.root, &name).ok()?;
203            let description = repo_description(&git_repo);
204
205            let commit_count = git::commit_count(&git_repo);
206            let head_id = git_repo.head_id().ok();
207
208            let last_commit = head_id
209                .and_then(|id| id.object().ok())
210                .and_then(|obj| obj.try_into_commit().ok())
211                .and_then(|commit| commit_date(&commit));
212
213            Some(RepoSummary {
214                name,
215                description,
216                commit_count,
217                last_commit,
218            })
219        })
220        .collect();
221
222    repos.sort_by(|a, b| b.last_commit.cmp(&a.last_commit));
223
224    Ok(RepoList { repos, site: state.site })
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn relative_http() {
233        assert!(!is_relative_url("http://example.com"));
234    }
235
236    #[test]
237    fn relative_https() {
238        assert!(!is_relative_url("https://example.com"));
239    }
240
241    #[test]
242    fn relative_http_case_sensitive() {
243        // Known quirk: uppercase scheme not matched, treated as relative
244        assert!(is_relative_url("Http://example.com"));
245    }
246
247    #[test]
248    fn relative_hash() {
249        assert!(!is_relative_url("#section"));
250    }
251
252    #[test]
253    fn relative_mailto() {
254        assert!(!is_relative_url("mailto:user@host"));
255    }
256
257    #[test]
258    fn relative_data() {
259        assert!(!is_relative_url("data:text/plain,hi"));
260    }
261
262    #[test]
263    fn relative_path_relative() {
264        assert!(is_relative_url("relative/path"));
265    }
266
267    #[test]
268    fn relative_path_absolute() {
269        assert!(is_relative_url("/absolute/path"));
270    }
271}