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