wayver's git archive


a simple self-hosted git server
git clone https://git.wayver.dev/bile

src/handlers/repo_file.rs@f4279bc2ca927f6f651a20c5f0e7b5ebf76cc3a4

raw
Date Commit Message Author Files + -
2026-02-24 01:17 use mime_guess to actually guess the file type wayverd 5 77 3
...

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        // this is a subtree
127        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        // this is not a subtree, so it should be a blob i.e. file
139        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
161// TODO: make sure I am escaping html properly here
162// TODO: allow disabling of syntax highlighting
163// TODO: -- dont pull in memory, use iterators if possible
164fn 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        // this is not a text file, but try to serve the file if the MIME type
179        // can give a hint at how
180        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 | mime::BMP | mime::GIF | mime::JPEG | mime::PNG | mime::SVG => 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            name => {
198                tracing::warn!(mime=?mime, name=?name, "unsupported mime type");
199                "Cannot display binary file.".to_string()
200            }
201        };
202
203        return Ok(output);
204    }
205
206    let syntax = syntaxes
207        .find_syntax_by_extension(extension)
208        .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
209
210    // get file contents from git object
211    let file_string = str::from_utf8(blob.content())?;
212
213    // create a highlighter that uses CSS classes so we can use prefers-color-scheme
214    let mut highlighter =
215        ClassedHTMLGenerator::new_with_class_style(syntax, syntaxes, ClassStyle::Spaced);
216    LinesWithEndings::from(file_string).for_each(|line| {
217        if let Err(err) = highlighter.parse_html_for_line_which_includes_newline(line) {
218            tracing::error!(err=?err, "failed to highlight code");
219        }
220    });
221
222    // use oid so it is a permalink
223    let prefix = format!(
224        "/{}/tree/{}/item/{}",
225        repo_name,
226        commit.id(),
227        path.display()
228    );
229
230    let mut output = String::from("<pre>\n");
231    for (n, line) in highlighter.finalize().lines().enumerate() {
232        let _ = writeln!(
233            &mut output,
234            "<a href='{1}#L{0}' id='L{0}' class='line'>{0}</a>{2}",
235            n + 1,
236            prefix,
237            line,
238        );
239    }
240    output.push_str("</pre>\n");
241
242    Ok(output)
243}
244