gitscope
← rgit / 0.1.5 / 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    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}