pub mod billing;
pub mod db;
pub mod routes;
use anyhow::Result;
use axum::{
extract::{FromRequestParts, Request},
http::{header, request::Parts, Method, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use db::CloudDb;
use std::net::SocketAddr;
use std::path::PathBuf;
use tower_http::cors::{Any, CorsLayer};
#[derive(Clone)]
pub struct AppState {
pub db: CloudDb,
pub jwt_secret: String,
pub base_url: String,
}
pub struct AppError(pub anyhow::Error);
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError(e)
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({"error": self.0.to_string()}).to_string(),
)
.into_response()
}
}
pub struct AuthUser(pub String);
#[axum::async_trait]
impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
type Rejection = (StatusCode, String);
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
let auth = parts
.headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(String::from)
.ok_or((StatusCode::UNAUTHORIZED, "missing token".to_string()))?;
let secret = parts
.extensions
.get::<JwtSecret>()
.map(|s| s.0.as_str())
.ok_or((
StatusCode::INTERNAL_SERVER_ERROR,
"server misconfigured".to_string(),
))?;
let user_id = routes::auth::verify_jwt(&auth, secret)
.ok_or((StatusCode::UNAUTHORIZED, "invalid token".to_string()))?;
Ok(AuthUser(user_id))
}
}
#[derive(Clone)]
struct JwtSecret(String);
async fn inject_jwt_secret(
axum::extract::State(state): axum::extract::State<AppState>,
mut req: Request,
next: Next,
) -> Response {
req.extensions_mut()
.insert(JwtSecret(state.jwt_secret.clone()));
next.run(req).await
}
pub const MIN_JWT_SECRET_LEN: usize = 32;
pub fn choose_jwt_secret(configured: Option<String>, dev: bool) -> anyhow::Result<String> {
match configured {
Some(s) if s.len() >= MIN_JWT_SECRET_LEN => Ok(s),
Some(_) => {
anyhow::bail!("BCTX_JWT_SECRET must be at least {MIN_JWT_SECRET_LEN} characters")
}
None if dev => Ok(format!(
"dev-{}-{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)),
None => anyhow::bail!(
"BCTX_JWT_SECRET is not set; refusing to start. For local development set BCTX_DEV=1."
),
}
}
pub struct CloudServer {
pub bind: SocketAddr,
pub db_path: Option<PathBuf>,
pub jwt_secret: String,
pub base_url: String,
}
impl CloudServer {
pub fn new(bind: SocketAddr) -> anyhow::Result<Self> {
let jwt_secret = choose_jwt_secret(
std::env::var("BCTX_JWT_SECRET").ok(),
std::env::var("BCTX_DEV").as_deref() == Ok("1"),
)?;
let base_url = std::env::var("BCTX_BASE_URL").unwrap_or_else(|_| format!("http://{bind}"));
Ok(Self {
bind,
db_path: None,
jwt_secret,
base_url,
})
}
pub fn with_db_path(mut self, path: PathBuf) -> Self {
self.db_path = Some(path);
self
}
pub async fn run(self) -> Result<()> {
let db = match self.db_path {
Some(ref p) => {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent)?;
}
CloudDb::open(p)?
}
None => CloudDb::open_in_memory()?,
};
let state = AppState {
db,
jwt_secret: self.jwt_secret,
base_url: self.base_url,
};
let app = Router::new()
.route("/", get(serve_dashboard))
.route("/dashboard.html", get(serve_dashboard))
.route("/auth/device", post(routes::auth::device_start))
.route("/auth/token", post(routes::auth::token_poll))
.route(
"/auth/activate",
get(routes::auth::activate_page).post(routes::auth::activate),
)
.route("/auth/web", post(routes::auth::web_login))
.route("/auth/web-code", post(routes::auth::web_code))
.route(
"/auth/web-code/exchange",
post(routes::auth::web_code_exchange),
)
.route("/sync/vault", post(routes::sync::push_vault))
.route("/sync/vault/:project_hash", get(routes::sync::pull_vault))
.route("/sync/signals", post(routes::sync::push_signals))
.route("/sync/usage", post(routes::sync::push_usage))
.route("/dashboard/summary", get(routes::dashboard::summary))
.route("/dashboard/gauge", get(routes::dashboard::gauge))
.route("/dashboard/history", get(routes::dashboard::history))
.route("/dashboard/commands", get(routes::dashboard::commands))
.route("/admin/stats", get(routes::admin::stats))
.route("/admin/conversion", get(routes::admin::conversion))
.route("/admin/export", get(routes::admin::export))
.route("/admin/export.csv", get(routes::admin::export_csv))
.route("/admin/promote", post(routes::admin::promote))
.route("/admin/demote", post(routes::admin::demote))
.route("/account/me", get(routes::account::me))
.route("/team", post(routes::team::create_team))
.route("/team/:team_id", get(routes::team::get_team))
.route("/team/:team_id/invite", post(routes::team::invite_member))
.route("/team/:team_id/vault", post(routes::team::push_team_vault))
.route(
"/team/:team_id/vault/:project_hash",
get(routes::team::pull_team_vault),
)
.route("/billing/webhook", post(billing::webhook::handle))
.route("/billing/checkout", post(routes::billing::create_checkout))
.route("/billing/portal", post(routes::billing::create_portal))
.route("/billing/status", get(routes::billing::billing_status))
.route("/billing/resume", post(routes::billing::billing_resume))
.route("/billing/success", get(routes::billing::checkout_success))
.route("/billing/cancel", get(routes::billing::checkout_cancel))
.route("/compress", post(routes::compress::compress))
.route("/health", get(health))
.layer(middleware::from_fn_with_state(
state.clone(),
inject_jwt_secret,
))
.layer(
CorsLayer::new()
.allow_origin(Any)
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
.allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]),
)
.with_state(state);
let listener = tokio::net::TcpListener::bind(self.bind).await?;
tracing::info!("bctx-cloud listening on {}", self.bind);
axum::serve(listener, app).await?;
Ok(())
}
}
async fn health() -> impl IntoResponse {
serde_json::json!({"status": "ok", "version": env!("CARGO_PKG_VERSION")}).to_string()
}
static DASHBOARD_HTML: &str = include_str!("dashboard.html");
async fn serve_dashboard() -> impl IntoResponse {
axum::response::Response::builder()
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
.body(axum::body::Body::from(DASHBOARD_HTML))
.unwrap()
}
#[cfg(test)]
mod secret_tests {
use super::*;
#[test]
fn missing_secret_is_refused_outside_dev() {
assert!(choose_jwt_secret(None, false).is_err());
}
#[test]
fn short_secret_is_refused() {
assert!(choose_jwt_secret(Some("short".into()), false).is_err());
}
#[test]
fn strong_secret_is_used_as_is() {
let s = "a".repeat(MIN_JWT_SECRET_LEN);
assert_eq!(choose_jwt_secret(Some(s.clone()), false).unwrap(), s);
}
#[test]
fn dev_mode_gets_a_fresh_random_secret() {
let a = choose_jwt_secret(None, true).unwrap();
let b = choose_jwt_secret(None, true).unwrap();
assert_ne!(a, b);
assert!(a.len() >= MIN_JWT_SECRET_LEN);
}
#[test]
fn no_default_secret_literal_remains_in_source() {
let src = include_str!("mod.rs");
assert!(!src.contains(concat!("dev-sec", "ret-change")));
assert!(!src.contains(concat!("\"dev", "-secret\"")));
}
}
#[cfg(test)]
mod dashboard_html_tests {
use super::DASHBOARD_HTML;
fn inner_html_interpolations() -> Vec<(usize, String)> {
let mut found = Vec::new();
let mut rest = DASHBOARD_HTML;
while let Some(at) = rest.find(".innerHTML =") {
let line = DASHBOARD_HTML[..DASHBOARD_HTML.len() - rest.len() + at]
.lines()
.count();
rest = &rest[at..];
let end = rest.find(";\n").unwrap_or(rest.len());
let stmt = &rest[..end];
let mut s = stmt;
while let Some(open) = s.find("${") {
let mut depth = 0;
let mut close = open + 2;
for (i, c) in s[open + 2..].char_indices() {
match c {
'{' => depth += 1,
'}' if depth == 0 => {
close = open + 2 + i;
break;
}
'}' => depth -= 1,
_ => {}
}
}
found.push((line, s[open + 2..close].trim().to_string()));
s = &s[close..];
}
rest = &rest[end..];
}
found
}
#[test]
fn inner_html_never_interpolates_unescaped_text() {
let numeric =
|e: &str| e.starts_with("fmt(") || e.ends_with(".toFixed(0)") || e == "s.run_count";
let constant_choice = |e: &str| {
e.contains(" ? '") && e.contains(" : '") && e.ends_with('\'') && !e.contains('+')
};
let bad: Vec<_> = inner_html_interpolations()
.into_iter()
.filter(|(_, e)| !(e.starts_with("esc(") || numeric(e) || constant_choice(e)))
.collect();
assert!(bad.is_empty(), "wrap these in esc(): {bad:?}");
}
#[test]
fn the_scan_sees_the_command_table() {
assert!(inner_html_interpolations()
.iter()
.any(|(_, e)| e.contains("s.program")));
}
}