wayver's git archive


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

src/handlers/repo_file.rs@6e060abc1f25c2e1b79fe06bfa8b72cd26952ee1

raw
Date Commit Message Author Files + -
2026-02-19 17:51 large refactoring wayverd 53 2153 1683
...

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        // this is a subtree
126        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        // this is not a subtree, so it should be a blob i.e. file
138        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
160// TODO: make sure I am escaping html properly here
161// TODO: allow disabling of syntax highlighting
162// TODO: -- dont pull in memory, use iterators if possible
163fn 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        // this is not a text file, but try to serve the file if the MIME type
178        // can give a hint at how
179        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    // get file contents from git object
207    let file_string = str::from_utf8(blob.content())?;
208
209    // create a highlighter that uses CSS classes so we can use prefers-color-scheme
210    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    // use oid so it is a permalink
219    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