rustpbx 0.4.9

A SIP PBX implementation in Rust
Documentation
use crate::console::{ConsoleState, ReloadTarget};
use axum::{Json, Router, extract::State, response::IntoResponse, routing::get};
use http::StatusCode;
use serde_json::json;
use std::sync::Arc;

pub mod addons;
pub mod call_control;
pub mod call_record;
pub mod dashboard;
pub mod diagnostics;
pub mod extension;
pub mod forms;
pub mod licenses;
pub mod locales;
pub mod metrics;
pub mod notifications;
pub mod presence;
pub mod routing;
pub mod setting;
pub mod sip_trunk;
pub mod sipflow;
pub mod user;
pub mod utils;

pub fn bad_request(message: impl Into<String>) -> axum::response::Response {
    crate::console::config_helpers::json_error(StatusCode::BAD_REQUEST, message)
}

#[allow(clippy::result_large_err)]
pub fn require_field(
    value: &Option<String>,
    field: &str,
) -> Result<String, axum::response::Response> {
    normalize_optional_string(value).ok_or_else(|| bad_request(format!("{} is required", field)))
}

pub fn normalize_optional_string(value: &Option<String>) -> Option<String> {
    value
        .as_ref()
        .map(|v| v.trim())
        .filter(|v| !v.is_empty())
        .map(|v| v.to_string())
}

pub fn sanitize_optional_string(value: Option<String>) -> Option<String> {
    normalize_optional_string(&value)
}

pub fn router(state: Arc<ConsoleState>) -> Router {
    let base_path = state.base_path().to_string();
    let api_prefix = state.api_prefix().to_string();

    // Page routes (nested under base_path)
    let page_routes = Router::new()
        .merge(user::urls())
        .merge(extension::urls())
        .merge(sip_trunk::urls())
        .merge(setting::urls())
        .merge(routing::urls())
        .merge(call_record::urls())
        .merge(diagnostics::urls())
        .merge(call_control::urls())
        .merge(addons::urls())
        .merge(licenses::urls())
        .merge(sipflow::urls())
        .merge(notifications::urls())
        .merge(metrics::urls());
    #[cfg(feature = "addon-cc")]
    let page_routes = page_routes.merge(crate::addons::cc::console_handlers::page_urls());

    // API routes (nested under api_prefix)
    let api_routes = Router::new()
        .route("/pending-reloads", get(pending_reloads_handler))
        .merge(locales::api_urls())
        .merge(presence::api_urls())
        .merge(notifications::api_urls())
        .merge(metrics::api_urls())
        .merge(addons::api_urls());
    #[cfg(feature = "addon-cc")]
    let api_routes = {
        let cc_api_routes = crate::addons::cc::console_handlers::api_urls();
        let cc_api_routes = if let Some(app_state) = state.app_state() {
            if let Some(cc_state) = app_state.get_addon_state::<crate::addons::cc::CcAddonState>() {
                let auth_state = crate::addons::cc::phone_auth::PhoneAuthState {
                    phone_auth: cc_state.phone_auth.clone(),
                    console_state: Some(state.clone()),
                };
                cc_api_routes.layer(axum::middleware::from_fn_with_state(
                    auth_state,
                    crate::addons::cc::phone_auth::phone_auth_middleware,
                ))
            } else {
                cc_api_routes
            }
        } else {
            cc_api_routes
        };
        api_routes.merge(cc_api_routes)
    };

    Router::new()
        .route(&format!("{base_path}/"), get(self::dashboard::dashboard))
        .route(
            &format!("{base_path}/dashboard/data"),
            get(self::dashboard::dashboard_data),
        )
        .nest(&base_path, page_routes)
        .nest(&api_prefix, api_routes)
        .with_state(state)
}

async fn pending_reloads_handler(State(state): State<Arc<ConsoleState>>) -> impl IntoResponse {
    let targets = state.pending_reload_targets();
    Json(json!({
        "pending": {
            "routes": targets.contains(&ReloadTarget::Routes),
            "trunks": targets.contains(&ReloadTarget::Trunks),
            "sbc_routes": targets.contains(&ReloadTarget::SbcRoutes),
            "sbc_trunks": targets.contains(&ReloadTarget::SbcTrunks),
            "queues": targets.contains(&ReloadTarget::Queues),
            "app": targets.contains(&ReloadTarget::App),
            "acl": targets.contains(&ReloadTarget::Acl),
        }
    }))
}

#[cfg(test)]
pub mod test_helpers {
    use crate::{config::ConsoleConfig, console::ConsoleState, models::migration::Migrator};
    use sea_orm::Database;
    use sea_orm_migration::MigratorTrait;
    use std::sync::Arc;

    pub async fn setup_state() -> Arc<ConsoleState> {
        let db = Database::connect("sqlite::memory:")
            .await
            .expect("connect sqlite memory");
        Migrator::up(&db, None).await.expect("run migrations");
        ConsoleState::initialize(db, ConsoleConfig::default())
            .await
            .expect("initialize console state")
    }

    pub fn superuser() -> crate::models::user::Model {
        let now = chrono::Utc::now();
        crate::models::user::Model {
            id: 1,
            email: "admin@rustpbx.com".into(),
            username: "admin".into(),
            password_hash: "hashed".into(),
            reset_token: None,
            reset_token_expires: None,
            last_login_at: None,
            last_login_ip: None,
            created_at: now,
            updated_at: now,
            is_active: true,
            is_staff: true,
            is_superuser: true,
            mfa_enabled: false,
            mfa_secret: None,
            auth_source: "local".into(),
        }
    }

    pub fn unprivileged_user() -> crate::models::user::Model {
        let now = chrono::Utc::now();
        crate::models::user::Model {
            id: 2,
            email: "user@rustpbx.com".into(),
            username: "user".into(),
            password_hash: "hashed".into(),
            reset_token: None,
            reset_token_expires: None,
            last_login_at: None,
            last_login_ip: None,
            created_at: now,
            updated_at: now,
            is_active: true,
            is_staff: false,
            is_superuser: false,
            mfa_enabled: false,
            mfa_secret: None,
            auth_source: "local".into(),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::test_helpers::setup_state;
    use super::*;
    use axum::{body::to_bytes, extract::State, http::StatusCode};

    #[tokio::test]
    async fn pending_reloads_handler_false_initially() {
        let state = setup_state().await;
        let response = pending_reloads_handler(State(state)).await.into_response();
        assert_eq!(response.status(), StatusCode::OK);
        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(v["pending"]["routes"], false);
        assert_eq!(v["pending"]["trunks"], false);
    }

    #[tokio::test]
    async fn pending_reloads_handler_true_after_mark() {
        let state = setup_state().await;
        state.mark_pending_reload(ReloadTarget::Routes);

        let response = pending_reloads_handler(State(state)).await.into_response();
        let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
        let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
        assert_eq!(v["pending"]["routes"], true);
        assert_eq!(v["pending"]["trunks"], false);
    }
}