use kittybox::database::FileStorage; use std::{env, time::Duration}; use tracing::{debug, error, info}; #[tokio::main] async fn main() { use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Registry}; Registry::default() .with(EnvFilter::from_default_env()) .with(tracing_subscriber::fmt::layer().json()) .init(); info!("Starting the kittybox server..."); let backend_uri: String = match env::var("BACKEND_URI") { Ok(val) => { debug!("Backend URI: {}", val); val } Err(_) => { error!("BACKEND_URI is not set, cannot find a database"); std::process::exit(1); } }; let listen_at = match env::var("SERVE_AT") .ok() .unwrap_or_else(|| "[::]:8080".to_string()) .parse::() { Ok(addr) => addr, Err(e) => { error!("Cannot parse SERVE_AT: {}", e); std::process::exit(1); } }; let http: reqwest::Client = { #[allow(unused_mut)] let mut builder = reqwest::Client::builder().user_agent(concat!( env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION") )); // TODO: add a root certificate if there's an environment variable pointing at it //builder = builder.add_root_certificate(reqwest::Certificate::from_pem(todo!())); builder.build().unwrap() }; if backend_uri.starts_with("redis") { println!("The Redis backend is deprecated."); std::process::exit(1); } else if backend_uri.starts_with("file") { let database = { let folder = backend_uri.strip_prefix("file://").unwrap(); let path = std::path::PathBuf::from(folder); match kittybox::database::FileStorage::new(path).await { Ok(db) => db, Err(err) => { error!("Error creating database: {:?}", err); std::process::exit(1); } } }; let blobstore = { let variable = std::env::var("BLOBSTORE_URI") .unwrap(); let folder = variable .strip_prefix("file://") .unwrap(); let path = std::path::PathBuf::from(folder); kittybox::media::storage::file::FileStore::new(path) }; let auth_backend = { let variable = std::env::var("AUTH_STORE_URI") .unwrap(); let folder = variable .strip_prefix("file://") .unwrap(); kittybox::indieauth::backend::fs::FileBackend::new(folder) }; // This code proves that different components of Kittybox can // be split up without hurting the app // // If needed, some features could be omitted from the binary // or just not spun up in the future // // For example, the frontend code could run spearately from // Micropub and only have read access to the database folder let frontend = axum::Router::new() .route( "/", axum::routing::get(kittybox::frontend::homepage::) .layer(axum::Extension(database.clone()))) .route("/.kittybox/static/:path", axum::routing::get(kittybox::frontend::statics)) .fallback( axum::routing::get(kittybox::frontend::catchall::) .layer(axum::Extension(database.clone()))); // Onboarding is a bit of a special case. One might argue that // the onboarding makes Kittybox a monolith. This is wrong. // The "onboarding receiver" doesn't need any code from the // onboarding form - they're grouped in a single module for // convenience only, since modifying one usually requires // updating the other to match. // // For example, this "router" just groups two separate methods // in one request, because logically they live in the same // subtree. But one could manually construct only one but not // the other, to receive a "frontend-only" application. Of // course, in this scenario, one must employ a reverse proxy // to distinguish between GET and POST requests to the same // path, and route them to the correct set of endpoints with // write access. let onboarding = axum::Router::new() .route("/.kittybox/onboarding", kittybox::frontend::onboarding::router( database.clone(), http.clone() )); let micropub = axum::Router::new() .route("/.kittybox/micropub", kittybox::micropub::router( database.clone(), http.clone(), auth_backend.clone() )) .nest("/.kittybox/micropub/client", kittybox::companion::router()); let media = axum::Router::new() .nest("/.kittybox/media", kittybox::media::router(blobstore, auth_backend.clone())); let indieauth = kittybox::indieauth::router(auth_backend, database.clone(), http.clone()); let technical = axum::Router::new() .route( "/.kittybox/coffee", axum::routing::get(|| async { use axum::http::{header, StatusCode}; ( StatusCode::IM_A_TEAPOT, [(header::CONTENT_TYPE, "text/plain")], "Sorry, can't brew coffee yet!", ) }), ) .route( "/.kittybox/health", axum::routing::get( |axum::Extension(db): axum::Extension| async move { // TODO health-check the database "OK" } ) .layer(axum::Extension(database)) ) .route( "/.kittybox/metrics", axum::routing::get(|| async { todo!() }), ); let svc = axum::Router::new() .merge(frontend) .merge(onboarding) .merge(micropub) .merge(media) .merge(indieauth) .merge(technical) .layer(tower::ServiceBuilder::new() .layer(tower_http::trace::TraceLayer::new_for_http()) .into_inner()) .layer(tower_http::catch_panic::CatchPanicLayer::new()); // A little dance to turn a potential file descriptor into // a guaranteed async network socket let tcp_listener: std::net::TcpListener = { let mut listenfd = listenfd::ListenFd::from_env(); let tcp_listener = if let Ok(Some(listener)) = listenfd.take_tcp_listener(0) { listener } else { std::net::TcpListener::bind(listen_at).unwrap() }; // Set the socket to non-blocking so tokio can poll it // properly -- this is the async magic! tcp_listener.set_nonblocking(true).unwrap(); tcp_listener }; info!("Listening on {}", tcp_listener.local_addr().unwrap()); let server = hyper::server::Server::from_tcp(tcp_listener) .unwrap() // Otherwise Chrome keeps connections open for too long .tcp_keepalive(Some(Duration::from_secs(30 * 60))) .serve(svc.into_make_service()) .with_graceful_shutdown(async move { // Defer to C-c handler whenever we're not on Unix // TODO consider using a diverging future here #[cfg(not(unix))] return tokio::signal::ctrl_c().await.unwrap(); #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; signal(SignalKind::terminate()) .unwrap() .recv() .await .unwrap() } }); if let Err(err) = server.await { error!("Error serving requests: {}", err); std::process::exit(1); } } else { println!("Unknown backend, not starting."); std::process::exit(1); } }