Parent [>]
1//! Route handlers, shared types, and router construction.
2//!
3//! This module defines the [`AppState`] injected into every handler, the
4//! [`RepoName`] path extractor (which validates against directory traversal),
5//! the [`Breadcrumb`] type used by tree/blame pages, and the [`router`]
6//! function that wires all 15 routes together.
7
8mod archive;
9mod blame;
10mod branch;
11mod commit;
12mod git_upload;
13mod raw;
14mod repo;
15mod tag;
16mod tree;
17
18use std::collections::HashMap;
19use std::path::{Component, PathBuf};
20
21use crate::error::{Error, Result};
22use axum::Router;
23use axum::extract::{FromRequestParts, Path, State};
24use axum::http::header;
25use axum::http::request::Parts;
26use axum::response::IntoResponse;
27use axum::routing::{get, post};
28use serde::Serialize;
29
30/// A breadcrumb segment for the navigation bar.
31#[derive(Serialize)]
32pub struct Breadcrumb {
33    pub label: String,
34    pub url: String,
35}
36
37/// Builds breadcrumbs for a tree path.
38///
39/// The root breadcrumb is always `"tree"`.  Each path segment adds a breadcrumb
40/// linking to its corresponding subdirectory.
41pub fn build_breadcrumbs(base: &str, repo: &str, sha: &str, path: &str) -> Vec<Breadcrumb> {
42    let mut crumbs = Vec::new();
43    crumbs.push(Breadcrumb {
44        label: "tree".to_string(),
45        url: format!("{base}/{repo}/commits/{sha}/tree"),
46    });
47    if !path.is_empty() {
48        let mut accumulated = String::new();
49        for segment in path.split('/') {
50            if !accumulated.is_empty() {
51                accumulated.push('/');
52            }
53            accumulated.push_str(segment);
54            crumbs.push(Breadcrumb {
55                label: segment.to_string(),
56                url: format!("{base}/{repo}/commits/{sha}/tree/{accumulated}"),
57            });
58        }
59    }
60    crumbs
61}
62
63/// Axum extractor that pulls the `{repo}` path parameter and validates it
64/// contains no path traversal components before passing it to handlers.
65pub struct RepoName(pub String);
66
67impl<S: Send + Sync> FromRequestParts<S> for RepoName {
68    type Rejection = Error;
69
70    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
71        let Path(params) = Path::<HashMap<String, String>>::from_request_parts(parts, state)
72            .await
73            .map_err(|e| Error::BadRequest(e.to_string()))?;
74
75        let name = params
76            .get("repo")
77            .ok_or_else(|| Error::BadRequest("missing repo parameter".to_string()))?
78            .clone();
79
80        // Reject names containing non-normal components (e.g. `..`) to prevent
81        // directory traversal.
82        if std::path::Path::new(&name)
83            .components()
84            .any(|c| !matches!(c, Component::Normal(_)))
85        {
86            return Err(Error::BadRequest("invalid repository name".to_string()));
87        }
88
89        Ok(Self(name))
90    }
91}
92
93/// Shared application state passed to all route handlers.
94#[derive(Clone)]
95pub struct AppState {
96    /// Path to the directory containing bare git repositories.
97    pub root: PathBuf,
98    /// Concatenated CSS served at `/style.css`.
99    pub css: String,
100}
101
102/// Serves the concatenated stylesheet.
103async fn style(State(state): State<AppState>) -> impl IntoResponse {
104    (
105        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
106        state.css,
107    )
108}
109
110/// Catch-all for undefined sub-paths under `/{repo}/`.
111/// Returns a styled 404 via the error handler instead of axum's bare 404.
112async fn fallback(Path(params): Path<HashMap<String, String>>) -> impl IntoResponse {
113    let repo = params.get("repo").map(|s| s.as_str()).unwrap_or("?");
114    let path = params.get("path").map(|s| s.as_str()).unwrap_or("");
115    Error::NotFound(format!("/{repo}/{path}"))
116}
117
118/// Serves the favicon bytes loaded at startup, with the correct MIME type.
119async fn favicon() -> impl IntoResponse {
120    (
121        [(header::CONTENT_TYPE, crate::config::favicon_mime_type())],
122        crate::config::favicon(),
123    )
124}
125
126/// Builds the application router with all routes and shared state attached.
127pub fn router(state: AppState) -> Router {
128    Router::new()
129        .route("/favicon.ico", get(favicon))
130        .route("/style.css", get(style))
131        .route("/", get(repo::list))
132        .route("/{repo}", get(repo::detail))
133        .route("/{repo}/tags", get(tag::list))
134        .route("/{repo}/tags/{*tag}", get(tag::detail))
135        .route("/{repo}/branches", get(branch::list))
136        .route("/{repo}/branches/{*branch}", get(branch::detail))
137        .route("/{repo}/commits", get(commit::list))
138        .route("/{repo}/commits/{sha}", get(commit::detail))
139        .route("/{repo}/commits/{sha}/tree", get(tree::handler))
140        .route("/{repo}/commits/{sha}/tree/{*path}", get(tree::handler))
141        .route("/{repo}/commits/{sha}/blame/{*path}", get(blame::handler))
142        .route("/{repo}/commits/{sha}/raw/{*path}", get(raw::handler))
143        .route("/{repo}/archive/{format}/{*ref}", get(archive::handler))
144        .route("/{repo}/info/refs", get(git_upload::info_refs))
145        .route("/{repo}/git-upload-pack", post(git_upload::upload_pack))
146        .route("/{repo}/{*path}", get(fallback))
147        .with_state(state)
148}