A minimal self-hosted git browser on Rust axum: bare repos rendered as a website with commits, diffs, syntax-highlighted blobs, atom feeds, and clone over HTTPS.
axumdockergitgit-browsergitoxidegixrustself-hosted
1use axum::{http::header, middleware as axum_middleware, Router};
2use minijinja::Environment;
3use std::path::PathBuf;
4use std::sync::Arc;
5use tower_http::services::ServeDir;
6use tower_http::set_header::SetResponseHeaderLayer;
7
8use crate::middleware::log_requests;
9use crate::routes;
10use crate::templates;
11
12#[derive(Clone)]
13pub struct AppState {
14 pub env: Arc<Environment<'static>>,
15 pub config: Arc<Config>,
16}
17
18#[derive(Debug, Clone)]
19pub struct Config {
20 pub root: PathBuf,
21 pub repo_root: PathBuf,
22 pub base_url: String,
23 pub clone_base: String,
24 pub site_title: String,
25 pub site_tagline: String,
26}
27
28impl AppState {
29 pub fn from_env() -> Self {
30 let root: PathBuf = std::env::var("REPOS_ROOT")
31 .map(PathBuf::from)
32 .unwrap_or_else(|_| PathBuf::from("."));
33 let repo_root: PathBuf = std::env::var("REPOS_REPO_ROOT")
34 .map(PathBuf::from)
35 .unwrap_or_else(|_| PathBuf::from("/srv/git"));
36 let base_url = std::env::var("BASE_URL").unwrap_or_default();
37 let clone_base = std::env::var("REPOS_CLONE_BASE")
38 .unwrap_or_else(|_| "https://repos.bythewood.me".to_string());
39 let site_title = std::env::var("REPOS_TITLE").unwrap_or_else(|_| "repos".to_string());
40 let site_tagline = std::env::var("REPOS_TAGLINE").unwrap_or_default();
41
42 let templates_dir = root.join("templates");
43 let manifest_path = root.join("dist/.vite/manifest.json");
44 let env = Arc::new(templates::build_env(&templates_dir, &manifest_path));
45
46 let config = Arc::new(Config {
47 root,
48 repo_root,
49 base_url,
50 clone_base,
51 site_title,
52 site_tagline,
53 });
54
55 Self { env, config }
56 }
57}
58
59pub fn router(state: AppState) -> Router {
60 let dist_dir = state.config.root.join("dist");
61
62 let static_cache = SetResponseHeaderLayer::if_not_present(
63 header::CACHE_CONTROL,
64 header::HeaderValue::from_static("public, max-age=31536000, immutable"),
65 );
66
67 Router::new()
68 // Smart-HTTP clone endpoints live at /:name.git/... and need to be
69 // matched before the catch-all repo routes; merge clone first.
70 .merge(routes::clone::router())
71 // seo routes (favicon, robots) before the repo catch-all so
72 // /favicon.ico doesn't hit /:name.
73 .merge(routes::seo::router())
74 .merge(routes::index::router())
75 .merge(routes::atom::router())
76 .merge(routes::log::router())
77 .merge(routes::commit::router())
78 .merge(routes::tree::router())
79 .merge(routes::blob::router())
80 // repo landing page is the most general single-segment route, so
81 // merge it last to avoid swallowing /static, /favicon.ico, etc.
82 .merge(routes::repo::router())
83 .nest_service(
84 "/static",
85 tower::ServiceBuilder::new()
86 .layer(static_cache)
87 .service(ServeDir::new(&dist_dir)),
88 )
89 .fallback(crate::middleware::not_found)
90 .layer(axum_middleware::from_fn(log_requests))
91 .with_state(state)
92}