1#![deny(clippy::pedantic)]
2
3use std::{
4 borrow::Cow,
5 fmt::{Display, Formatter},
6 future::IntoFuture,
7 net::SocketAddr,
8 path::PathBuf,
9 str::FromStr,
10 sync::{Arc, OnceLock},
11 time::Duration,
12};
13
14use anyhow::Context;
15use askama::Template;
16use axum::{
17 body::Body,
18 http,
19 http::{HeaderValue, StatusCode},
20 response::{IntoResponse, Response},
21 routing::get,
22 Extension, Router,
23};
24use clap::Parser;
25use const_format::formatcp;
26use database::schema::SCHEMA_VERSION;
27use rocksdb::{Options, SliceTransform};
28use tokio::{
29 net::TcpListener,
30 signal::unix::{signal, SignalKind},
31 sync::mpsc,
32};
33use tower_http::{cors::CorsLayer, timeout::TimeoutLayer};
34use tower_layer::layer_fn;
35use tracing::{error, info, instrument, warn};
36use tracing_subscriber::{
37 fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter,
38};
39use xxhash_rust::const_xxh3;
40
41use crate::{
42 database::schema::prefixes::{
43 COMMIT_COUNT_FAMILY, COMMIT_FAMILY, REFERENCE_FAMILY, REPOSITORY_FAMILY, TAG_FAMILY,
44 },
45 git::Git,
46 layers::logger::LoggingMiddleware,
47 syntax_highlight::prime_highlighters,
48 theme::Theme,
49};
50
51mod database;
52mod git;
53mod layers;
54mod methods;
55mod syntax_highlight;
56mod theme;
57mod unified_diff_builder;
58
59const CRATE_VERSION: &str = clap::crate_version!();
60
61const GLOBAL_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/statics/css/style.css"));
62const GLOBAL_CSS_HASH: &str = const_hex::Buffer::<16, false>::new()
63 .const_format(&const_xxh3::xxh3_128(GLOBAL_CSS).to_be_bytes())
64 .as_str();
65
66static HIGHLIGHT_CSS_HASH: OnceLock<Box<str>> = OnceLock::new();
67static DARK_HIGHLIGHT_CSS_HASH: OnceLock<Box<str>> = OnceLock::new();
68
69#[derive(Parser, Debug)]
70#[clap(author, version, about)]
71pub struct Args {
72 /// Path to a directory in which the `RocksDB` database should be stored, will be created if it doesn't already exist
73 ///
74 /// The `RocksDB` database is very quick to generate, so this can be pointed to temporary storage
75 #[clap(short, long, value_parser)]
76 db_store: PathBuf,
77 /// The socket address to bind to (eg. 0.0.0.0:3333)
78 bind_address: SocketAddr,
79 /// The path in which your bare Git repositories reside (will be scanned recursively)
80 scan_path: PathBuf,
81 /// Optional path (relative to cwd) to a plain text file containing a list of repositories relative to the `scan_path`
82 /// that are whitelisted to be exposed by rgit.
83 #[clap(long)]
84 repository_list: Option<PathBuf>,
85 /// Configures the metadata refresh interval (eg. "never" or "60s")
86 #[clap(long, default_value_t = RefreshInterval::Duration(Duration::from_secs(300)))]
87 refresh_interval: RefreshInterval,
88 /// Configures the request timeout.
89 #[clap(long, default_value_t = Duration::from_secs(10).into())]
90 request_timeout: humantime::Duration,
91}
92
93#[derive(Debug, Clone, Copy)]
94pub enum RefreshInterval {
95 Never,
96 Duration(Duration),
97}
98
99impl Display for RefreshInterval {
100 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
101 match self {
102 Self::Never => write!(f, "never"),
103 Self::Duration(s) => write!(f, "{}", humantime::format_duration(*s)),
104 }
105 }
106}
107
108impl FromStr for RefreshInterval {
109 type Err = &'static str;
110
111 fn from_str(s: &str) -> Result<Self, Self::Err> {
112 if s == "never" {
113 Ok(Self::Never)
114 } else if let Ok(v) = humantime::parse_duration(s) {
115 Ok(Self::Duration(v))
116 } else {
117 Err("must be seconds, a human readable duration (eg. '10m') or 'never'")
118 }
119 }
120}
121
122#[tokio::main]
123#[allow(clippy::too_many_lines)]
124async fn main() -> Result<(), anyhow::Error> {
125 let args: Args = Args::parse();
126
127 if std::env::var_os("RUST_LOG").is_none() {
128 std::env::set_var("RUST_LOG", "info");
129 }
130
131 let logger_layer = tracing_subscriber::fmt::layer().with_span_events(FmtSpan::CLOSE);
132 let env_filter = EnvFilter::from_default_env();
133
134 tracing_subscriber::registry()
135 .with(env_filter)
136 .with(logger_layer)
137 .init();
138
139 let db = open_db(&args)?;
140
141 let indexer_wakeup_task = run_indexer(
142 db.clone(),
143 args.scan_path.clone(),
144 args.repository_list.clone(),
145 args.refresh_interval,
146 );
147
148 let css = {
149 let theme = basic_toml::from_str::<Theme>(include_str!("../themes/github_light.toml"))
150 .unwrap()
151 .build_css();
152 let css = Box::leak(
153 format!("@media (prefers-color-scheme: light){{{theme}}}")
154 .into_boxed_str()
155 .into_boxed_bytes(),
156 );
157 HIGHLIGHT_CSS_HASH.set(build_asset_hash(css)).unwrap();
158 css
159 };
160
161 let dark_css = {
162 let theme = basic_toml::from_str::<Theme>(include_str!("../themes/onedark.toml"))
163 .unwrap()
164 .build_css();
165 let css = Box::leak(
166 format!("@media (prefers-color-scheme: dark){{{theme}}}")
167 .into_boxed_str()
168 .into_boxed_bytes(),
169 );
170 DARK_HIGHLIGHT_CSS_HASH.set(build_asset_hash(css)).unwrap();
171 css
172 };
173
174 let static_favicon = |content: &'static [u8]| {
175 move || async move {
176 let mut resp = Response::new(Body::from(content));
177 resp.headers_mut().insert(
178 http::header::CONTENT_TYPE,
179 HeaderValue::from_static("image/x-icon"),
180 );
181 resp
182 }
183 };
184
185 let static_css = |content: &'static [u8]| {
186 move || async move {
187 let mut resp = Response::new(Body::from(content));
188 resp.headers_mut().insert(
189 http::header::CONTENT_TYPE,
190 HeaderValue::from_static("text/css"),
191 );
192 resp
193 }
194 };
195
196 info!("Priming highlighters...");
197 prime_highlighters();
198 info!("Server starting up...");
199
200 let app = Router::new()
201 .route("/", get(methods::index::handle))
202 .route(
203 formatcp!("/style-{}.css", GLOBAL_CSS_HASH),
204 get(static_css(GLOBAL_CSS)),
205 )
206 .route(
207 &format!("/highlight-{}.css", HIGHLIGHT_CSS_HASH.get().unwrap()),
208 get(static_css(css)),
209 )
210 .route(
211 &format!(
212 "/highlight-dark-{}.css",
213 DARK_HIGHLIGHT_CSS_HASH.get().unwrap()
214 ),
215 get(static_css(dark_css)),
216 )
217 .route(
218 "/favicon.ico",
219 get(static_favicon(include_bytes!("../statics/favicon.ico"))),
220 )
221 .fallback(methods::repo::service)
222 .layer(TimeoutLayer::new(args.request_timeout.into()))
223 .layer(layer_fn(LoggingMiddleware))
224 .layer(Extension(Arc::new(Git::new())))
225 .layer(Extension(db))
226 .layer(Extension(Arc::new(args.scan_path)))
227 .layer(CorsLayer::new());
228
229 let listener = TcpListener::bind(&args.bind_address).await?;
230 let app = app.into_make_service_with_connect_info::<SocketAddr>();
231 let server = axum::serve(listener, app).into_future();
232
233 tokio::select! {
234 res = server => res.context("failed to run server"),
235 res = indexer_wakeup_task => res.context("failed to run indexer"),
236 _ = tokio::signal::ctrl_c() => {
237 info!("Received ctrl-c, shutting down");
238 Ok(())
239 }
240 }
241}
242
243fn open_db(args: &Args) -> Result<Arc<rocksdb::DB>, anyhow::Error> {
244 loop {
245 let mut db_options = Options::default();
246 db_options.create_missing_column_families(true);
247 db_options.create_if_missing(true);
248
249 let mut commit_family_options = Options::default();
250 commit_family_options.set_prefix_extractor(SliceTransform::create(
251 "commit_prefix",
252 |input| memchr::memchr(b'\0', input).map_or(input, |idx| &input[..idx]),
253 None,
254 ));
255
256 let mut tag_family_options = Options::default();
257 tag_family_options.set_prefix_extractor(SliceTransform::create_fixed_prefix(
258 std::mem::size_of::<u64>(),
259 )); // repository id prefix
260
261 let db = rocksdb::DB::open_cf_with_opts(
262 &db_options,
263 &args.db_store,
264 vec![
265 (COMMIT_FAMILY, commit_family_options),
266 (REPOSITORY_FAMILY, Options::default()),
267 (TAG_FAMILY, tag_family_options),
268 (REFERENCE_FAMILY, Options::default()),
269 (COMMIT_COUNT_FAMILY, Options::default()),
270 ],
271 )?;
272
273 let needs_schema_regen = match db.get("schema_version")? {
274 Some(v) if v.as_slice() != SCHEMA_VERSION.as_bytes() => Some(Some(v)),
275 Some(_) => None,
276 None => {
277 db.put("schema_version", SCHEMA_VERSION)?;
278 None
279 }
280 };
281
282 if let Some(version) = needs_schema_regen {
283 let old_version = version
284 .as_deref()
285 .map_or(Cow::Borrowed("unknown"), String::from_utf8_lossy);
286
287 warn!("Clearing outdated database ({old_version} != {SCHEMA_VERSION})");
288
289 drop(db);
290 rocksdb::DB::destroy(&Options::default(), &args.db_store)?;
291 } else {
292 break Ok(Arc::new(db));
293 }
294 }
295}
296
297async fn run_indexer(
298 db: Arc<rocksdb::DB>,
299 scan_path: PathBuf,
300 repository_list: Option<PathBuf>,
301 refresh_interval: RefreshInterval,
302) -> Result<(), tokio::task::JoinError> {
303 let (indexer_wakeup_send, mut indexer_wakeup_recv) = mpsc::channel(10);
304
305 std::thread::spawn(move || loop {
306 info!("Running periodic index");
307 crate::database::indexer::run(&scan_path, repository_list.as_deref(), &db);
308 info!("Finished periodic index");
309
310 if indexer_wakeup_recv.blocking_recv().is_none() {
311 break;
312 }
313 });
314
315 tokio::spawn({
316 let mut sighup = signal(SignalKind::hangup()).expect("could not subscribe to sighup");
317 let build_sleeper = move || async move {
318 match refresh_interval {
319 RefreshInterval::Never => futures_util::future::pending().await,
320 RefreshInterval::Duration(v) => tokio::time::sleep(v).await,
321 };
322 };
323
324 async move {
325 loop {
326 tokio::select! {
327 _ = sighup.recv() => {},
328 () = build_sleeper() => {},
329 }
330
331 if indexer_wakeup_send.send(()).await.is_err() {
332 error!("Indexing thread has died and is no longer accepting wakeup messages");
333 }
334 }
335 }
336 })
337 .await
338}
339
340#[must_use]
341pub fn build_asset_hash(v: &[u8]) -> Box<str> {
342 let hasher = const_xxh3::xxh3_128(v);
343 let out = const_hex::encode(hasher.to_be_bytes());
344 Box::from(out)
345}
346
347pub struct TemplateResponse<T> {
348 template: T,
349}
350
351impl<T: Template> IntoResponse for TemplateResponse<T> {
352 #[instrument(skip_all)]
353 fn into_response(self) -> Response {
354 match self.template.render() {
355 Ok(body) => {
356 let headers = [(
357 http::header::CONTENT_TYPE,
358 HeaderValue::from_static("text/html; charset=utf-8"),
359 )];
360
361 (headers, body).into_response()
362 }
363 Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
364 }
365 }
366}
367
368pub fn into_response<T: Template>(template: T) -> impl IntoResponse {
369 TemplateResponse { template }
370}
371
372pub enum ResponseEither<A, B> {
373 Left(A),
374 Right(B),
375}
376
377impl<A: IntoResponse, B: IntoResponse> IntoResponse for ResponseEither<A, B> {
378 fn into_response(self) -> Response {
379 match self {
380 Self::Left(a) => a.into_response(),
381 Self::Right(b) => b.into_response(),
382 }
383 }
384}
385
386#[cfg(test)]
387mod test {
388 #[test]
389 fn placeholder() {
390 assert!(true);
391 }
392}