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