diff --git a/crates/tranquil-api/Cargo.toml b/crates/tranquil-api/Cargo.toml index 1dfc6bf..6d9d513 100644 --- a/crates/tranquil-api/Cargo.toml +++ b/crates/tranquil-api/Cargo.toml @@ -12,12 +12,15 @@ tranquil-db = { workspace = true } tranquil-db-traits = { workspace = true } tranquil-lexicon = { workspace = true, features = ["resolve"] } tranquil-scopes = { workspace = true } +tranquil-signal = { workspace = true } anyhow = { workspace = true } axum = { workspace = true } backon = { workspace = true } base32 = { workspace = true } base64 = { workspace = true } +image = { workspace = true } +qrcodegen = { workspace = true } bcrypt = { workspace = true } bs58 = { workspace = true } bytes = { workspace = true } diff --git a/crates/tranquil-api/src/admin/mod.rs b/crates/tranquil-api/src/admin/mod.rs index b1998ce..c6f72b9 100644 --- a/crates/tranquil-api/src/admin/mod.rs +++ b/crates/tranquil-api/src/admin/mod.rs @@ -2,6 +2,7 @@ pub mod account; pub mod config; pub mod invite; pub mod server_stats; +pub mod signal; pub mod status; pub use account::{ @@ -13,4 +14,5 @@ pub use invite::{ disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes, }; pub use server_stats::get_server_stats; +pub use signal::{get_signal_status, link_signal_device, unlink_signal_device}; pub use status::{get_subject_status, update_subject_status}; diff --git a/crates/tranquil-api/src/admin/signal.rs b/crates/tranquil-api/src/admin/signal.rs new file mode 100644 index 0000000..50614d0 --- /dev/null +++ b/crates/tranquil-api/src/admin/signal.rs @@ -0,0 +1,163 @@ +use axum::{Json, extract::State}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use image::{ImageBuffer, Luma}; +use serde::Serialize; +use tranquil_pds::api::error::ApiError; +use tranquil_pds::auth::{Admin, Auth}; +use tranquil_pds::state::AppState; +use tranquil_signal::PgSignalStore; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignalStatusOutput { + pub enabled: bool, + pub linked: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SignalLinkOutput { + pub qr_base64: String, +} + +pub async fn get_signal_status( + State(state): State, + _auth: Auth, +) -> Result, ApiError> { + let enabled = tranquil_config::get().signal.enabled; + let linked = match &state.signal_sender { + Some(slot) => slot.is_linked().await, + None => false, + }; + + Ok(Json(SignalStatusOutput { enabled, linked })) +} + +pub async fn link_signal_device( + State(state): State, + _auth: Auth, +) -> Result, ApiError> { + let slot = state + .signal_sender + .as_ref() + .ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?; + + if slot.is_linked().await { + return Err(ApiError::InvalidRequest( + "Signal device already linked".into(), + )); + } + + let (generation, link_cancel) = slot.begin_link().await; + + let device_name = tranquil_signal::DeviceName::new("tranquil-pds".to_string()) + .map_err(|e| ApiError::InternalError(Some(format!("invalid device name: {e}"))))?; + + let link_result = tranquil_signal::SignalClient::link_device( + &state.repos.pool, + device_name, + state.shutdown.clone(), + link_cancel, + slot.linking_flag(), + ) + .await + .map_err(|e| ApiError::InternalError(Some(format!("Signal linking failed: {e}"))))?; + + let qr_base64 = url_to_qr_png_base64(link_result.url.as_str()) + .map_err(|e| ApiError::InternalError(Some(format!("QR generation failed: {e}"))))?; + + let slot_for_task = slot.clone(); + let shutdown = state.shutdown.clone(); + tokio::spawn(async move { + let result = tokio::select! { + biased; + _ = shutdown.cancelled() => { + tracing::info!("Signal linking aborted due to server shutdown"); + return; + } + r = link_result.completion => r, + }; + match result { + Ok(Ok(client)) => { + if slot_for_task.complete_link(generation, client).await { + tracing::info!("Signal device linked successfully"); + } else { + tracing::warn!( + "Signal link completed but generation mismatch or already linked; discarding" + ); + } + } + Ok(Err(e)) => { + tracing::error!(error = %e, "Signal device linking failed"); + } + Err(_) => { + tracing::error!("Signal linking task dropped without completing"); + } + } + }); + + Ok(Json(SignalLinkOutput { qr_base64 })) +} + +pub async fn unlink_signal_device( + State(state): State, + _auth: Auth, +) -> Result, ApiError> { + let slot = state + .signal_sender + .as_ref() + .ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?; + + let store = PgSignalStore::new(state.repos.pool.clone()); + store + .clear_all() + .await + .map_err(|e| ApiError::InternalError(Some(format!("Failed to clear signal data: {e}"))))?; + + slot.unlink().await; + + Ok(Json(serde_json::json!({}))) +} + +const QR_MODULE_SCALE: u32 = 8; +const QR_QUIET_ZONE_MODULES: u32 = 4; + +fn url_to_qr_png_base64(url: &str) -> Result { + let qr = qrcodegen::QrCode::encode_text(url, qrcodegen::QrCodeEcc::Medium) + .map_err(|e| format!("QR encode failed: {e:?}"))?; + let size = u32::try_from(qr.size()).map_err(|_| "QR size is negative".to_string())?; + let img_size = size + .checked_add( + QR_QUIET_ZONE_MODULES + .checked_mul(2) + .ok_or("border overflow")?, + ) + .ok_or("image size overflow")? + .checked_mul(QR_MODULE_SCALE) + .ok_or("scaled size overflow")?; + + let img: ImageBuffer, Vec> = ImageBuffer::from_fn(img_size, img_size, |x, y| { + let module_x = x / QR_MODULE_SCALE; + let module_y = y / QR_MODULE_SCALE; + match ( + module_x.checked_sub(QR_QUIET_ZONE_MODULES), + module_y.checked_sub(QR_QUIET_ZONE_MODULES), + ) { + (Some(mx), Some(my)) if mx < size && my < size => { + if qr.get_module(mx as i32, my as i32) { + Luma([0u8]) + } else { + Luma([255u8]) + } + } + _ => Luma([255u8]), + } + }); + + let mut png_bytes = Vec::new(); + let mut cursor = std::io::Cursor::new(&mut png_bytes); + img.write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| format!("PNG encode failed: {e}"))?; + + Ok(STANDARD.encode(&png_bytes)) +} diff --git a/crates/tranquil-api/src/lib.rs b/crates/tranquil-api/src/lib.rs index 8dd2bf5..933bd74 100644 --- a/crates/tranquil-api/src/lib.rs +++ b/crates/tranquil-api/src/lib.rs @@ -330,6 +330,12 @@ pub fn api_routes() -> axum::Router { get(admin::get_invite_codes), ) .route("/_admin.getServerStats", get(admin::get_server_stats)) + .route("/_admin.getSignalStatus", get(admin::get_signal_status)) + .route("/_admin.linkSignalDevice", post(admin::link_signal_device)) + .route( + "/_admin.unlinkSignalDevice", + post(admin::unlink_signal_device), + ) .route("/_server.getConfig", get(admin::get_server_config)) .route( "/_admin.updateServerConfig", diff --git a/crates/tranquil-api/src/server/meta.rs b/crates/tranquil-api/src/server/meta.rs index 03bd286..5067c9f 100644 --- a/crates/tranquil-api/src/server/meta.rs +++ b/crates/tranquil-api/src/server/meta.rs @@ -14,7 +14,7 @@ fn get_available_comms_channels() -> Vec { if cfg.telegram.bot_token.is_some() { channels.push(CommsChannel::Telegram); } - if cfg.signal.sender_number.is_some() { + if cfg.signal.enabled { channels.push(CommsChannel::Signal); } channels diff --git a/crates/tranquil-config/src/lib.rs b/crates/tranquil-config/src/lib.rs index d8ddade..b4c1656 100644 --- a/crates/tranquil-config/src/lib.rs +++ b/crates/tranquil-config/src/lib.rs @@ -678,13 +678,8 @@ pub struct TelegramConfig { #[derive(Debug, Config)] pub struct SignalConfig { - /// Path to the `signal-cli` binary. - #[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")] - pub cli_path: String, - - /// Sender phone number. When unset, Signal integration is disabled. - #[config(env = "SIGNAL_SENDER_NUMBER")] - pub sender_number: Option, + #[config(env = "SIGNAL_ENABLED", default = false)] + pub enabled: bool, } #[derive(Debug, Config)] diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index cc04f20..0be34a8 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -15,6 +15,7 @@ tranquil-scopes = { workspace = true } tranquil-auth = { workspace = true } tranquil-oauth = { workspace = true } tranquil-comms = { workspace = true } +tranquil-signal = { workspace = true } tranquil-db = { workspace = true } tranquil-db-traits = { workspace = true } tranquil-lexicon = { workspace = true, features = ["resolve"] } diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index ace7940..efb085f 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -60,6 +60,7 @@ pub struct AppState { pub cross_pds_oauth: Arc, pub shutdown: CancellationToken, pub bootstrap_invite_code: Option, + pub signal_sender: Option>, } #[derive(Debug, Clone, Copy)] @@ -310,6 +311,7 @@ impl AppState { webauthn_config, shutdown, bootstrap_invite_code: None, + signal_sender: None, } } @@ -328,6 +330,11 @@ impl AppState { self } + pub fn with_signal_sender(mut self, slot: Arc) -> Self { + self.signal_sender = Some(slot); + self + } + pub fn with_circuit_breakers(mut self, circuit_breakers: CircuitBreakers) -> Self { self.circuit_breakers = Arc::new(circuit_breakers); self diff --git a/crates/tranquil-server/Cargo.toml b/crates/tranquil-server/Cargo.toml index fab4f79..4db89a5 100644 --- a/crates/tranquil-server/Cargo.toml +++ b/crates/tranquil-server/Cargo.toml @@ -10,6 +10,7 @@ tranquil-sync = { workspace = true } tranquil-api = { workspace = true } tranquil-oauth-server = { workspace = true } tranquil-config = { workspace = true } +tranquil-signal = { workspace = true } axum = { workspace = true } clap = { workspace = true } diff --git a/crates/tranquil-server/src/main.rs b/crates/tranquil-server/src/main.rs index b2fc5ea..03250a7 100644 --- a/crates/tranquil-server/src/main.rs +++ b/crates/tranquil-server/src/main.rs @@ -109,7 +109,22 @@ async fn run() -> Result<(), Box> { spawn_signal_handler(shutdown.clone()); - let state = AppState::new(shutdown.clone()).await?; + let mut state = AppState::new(shutdown.clone()).await?; + + let signal_sender = if tranquil_config::get().signal.enabled { + let slot = Arc::new(tranquil_signal::SignalSlot::default()); + state = state.with_signal_sender(slot.clone()); + if let Some(client) = + tranquil_signal::SignalClient::from_pool(&state.repos.pool, shutdown.clone()).await + { + slot.set_client(client).await; + info!("Signal device already linked"); + } + Some(SignalSender::new(slot)) + } else { + None + }; + tranquil_sync::listener::start_sequencer_listener(state.clone()).await; let backfill_repo_repo = state.repo_repo.clone(); @@ -210,9 +225,9 @@ async fn run() -> Result<(), Box> { comms_service = comms_service.register_sender(telegram_sender); } - if let Some(signal_sender) = SignalSender::from_config(cfg) { + if let Some(sender) = signal_sender { info!("Signal comms enabled"); - comms_service = comms_service.register_sender(signal_sender); + comms_service = comms_service.register_sender(sender); } let comms_handle = tokio::spawn(comms_service.run(shutdown.clone())); diff --git a/example.toml b/example.toml index 9101427..776ae6e 100644 --- a/example.toml +++ b/example.toml @@ -320,17 +320,11 @@ #webhook_secret = [signal] -# Path to the `signal-cli` binary. +# Protocol state is stored in postgres' signal_* tables. +# Link a device via the admin API before enabling. # -# Can also be specified via environment variable `SIGNAL_CLI_PATH`. -# -# Default value: "/usr/local/bin/signal-cli" -#cli_path = "/usr/local/bin/signal-cli" - -# Sender phone number. When unset, Signal integration is disabled. -# -# Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`. -#sender_number = +# Can also be specified via environment variable `SIGNAL_ENABLED`. +#enabled = false [notifications] # Polling interval in milliseconds for the comms queue.