Parent [>]
1//! Route handler for serving raw file contents from a git repository.
2//!
3//! Covers one route: `/{repo}/commits/{sha}/raw/{*path}`.  The handler resolves
4//! the rev-spec `{sha}:{path}` via `gix`, validates the path against directory
5//! traversal, and serves the blob bytes with an appropriate `Content-Type`
6//! header derived from the file extension.
7
8use axum::extract::{Path, State};
9use axum::http::header;
10use axum::response::IntoResponse;
11use serde::Deserialize;
12
13use crate::error::{Error, Result};
14use crate::git::{self, GitResultExt as _};
15use crate::routes::{AppState, RepoName};
16
17/// Path parameters for the raw file route.
18#[derive(Deserialize)]
19pub(super) struct RawPath {
20    sha: String,
21    path: String,
22}
23
24/// Serves the raw contents of a file at `{sha}:{path}` in the given repository
25/// with an appropriate `Content-Type` header.
26pub async fn handler(
27    State(state): State<AppState>,
28    RepoName(name): RepoName,
29    Path(params): Path<RawPath>,
30) -> Result<impl IntoResponse> {
31    git::validate_path(&params.path)?;
32
33    let git_repo = git::open_repo(&state.root, &name)?;
34
35    // Build a `sha:path` rev-spec and resolve it to a blob.
36    let spec = format!("{}:{}", params.sha, params.path);
37    let blob = git_repo
38        .rev_parse_single(spec.as_str())
39        .map_err(|_| Error::NotFound(spec.clone()))?
40        .object()
41        .corrupt()?
42        .try_into_blob()
43        .map_err(|_| Error::NotFound(spec))?;
44
45    let content_type = content_type_for(&params.path);
46
47    Ok((
48        [
49            (header::CONTENT_TYPE, content_type),
50            (header::CONTENT_SECURITY_POLICY, "sandbox"),
51            (header::X_CONTENT_TYPE_OPTIONS, "nosniff"),
52        ],
53        blob.data.clone(),
54    ))
55}
56
57/// Returns a `Content-Type` string based on the file extension of `path`.
58/// Falls back to `application/octet-stream` for unknown extensions.
59fn content_type_for(path: &str) -> &'static str {
60    match path.rsplit('.').next().unwrap_or("") {
61        "png" => "image/png",
62        "jpg" | "jpeg" => "image/jpeg",
63        "gif" => "image/gif",
64        "svg" => "image/svg+xml",
65        "webp" => "image/webp",
66        "ico" => "image/x-icon",
67        "txt" | "md" => "text/plain; charset=utf-8",
68        _ => "application/octet-stream",
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    #[test]
77    fn content_type_png() {
78        assert_eq!(content_type_for("image.png"), "image/png");
79    }
80
81    #[test]
82    fn content_type_jpg() {
83        assert_eq!(content_type_for("photo.jpg"), "image/jpeg");
84    }
85
86    #[test]
87    fn content_type_jpeg() {
88        assert_eq!(content_type_for("photo.jpeg"), "image/jpeg");
89    }
90
91    #[test]
92    fn content_type_svg() {
93        assert_eq!(content_type_for("graph.svg"), "image/svg+xml");
94    }
95
96    #[test]
97    fn content_type_md() {
98        assert_eq!(content_type_for("readme.md"), "text/plain; charset=utf-8");
99    }
100
101    #[test]
102    fn content_type_unknown() {
103        assert_eq!(content_type_for("file.xyz"), "application/octet-stream");
104    }
105
106    #[test]
107    fn content_type_no_extension() {
108        assert_eq!(content_type_for("Makefile"), "application/octet-stream");
109    }
110}