gitscope
← rgit / main / src/main.rs
https://dev.kronoz.win/r/rgit.git
src/main.rs · Rust
Blame Raw
  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}