raw
1use std::{fmt::Write as _, path};
2
3use axum::{
4 extract::State,
5 http::StatusCode,
6 response::{IntoResponse as _, Response},
7};
8use syntect::{
9 html::{ClassStyle, ClassedHTMLGenerator},
10 parsing::SyntaxSet,
11 util::LinesWithEndings,
12};
13
14use crate::{
15 BileState,
16 config::Config,
17 error::{Context as _, Result},
18 git::Repository,
19 http::{
20 extractor::{ObjectName, Ref, RepoName},
21 path::Path,
22 response::{ErrorPage, Html, Redirect},
23 },
24 utils::{blob_mime, filters},
25};
26
27#[derive(askama::Template)]
28#[template(path = "tree.html")]
29struct RepoTreeTemplate<'a> {
30 config: &'a Config,
31 repo: &'a Repository,
32 tree: git2::Tree<'a>,
33 path: &'a path::Path,
34 spec: &'a str,
35 last_commit: git2::Commit<'a>,
36}
37
38#[derive(askama::Template)]
39#[template(path = "file.html")]
40struct RepoFileTemplate<'a> {
41 config: &'a Config,
42 repo: &'a Repository,
43 path: &'a path::Path,
44 file_text: &'a str,
45 spec: &'a str,
46 last_commit: git2::Commit<'a>,
47}
48
49#[tracing::instrument(skip_all)]
50pub(crate) async fn get_1(state: State<BileState>, Path(repo_name): Path<RepoName>) -> Response {
51 state
52 .spawn(move |state| inner(&state, &repo_name, None, None))
53 .await
54}
55
56#[tracing::instrument(skip_all)]
57pub(crate) async fn get_2(
58 state: State<BileState>,
59 Path((repo_name, r#ref)): Path<(RepoName, Ref)>,
60) -> Response {
61 state
62 .spawn(move |state| inner(&state, &repo_name, Some(&r#ref), None))
63 .await
64}
65
66#[tracing::instrument(skip_all)]
67pub(crate) async fn get_3(
68 state: State<BileState>,
69 Path((repo_name, r#ref, object_name)): Path<(RepoName, Ref, ObjectName)>,
70) -> Response {
71 state
72 .spawn(move |state| inner(&state, &repo_name, Some(&r#ref), Some(&object_name)))
73 .await
74}
75
76fn inner(
77 state: &BileState,
78 repo_name: &RepoName,
79 r#ref: Option<&Ref>,
80 object_name: Option<&ObjectName>,
81) -> Result<Response> {
82 let Some(repo) = Repository::open(&state.config, repo_name).context("opening repository")?
83 else {
84 return Ok(ErrorPage::from(state)
85 .with_status(StatusCode::NOT_FOUND)
86 .into_response());
87 };
88
89 if repo.is_empty()? {
90 return Ok(Redirect::permanent(&format!("/{repo_name}"))
91 .unwrap_or(Redirect::PERMANENT_ROOT)
92 .into_response());
93 }
94
95 let spec = repo.ref_or_head_shorthand(r#ref)?;
96 let Some((commit, tree)) = repo
97 .commit_tree(&spec)
98 .context("failed to get commit tree")?
99 else {
100 return Ok(ErrorPage::from(state)
101 .with_status(StatusCode::NOT_FOUND)
102 .into_response());
103 };
104
105 let (path, tree_obj) = if let Some(path) = object_name {
106 let path = path::Path::new(&path.0);
107
108 (path, repo.tree_object(&tree, path)?)
109 } else {
110 (path::Path::new(""), Some(tree.into_object()))
111 };
112
113 let Some(tree_obj) = tree_obj else {
114 return Ok(ErrorPage::from(state)
115 .with_status(StatusCode::NOT_FOUND)
116 .into_response());
117 };
118
119 let Some(last_commit) = repo.file_last_commit(&spec, path)? else {
120 return Ok(ErrorPage::from(state)
121 .with_status(StatusCode::NOT_FOUND)
122 .into_response());
123 };
124
125 let tree_obj = match tree_obj.into_tree() {
126 Ok(sub_tree) => {
128 return Ok(Html(RepoTreeTemplate {
129 config: &state.config,
130 repo: &repo,
131 tree: sub_tree,
132 path,
133 spec: &spec,
134 last_commit,
135 })
136 .into_response());
137 }
138 Err(tree_obj) => tree_obj,
140 };
141
142 let Some(blob) = tree_obj.as_blob() else {
143 return Ok(ErrorPage::from(state)
144 .with_status(StatusCode::NOT_FOUND)
145 .into_response());
146 };
147
148 let output = render(&state.syntax, repo_name, path, &spec, &commit, blob)?;
149
150 Ok(Html(RepoFileTemplate {
151 config: &state.config,
152 repo: &repo,
153 path,
154 file_text: &output,
155 spec: &spec,
156 last_commit,
157 })
158 .into_response())
159}
160
161fn render(
165 syntaxes: &SyntaxSet,
166 repo_name: &RepoName,
167 path: &path::Path,
168 spec: &str,
169 commit: &git2::Commit<'_>,
170 blob: &git2::Blob<'_>,
171) -> Result<String> {
172 let extension = path
173 .extension()
174 .and_then(std::ffi::OsStr::to_str)
175 .unwrap_or_default();
176
177 if blob.is_binary() {
178 let mime = blob_mime(blob, extension);
181
182 let output = match mime.type_() {
183 mime::TEXT => unreachable!("git detected this file as binary"),
184 mime::IMAGE => format!(
185 "<img src=\"/{}/tree/{}/raw/{}\" />",
186 repo_name,
187 spec,
188 path.display()
189 ),
190 tag @ (mime::AUDIO | mime::VIDEO) => format!(
191 "<{} src=\"/{}/tree/{}/raw/{}\" controls>Your browser does not have support for playing this {0} file.</{0}>",
192 tag,
193 repo_name,
194 spec,
195 path.display()
196 ),
197 _ => "Cannot display binary file.".to_string(),
198 };
199
200 return Ok(output);
201 }
202
203 let syntax = syntaxes
204 .find_syntax_by_extension(extension)
205 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
206
207 let file_string = str::from_utf8(blob.content())?;
209
210 let mut highlighter =
212 ClassedHTMLGenerator::new_with_class_style(syntax, syntaxes, ClassStyle::Spaced);
213 LinesWithEndings::from(file_string).for_each(|line| {
214 if let Err(err) = highlighter.parse_html_for_line_which_includes_newline(line) {
215 tracing::error!(err=?err, "failed to highlight code");
216 }
217 });
218
219 let prefix = format!(
221 "/{}/tree/{}/item/{}",
222 repo_name,
223 commit.id(),
224 path.display()
225 );
226
227 let mut output = String::from("<pre>\n");
228 for (n, line) in highlighter.finalize().lines().enumerate() {
229 let _ = writeln!(
230 &mut output,
231 "<a href='{1}#L{0}' id='L{0}' class='line'>{0}</a>{2}",
232 n + 1,
233 prefix,
234 line,
235 );
236 }
237 output.push_str("</pre>\n");
238
239 Ok(output)
240}
241