mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-09-03 08:46:55 +00:00
feat(signal): add admin endpoints, config, and server wiring
This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Json<SignalStatusOutput>, 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<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Json<SignalLinkOutput>, 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<AppState>,
|
||||
_auth: Auth<Admin>,
|
||||
) -> Result<Json<serde_json::Value>, 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<String, String> {
|
||||
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<Luma<u8>, Vec<u8>> = 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))
|
||||
}
|
||||
@@ -330,6 +330,12 @@ pub fn api_routes() -> axum::Router<AppState> {
|
||||
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",
|
||||
|
||||
@@ -14,7 +14,7 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
|
||||
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
|
||||
|
||||
@@ -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<String>,
|
||||
#[config(env = "SIGNAL_ENABLED", default = false)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Config)]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -60,6 +60,7 @@ pub struct AppState {
|
||||
pub cross_pds_oauth: Arc<CrossPdsOAuthClient>,
|
||||
pub shutdown: CancellationToken,
|
||||
pub bootstrap_invite_code: Option<String>,
|
||||
pub signal_sender: Option<Arc<tranquil_signal::SignalSlot>>,
|
||||
}
|
||||
|
||||
#[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<tranquil_signal::SignalSlot>) -> 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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -109,7 +109,22 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
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<dyn std::error::Error>> {
|
||||
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()));
|
||||
|
||||
+4
-10
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user