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::git::{self, commit_date};
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 the repository detail page.
40#[derive(Template, WebTemplate)]
41#[template(path = "repo.html")]
42struct Repo {
43    name: String,
44    description: Option<String>,
45    branches: Vec<git::BranchInfo>,
46    tags: Vec<git::TagInfo>,
47    commit_count: usize,
48    head_sha: Option<String>,
49    default_branch: Option<String>,
50    readme: Option<String>,
51    site: std::sync::Arc<SiteConfig>,
52}
53
54/// Renders the detail page for a single repository, including its branches,
55/// tags, and description.  Returns [`Error::RepoNotFound`] if no repository
56/// exists at the given name, or [`Error::GitCorrupt`] if the ref database
57/// cannot be read.
58pub async fn detail(
59    State(state): State<AppState>,
60    RepoName(name): RepoName,
61) -> Result<impl IntoResponse> {
62    let git_repo = git::open_repo(&state.root, &name)?;
63
64    let branches = git::load_branches(&git_repo);
65    let tags = git::load_tags(&git_repo);
66    let description = repo_description(&git_repo);
67    let commit_count = git::commit_count(&git_repo);
68    let head_id = git_repo.head_id().ok();
69
70    let head_sha = head_id.map(|id| id.to_string());
71
72    let base_url = state.site.base_url.clone();
73    let site = state.site;
74
75    let readme = ["HEAD:README.md", "HEAD:README", "HEAD:readme.md"]
76        .iter()
77        .find_map(|&spec| {
78            let blob = git_repo
79                .rev_parse_single(spec)
80                .ok()
81                .and_then(|id| id.object().ok())
82                .and_then(|obj| obj.try_into_blob().ok())?;
83
84            let text = std::str::from_utf8(&blob.data).ok()?;
85
86            let mut rendered = String::new();
87            let mut opts = Options::empty();
88            opts.insert(Options::ENABLE_TABLES);
89            opts.insert(Options::ENABLE_FOOTNOTES);
90            opts.insert(Options::ENABLE_STRIKETHROUGH);
91            opts.insert(Options::ENABLE_TASKLISTS);
92            opts.insert(Options::ENABLE_SMART_PUNCTUATION);
93            opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
94            opts.insert(Options::ENABLE_GFM);
95            opts.insert(Options::ENABLE_DEFINITION_LIST);
96            opts.insert(Options::ENABLE_SUPERSCRIPT);
97            opts.insert(Options::ENABLE_MATH);
98            let events = Parser::new_ext(text, opts)
99                .filter(|event| {
100                    !matches!(event,
101                        Event::Html(_)
102                        | Event::InlineHtml(_)
103                        | Event::Start(Tag::HtmlBlock)
104                        | Event::End(TagEnd::HtmlBlock)
105                    )
106                })
107                .map(|event| match event {
108                Event::Start(Tag::Image {
109                    link_type,
110                    dest_url,
111                    title,
112                    id,
113                }) => {
114                    let dest_url = match &head_sha {
115                        Some(sha) if is_relative_url(&dest_url) => format!(
116                            "{}/{}/commits/{}/raw/{}",
117                            base_url,
118                            name,
119                            sha,
120                            dest_url
121                        )
122                        .into(),
123                        _ => dest_url,
124                    };
125                    Event::Start(Tag::Image {
126                        link_type,
127                        dest_url,
128                        title,
129                        id,
130                    })
131                }
132                Event::Start(Tag::Link {
133                    link_type,
134                    dest_url,
135                    title,
136                    id,
137                }) => {
138                    let dest_url = match &head_sha {
139                        Some(sha) if is_relative_url(&dest_url) => format!(
140                            "{}/{}/commits/{}/tree/{}",
141                            base_url,
142                            name,
143                            sha,
144                            dest_url
145                        )
146                        .into(),
147                        _ => dest_url,
148                    };
149                    Event::Start(Tag::Link {
150                        link_type,
151                        dest_url,
152                        title,
153                        id,
154                    })
155                }
156                _ => event,
157            });
158            html::push_html(&mut rendered, events);
159
160            Some(rendered)
161        });
162
163    let default_branch = git_repo.head_name().ok().flatten().map(|h| h.shorten().to_string());
164
165    Ok(Repo {
166        name,
167        description,
168        branches,
169        tags,
170        commit_count,
171        head_sha,
172        default_branch,
173        readme,
174        site,
175    })
176}
177
178/// Template context for a single repository in the list.
179struct RepoSummary {
180    name: String,
181    description: Option<String>,
182    commit_count: usize,
183    last_commit: Option<String>,
184}
185
186/// Template context for the repository list page.
187#[derive(Template, WebTemplate)]
188#[template(path = "repo_list.html")]
189struct RepoList {
190    repos: Vec<RepoSummary>,
191    site: std::sync::Arc<SiteConfig>,
192}
193
194/// Renders the repository list page by scanning the root directory for git
195/// repositories.  Entries that cannot be opened as a git repository are
196/// skipped.  The list is sorted by latest commit first.
197pub async fn list(State(state): State<AppState>) -> Result<impl IntoResponse> {
198    let mut repos: Vec<RepoSummary> = std::fs::read_dir(&state.root)?
199        .filter_map(|entry| {
200            let name = entry.ok()?.file_name().to_string_lossy().into_owned();
201            let git_repo = git::open_repo(&state.root, &name).ok()?;
202            let description = repo_description(&git_repo);
203
204            let commit_count = git::commit_count(&git_repo);
205            let head_id = git_repo.head_id().ok();
206
207            let last_commit = head_id
208                .and_then(|id| id.object().ok())
209                .and_then(|obj| obj.try_into_commit().ok())
210                .and_then(|commit| commit_date(&commit));
211
212            Some(RepoSummary {
213                name,
214                description,
215                commit_count,
216                last_commit,
217            })
218        })
219        .collect();
220
221    repos.sort_by(|a, b| b.last_commit.cmp(&a.last_commit));
222
223    Ok(RepoList { repos, site: state.site })
224}