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 std::path::Component;
9
10use axum::extract::{Path, State};
11use axum::http::header;
12use axum::response::IntoResponse;
13use serde::Deserialize;
14
15use crate::error::{Error, Result};
16use crate::routes::{AppState, RepoName};
17
18/// Path parameters for the raw file route.
19#[derive(Deserialize)]
20pub(super) struct RawPath {
21    sha: String,
22    path: String,
23}
24
25/// Serves the raw contents of a file at `{sha}:{path}` in the given repository
26/// with an appropriate `Content-Type` header.
27pub async fn handler(
28    State(state): State<AppState>,
29    RepoName(name): RepoName,
30    Path(params): Path<RawPath>,
31) -> Result<impl IntoResponse> {
32    // Reject paths containing non-normal components (e.g. `..`) to prevent
33    // directory traversal.
34    if std::path::Path::new(&params.path)
35        .components()
36        .any(|c| !matches!(c, Component::Normal(_)))
37    {
38        return Err(Error::BadRequest("invalid path".to_string()));
39    }
40
41    let git_repo =
42        gix::open(state.root.join(&name)).map_err(|_| Error::RepoNotFound(name.clone()))?;
43
44    // Build a `sha:path` rev-spec and resolve it to a blob.
45    let spec = format!("{}:{}", params.sha, params.path);
46    let blob = git_repo
47        .rev_parse_single(spec.as_str())
48        .map_err(|_| Error::NotFound(spec.clone()))?
49        .object()
50        .map_err(|e| Error::GitCorrupt(e.to_string()))?
51        .try_into_blob()
52        .map_err(|_| Error::NotFound(spec))?;
53
54    let content_type = content_type_for(&params.path);
55
56    Ok(([(header::CONTENT_TYPE, content_type)], blob.data.to_vec()))
57}
58
59/// Returns a `Content-Type` string based on the file extension of `path`.
60/// Falls back to `application/octet-stream` for unknown extensions.
61fn content_type_for(path: &str) -> &'static str {
62    match path.rsplit('.').next().unwrap_or("") {
63        "png" => "image/png",
64        "jpg" | "jpeg" => "image/jpeg",
65        "gif" => "image/gif",
66        "svg" => "image/svg+xml",
67        "webp" => "image/webp",
68        "ico" => "image/x-icon",
69        "txt" | "md" => "text/plain; charset=utf-8",
70        _ => "application/octet-stream",
71    }
72}