pub mod billing;
pub mod db;
pub mod routes;
use anyhow::Result;
use axum::{
extract::{FromRequestParts, Request},
http::{request::Parts, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
Router,
};
use db::CloudDb;
use std::net::SocketAddr;
use std::path::PathBuf;
#[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())
.unwrap_or("dev-secret");
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 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) -> Self {
let jwt_secret = std::env::var("BCTX_JWT_SECRET")
.unwrap_or_else(|_| "dev-secret-change-in-prod".to_string());
let base_url = std::env::var("BCTX_BASE_URL").unwrap_or_else(|_| format!("http://{bind}"));
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("/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("/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("/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/success", get(routes::billing::checkout_success))
.route("/billing/cancel", get(routes::billing::checkout_cancel))
.route("/health", get(health))
.layer(middleware::from_fn_with_state(
state.clone(),
inject_jwt_secret,
))
.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()
}