Mercury/src/main.rs
Matthew L McPeak 1b2d1e7471
Some checks failed
ci / test (push) Failing after 11s
ci / build-ui (push) Failing after 6s
ci / publish (push) Has been skipped
fix: ci
2026-06-17 15:48:30 -04:00

170 lines
5.1 KiB
Rust

mod auth;
mod cache;
mod config;
mod db;
mod models;
mod routes;
mod state;
use std::io::{self, Write};
use std::sync::Arc;
use axum::{
extract::DefaultBodyLimit,
http::{header, HeaderValue, Method},
middleware,
routing::{get, post},
Router,
};
use tower_http::{
cors::CorsLayer,
services::{ServeDir, ServeFile},
};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use crate::{
auth::middleware::{blacklist_layer, require_auth},
cache::spawn_sweep_task,
config::Config,
db::create_pool,
models::blacklist::BlacklistEntry,
routes::{admin::admin_router, auth::login, crud::handle_crud},
state::{AppState, BlacklistCache, QueryCache},
};
fn build_cors(origins: &[String]) -> CorsLayer {
if origins.is_empty() {
return CorsLayer::new();
}
if origins.iter().any(|o| o == "*") {
return CorsLayer::permissive();
}
let parsed: Vec<HeaderValue> = origins.iter().filter_map(|o| o.parse().ok()).collect();
CorsLayer::new()
.allow_origin(parsed)
.allow_methods([
Method::GET,
Method::POST,
Method::PUT,
Method::DELETE,
Method::OPTIONS,
])
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "mercury=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
let config = Arc::new(Config::from_env()?);
let pool = create_pool(&config.database_url).await?;
// If no users exist, prompt to create the first admin interactively.
let user_count: i64 = sqlx::query_scalar::<_, Option<i64>>("SELECT COUNT(*) FROM users")
.fetch_one(&pool)
.await?
.unwrap_or(0);
if user_count == 0 {
println!("\nNo users found. Create the first admin account.");
print!("Username: ");
io::stdout().flush()?;
let mut username = String::new();
io::stdin().read_line(&mut username)?;
let username = username.trim().to_string();
if username.is_empty() {
anyhow::bail!("username cannot be empty");
}
print!("Password: ");
io::stdout().flush()?;
let mut password = String::new();
io::stdin().read_line(&mut password)?;
let password = password.trim().to_string();
if password.is_empty() {
anyhow::bail!("password cannot be empty");
}
let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?;
sqlx::query(
"INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, '63')",
)
.bind(&username)
.bind(hash)
.execute(&pool)
.await?;
tracing::info!("created first admin user: {}", username);
println!("Admin user '{}' created. Starting server...\n", username);
}
let query_cache = QueryCache::new();
let blacklist_cache = BlacklistCache::new();
// Load blacklist from DB into memory
let entries = sqlx::query_as::<_, BlacklistEntry>(
"SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id"
)
.fetch_all(&pool)
.await?;
blacklist_cache.load(entries).await;
// Start cache sweep
spawn_sweep_task(
query_cache.clone(),
config.cache_idle_timeout_secs,
config.cache_sweep_interval_secs,
);
let state = AppState {
pool,
query_cache,
blacklist_cache,
config: config.clone(),
};
let crud_routes = Router::new()
.route("/api/:table", get(handle_crud).post(handle_crud))
.route("/api/:table/", get(handle_crud).post(handle_crud))
.route(
"/api/:table/:id",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.route(
"/api/:table/:id/",
get(handle_crud).put(handle_crud).delete(handle_crud),
)
.layer(DefaultBodyLimit::max(1024 * 1024))
// require_auth is inner (added first); blacklist_layer is outer (added last, runs first).
// Order: blacklist check → auth check → handler.
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
.route_layer(middleware::from_fn_with_state(
state.clone(),
blacklist_layer,
));
let cors_layer = build_cors(&config.cors_origins);
let app = Router::new()
.route("/auth/login", post(login))
.merge(crud_routes)
.nest("/api/admin", admin_router(state.clone()))
.nest_service(
"/",
ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")),
)
.layer(cors_layer)
.with_state(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000));
tracing::info!("listening on {}", addr);
let listener = tokio::net::TcpListener::bind(addr).await?;
axum::serve(listener, app).await?;
Ok(())
}