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