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