Parent [>]
1mod archive;
2mod branch;
3mod commit;
4mod raw;
5mod repo;
6mod tag;
7mod tree;
8
9use std::collections::HashMap;
10use std::path::{Component, PathBuf};
11
12use axum::Router;
13use axum::extract::{FromRequestParts, Path, State};
14use axum::http::header;
15use axum::http::request::Parts;
16use axum::response::IntoResponse;
17use axum::routing::get;
18use jiff::Timestamp;
19use jiff::tz::{Offset, TimeZone};
20
21use crate::error::{Error, Result};
22
23/// Returns the commit's author timestamp as Unix seconds.
24pub(super) fn commit_timestamp(commit: &gix::Commit) -> Option<i64> {
25    commit.time().ok().map(|t| t.seconds)
26}
27
28/// Formats a commit's author date as a `YYYY-MM-DD` string in the commit's
29/// local timezone, returning `None` if the time cannot be parsed.
30pub(super) fn commit_date(commit: &gix::Commit) -> Option<String> {
31    let time = commit.time().ok()?;
32    let zone = TimeZone::fixed(Offset::from_seconds(time.offset).ok()?);
33
34    Timestamp::from_second(time.seconds)
35        .map(|ts| ts.to_zoned(zone).date().to_string())
36        .ok()
37}
38
39/// Formats a commit's committer date as a `YYYY-MM-DD` string in the
40/// commit's local timezone, returning `None` if the time cannot be parsed.
41pub(super) fn committer_date(commit: &gix::Commit) -> Option<String> {
42    let sig = commit.committer().ok()?;
43    let time = sig.time().ok()?;
44    let zone = TimeZone::fixed(Offset::from_seconds(time.offset).ok()?);
45
46    Timestamp::from_second(time.seconds)
47        .map(|ts| ts.to_zoned(zone).date().to_string())
48        .ok()
49}
50
51/// Axum extractor that pulls the `{repo}` path parameter and validates it
52/// contains no path traversal components before passing it to handlers.
53pub struct RepoName(pub String);
54
55impl<S: Send + Sync> FromRequestParts<S> for RepoName {
56    type Rejection = Error;
57
58    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
59        let Path(params) = Path::<HashMap<String, String>>::from_request_parts(parts, state)
60            .await
61            .map_err(|e| Error::BadRequest(e.to_string()))?;
62
63        let name = params
64            .get("repo")
65            .ok_or_else(|| Error::BadRequest("missing repo parameter".to_string()))?
66            .clone();
67
68        // Reject names containing non-normal components (e.g. `..`) to prevent
69        // directory traversal.
70        if std::path::Path::new(&name)
71            .components()
72            .any(|c| !matches!(c, Component::Normal(_)))
73        {
74            return Err(Error::BadRequest("invalid repository name".to_string()));
75        }
76
77        Ok(RepoName(name))
78    }
79}
80
81/// Shared application state passed to all route handlers.
82#[derive(Clone)]
83pub struct AppState {
84    /// Path to the directory containing bare git repositories.
85    pub root: PathBuf,
86    /// Concatenated CSS served at `/style.css`.
87    pub css: String,
88}
89
90/// Serves the concatenated stylesheet.
91async fn style(State(state): State<AppState>) -> impl IntoResponse {
92    (
93        [(header::CONTENT_TYPE, "text/css; charset=utf-8")],
94        state.css,
95    )
96}
97
98/// Builds the application router with all routes and shared state attached.
99pub fn router(state: AppState) -> Router {
100    Router::new()
101        .route("/style.css", get(style))
102        .route("/", get(repo::list))
103        .route("/{repo}", get(repo::detail))
104        .route("/{repo}/tags", get(tag::list))
105        .route("/{repo}/tags/{*tag}", get(tag::detail))
106        .route("/{repo}/branches", get(branch::list))
107        .route("/{repo}/branches/{*branch}", get(branch::detail))
108        .route("/{repo}/commits", get(commit::list))
109        .route("/{repo}/commits/{sha}", get(commit::detail))
110        .route("/{repo}/commits/{sha}/tree", get(tree::handler))
111        .route("/{repo}/commits/{sha}/tree/{*path}", get(tree::handler))
112        .route("/{repo}/commits/{sha}/raw/{*path}", get(raw::handler))
113        .route("/{repo}/archive/{format}/{*ref}", get(archive::handler))
114        .with_state(state)
115}