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