wayver's git archive


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

src/handlers/repo_file.rs@f3f2b40f0ffae5de2e6d3f661e32b582274bae49

raw
Date Commit Message Author Files + -
2026-02-17 21:07 initial mvp wayverd 74 10800 0
...

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