1#![deny(clippy::pedantic)]
2
3use std::{
4 fmt::{Display, Formatter},
5 net::SocketAddr,
6 path::PathBuf,
7 str::FromStr,
8 sync::Arc,
9 time::Duration,
10};
11
12use askama::Template;
13use axum::{
14 body::Body,
15 http,
16 http::{HeaderValue, StatusCode},
17 response::{IntoResponse, Response},
18 routing::get,
19 Extension, Router,
20};
21use bat::assets::HighlightingAssets;
22use clap::Parser;
23use once_cell::sync::{Lazy, OnceCell};
24use sha2::digest::FixedOutput;
25use sha2::Digest;
26use sled::Db;
27use syntect::html::ClassStyle;
28use tokio::{
29 signal::unix::{signal, SignalKind},
30 sync::mpsc,
31};
32use tower_http::cors::CorsLayer;
33use tower_layer::layer_fn;
34use tracing::{error, info, instrument};
35
36use crate::{git::Git, layers::logger::LoggingMiddleware};
37
38mod database;
39mod git;
40mod layers;
41mod methods;
42mod syntax_highlight;
43
44const CRATE_VERSION: &str = clap::crate_version!();
45
46static GLOBAL_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/statics/css/style.css",));
47static GLOBAL_CSS_HASH: Lazy<Box<str>> = Lazy::new(|| build_asset_hash(GLOBAL_CSS));
48
49static HIGHLIGHT_CSS_HASH: OnceCell<Box<str>> = OnceCell::new();
50static DARK_HIGHLIGHT_CSS_HASH: OnceCell<Box<str>> = OnceCell::new();
51
52#[derive(Parser, Debug)]
53#[clap(author, version, about)]
54pub struct Args {
55 /// Path to a directory in which the Sled database should be stored, will be created if it doesn't already exist
56 ///
57 /// The Sled database is very quick to generate, so this can be pointed to temporary storage
58 #[clap(short, long, value_parser)]
59 db_store: PathBuf,
60 /// The socket address to bind to (eg. 0.0.0.0:3333)
61 bind_address: SocketAddr,
62 /// The path in which your bare Git repositories reside (will be scanned recursively)
63 scan_path: PathBuf,
64 /// Configures the metadata refresh interval (eg. "never" or "60s")
65 #[clap(long, default_value_t = RefreshInterval::Duration(Duration::from_secs(300)))]
66 refresh_interval: RefreshInterval,
67}
68
69#[derive(Debug, Clone, Copy)]
70pub enum RefreshInterval {
71 Never,
72 Duration(Duration),
73}
74
75impl Display for RefreshInterval {
76 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
77 match self {
78 Self::Never => write!(f, "never"),
79 Self::Duration(s) => write!(f, "{}", humantime::format_duration(*s)),
80 }
81 }
82}
83
84impl FromStr for RefreshInterval {
85 type Err = &'static str;
86
87 fn from_str(s: &str) -> Result<Self, Self::Err> {
88 if s == "never" {
89 Ok(Self::Never)
90 } else if let Ok(v) = humantime::parse_duration(s) {
91 Ok(Self::Duration(v))
92 } else {
93 Err("must be seconds, a human readable duration (eg. '10m') or 'never'")
94 }
95 }
96}
97
98#[tokio::main]
99async fn main() -> Result<(), Box<dyn std::error::Error>> {
100 let args: Args = Args::parse();
101
102 let subscriber = tracing_subscriber::fmt();
103 #[cfg(debug_assertions)]
104 let subscriber = subscriber.pretty();
105 subscriber.init();
106
107 let db = sled::Config::default()
108 .use_compression(true)
109 .path(&args.db_store)
110 .open()
111 .unwrap();
112
113 let indexer_wakeup_task =
114 run_indexer(db.clone(), args.scan_path.clone(), args.refresh_interval);
115
116 let bat_assets = HighlightingAssets::from_binary();
117 let syntax_set = bat_assets.get_syntax_set().unwrap().clone();
118
119 let theme = bat_assets.get_theme("GitHub");
120 let css = syntect::html::css_for_theme_with_class_style(theme, ClassStyle::Spaced).unwrap();
121 let css = Box::leak(
122 format!(r#"@media (prefers-color-scheme: light){{{css}}}"#)
123 .into_boxed_str()
124 .into_boxed_bytes(),
125 );
126 HIGHLIGHT_CSS_HASH.set(build_asset_hash(css)).unwrap();
127
128 let dark_theme = bat_assets.get_theme("TwoDark");
129 let dark_css =
130 syntect::html::css_for_theme_with_class_style(dark_theme, ClassStyle::Spaced).unwrap();
131 let dark_css = Box::leak(
132 format!(r#"@media (prefers-color-scheme: dark){{{dark_css}}}"#)
133 .into_boxed_str()
134 .into_boxed_bytes(),
135 );
136 DARK_HIGHLIGHT_CSS_HASH
137 .set(build_asset_hash(dark_css))
138 .unwrap();
139
140 let static_favicon = |content: &'static [u8]| {
141 move || async move {
142 let mut resp = Response::new(Body::from(content));
143 resp.headers_mut().insert(
144 http::header::CONTENT_TYPE,
145 HeaderValue::from_static("image/x-icon"),
146 );
147 resp
148 }
149 };
150
151 let static_css = |content: &'static [u8]| {
152 move || async move {
153 let mut resp = Response::new(Body::from(content));
154 resp.headers_mut().insert(
155 http::header::CONTENT_TYPE,
156 HeaderValue::from_static("text/css"),
157 );
158 resp
159 }
160 };
161
162 let app = Router::new()
163 .route("/", get(methods::index::handle))
164 .route(
165 &format!("/style-{}.css", *GLOBAL_CSS_HASH),
166 get(static_css(GLOBAL_CSS)),
167 )
168 .route(
169 &format!("/highlight-{}.css", HIGHLIGHT_CSS_HASH.get().unwrap()),
170 get(static_css(css)),
171 )
172 .route(
173 &format!(
174 "/highlight-dark-{}.css",
175 DARK_HIGHLIGHT_CSS_HASH.get().unwrap()
176 ),
177 get(static_css(dark_css)),
178 )
179 .route(
180 "/favicon.ico",
181 get(static_favicon(include_bytes!("../statics/favicon.ico"))),
182 )
183 .fallback(methods::repo::service)
184 .layer(layer_fn(LoggingMiddleware))
185 .layer(Extension(Arc::new(Git::new(syntax_set))))
186 .layer(Extension(db))
187 .layer(Extension(Arc::new(args.scan_path)))
188 .layer(CorsLayer::new());
189
190 let server = axum::Server::bind(&args.bind_address)
191 .serve(app.into_make_service_with_connect_info::<SocketAddr>());
192
193 tokio::select! {
194 res = server => res.map_err(Box::from),
195 res = indexer_wakeup_task => res.map_err(Box::from),
196 _ = tokio::signal::ctrl_c() => {
197 info!("Received ctrl-c, shutting down");
198 Ok(())
199 }
200 }
201}
202
203async fn run_indexer(
204 db: Db,
205 scan_path: PathBuf,
206 refresh_interval: RefreshInterval,
207) -> Result<(), tokio::task::JoinError> {
208 let (indexer_wakeup_send, mut indexer_wakeup_recv) = mpsc::channel(10);
209
210 std::thread::spawn(move || loop {
211 info!("Running periodic index");
212 crate::database::indexer::run(&scan_path, &db);
213 info!("Finished periodic index");
214
215 if indexer_wakeup_recv.blocking_recv().is_none() {
216 break;
217 }
218 });
219
220 tokio::spawn({
221 let mut sighup = signal(SignalKind::hangup()).expect("could not subscribe to sighup");
222 let build_sleeper = move || async move {
223 match refresh_interval {
224 RefreshInterval::Never => futures::future::pending().await,
225 RefreshInterval::Duration(v) => tokio::time::sleep(v).await,
226 };
227 };
228
229 async move {
230 loop {
231 tokio::select! {
232 _ = sighup.recv() => {},
233 () = build_sleeper() => {},
234 }
235
236 if indexer_wakeup_send.send(()).await.is_err() {
237 error!("Indexing thread has died and is no longer accepting wakeup messages");
238 }
239 }
240 }
241 })
242 .await
243}
244
245#[must_use]
246pub fn build_asset_hash(v: &[u8]) -> Box<str> {
247 let mut hasher = sha2::Sha256::default();
248 hasher.update(v);
249 let mut out = hex::encode(hasher.finalize_fixed());
250 out.truncate(10);
251 Box::from(out)
252}
253
254#[instrument(skip(t))]
255pub fn into_response<T: Template>(t: &T) -> Response {
256 match t.render() {
257 Ok(body) => {
258 let headers = [(
259 http::header::CONTENT_TYPE,
260 HeaderValue::from_static(T::MIME_TYPE),
261 )];
262
263 (headers, body).into_response()
264 }
265 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
266 }
267}