use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
};
use anyhow::Context;
use axum::Router;
use clap::Parser;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use url::Url;
use uuid::Uuid;
mod api;
mod assets;
mod config;
mod models;
use config::{DatabaseConfig, DatabasePool, PoolConfig};
#[derive(Parser, Debug, Clone)]
#[command(name = "sql-web")]
#[command(about = "A web-based database browser for SQLite, MySQL, and PostgreSQL")]
pub struct Args {
#[arg(value_name = "DATABASE", conflicts_with = "database_url")]
pub database: Option<String>,
#[arg(short = 'd', long, value_name = "DATABASE_URL")]
pub database_url: Option<String>,
#[arg(short = 'H', long, default_value = "127.0.0.1")]
pub host: String,
#[arg(short, long, default_value = "8080")]
pub port: u16,
#[arg(short, long)]
pub readonly: bool,
#[arg(short = 'R', long, default_value = "50")]
pub rows_per_page: usize,
#[arg(short = 'Q', long, default_value = "1000")]
pub query_rows_per_page: usize,
#[arg(long, default_value = "10")]
pub max_connections: u32,
#[arg(long, default_value = "1")]
pub min_connections: u32,
#[arg(long, default_value = "10")]
pub connect_timeout_seconds: u64,
#[arg(long)]
pub debug: bool,
}
#[derive(Clone)]
pub struct AppState {
pub args: Args,
pub db_config: DatabaseConfig,
pub pool: DatabasePool,
pub auth_token: String,
pub auth_password: String,
}
pub type SharedState = Arc<AppState>;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = Args::parse();
init_tracing(args.debug);
let database_url = resolve_database_url(&args)?;
let mut db_config = DatabaseConfig::from_url(&database_url)
.map_err(|error| anyhow::anyhow!("Invalid database URL: {error}"))?;
db_config.readonly = db_config.readonly || args.readonly;
let pool_config = PoolConfig {
max_connections: args.max_connections,
min_connections: args.min_connections,
connect_timeout_seconds: args.connect_timeout_seconds,
};
let pool = DatabasePool::connect(&db_config, &pool_config)
.await
.context("Failed to connect to database")?;
let (auth_password, generated_password) = runtime_password();
let addr: SocketAddr = format!("{}:{}", args.host, args.port)
.parse()
.context("Invalid bind address")?;
let state = Arc::new(AppState {
args,
db_config,
pool,
auth_token: Uuid::new_v4().to_string(),
auth_password: auth_password.clone(),
});
let app = Router::new()
.nest("/api", api::router(state.clone()))
.fallback(assets::serve)
.with_state(state.clone());
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("Failed to bind to {addr}"))?;
tracing::info!("sql-web listening on http://{addr}");
if generated_password {
tracing::info!("one-time login password: {auth_password}");
} else {
tracing::info!("using SQL_WEB_PASSWORD for login");
}
axum::serve(listener, app).await?;
Ok(())
}
fn runtime_password() -> (String, bool) {
match std::env::var("SQL_WEB_PASSWORD") {
Ok(password) => (password, false),
Err(_) => (Uuid::new_v4().simple().to_string(), true),
}
}
fn resolve_database_url(args: &Args) -> anyhow::Result<String> {
let input = args
.database_url
.as_deref()
.or(args.database.as_deref())
.ok_or_else(|| {
anyhow::anyhow!(
"missing database: use `sql-web ./database.db` or `sql-web --database-url <URL>`"
)
})?;
if has_supported_scheme(input) {
return Ok(input.to_string());
}
if is_sqlite_file_path(input) {
return sqlite_file_url(Path::new(input));
}
Err(anyhow::anyhow!(
"unsupported database input `{input}`: pass a mysql/postgres/sqlite URL, or a SQLite file ending in .db, .sqlite, .sqlite3, or .db3"
))
}
fn has_supported_scheme(input: &str) -> bool {
Url::parse(input)
.map(|url| matches!(url.scheme(), "sqlite" | "mysql" | "postgres" | "postgresql"))
.unwrap_or(false)
}
fn is_sqlite_file_path(input: &str) -> bool {
Path::new(input)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| {
matches!(
extension.to_ascii_lowercase().as_str(),
"db" | "sqlite" | "sqlite3" | "db3"
)
})
.unwrap_or(false)
}
fn sqlite_file_url(path: &Path) -> anyhow::Result<String> {
let absolute_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
};
let file_url = Url::from_file_path(normalize_path(&absolute_path))
.map_err(|_| anyhow::anyhow!("invalid SQLite database path: {}", path.display()))?
.to_string();
Ok(file_url.replacen("file:", "sqlite:", 1))
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
component => normalized.push(component.as_os_str()),
}
}
normalized
}
fn init_tracing(debug: bool) {
let default_filter = if debug { "sql_web=debug,info" } else { "info" };
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| default_filter.into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
}