The smokesignal.events web application

feature: Initial webhooks implementation

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

+1217 -19
+10
migrations/20250720112318_identity_webhooks.sql
··· 1 + -- Add table for identity webhook endpoints 2 + CREATE TABLE identity_webhooks ( 3 + did TEXT NOT NULL, 4 + service TEXT NOT NULL, 5 + created_at TIMESTAMP WITH TIME ZONE NOT NULL, 6 + enabled BOOLEAN NOT NULL DEFAULT true, 7 + errored_at TIMESTAMP WITH TIME ZONE DEFAULT NULL, 8 + error TEXT DEFAULT NULL, 9 + PRIMARY KEY (did, service) 10 + );
+49 -2
src/bin/smokesignal.rs
··· 1 1 use anyhow::Result; 2 - use atproto_identity::key::identify_key; 2 + use atproto_identity::key::{identify_key, to_public}; 3 3 use atproto_identity::resolve::{IdentityResolver, InnerIdentityResolver, create_resolver}; 4 4 use atproto_jetstream::{CancellationToken, Consumer as JetstreamConsumer, ConsumerTaskConfig}; 5 5 use atproto_oauth_axum::state::OAuthClientConfig; 6 6 use smokesignal::atproto::lexicon::community::lexicon::calendar::event::NSID; 7 7 use smokesignal::consumer::Consumer; 8 8 use smokesignal::processor::ContentFetcher; 9 + use smokesignal::service::{ServiceDID, ServiceKey, build_service_document}; 9 10 use smokesignal::storage::content::{CachedContentStorage, ContentStorage, FilesystemStorage}; 10 11 use smokesignal::{ 11 12 http::{ ··· 33 34 use std::{collections::HashMap, env, str::FromStr, sync::Arc}; 34 35 use tokio::net::TcpListener; 35 36 use tokio::signal; 37 + use tokio::sync::mpsc; 36 38 use tokio_util::task::TaskTracker; 37 39 use tracing_subscriber::prelude::*; 38 40 use unic_langid::LanguageIdentifier; ··· 100 102 101 103 // Initialize the DNS resolver with configuration from the app config 102 104 let dns_resolver = create_resolver(config.dns_nameservers.as_ref()); 105 + 106 + let service_did = ServiceDID(format!("did:web:{}", &config.external_base)); 107 + 108 + let service_key = ServiceKey(config.service_key.as_ref().clone()); 109 + let public_service_key = to_public(config.service_key.as_ref()) 110 + .map(|public_key_data| public_key_data.to_string()) 111 + .expect("public service key"); 112 + 113 + let service_document = build_service_document(&config.external_base, &public_service_key); 103 114 104 115 // Initialize OAuth and identity resolution components 105 116 let oauth_storage = PostgresOAuthRequestStorage::new_arc(pool.clone()); 106 117 let document_storage = PostgresDidDocumentStorage::new_arc(pool.clone()); 107 118 108 119 // Create a key provider populated with signing keys from OAuth backend config 109 - let key_provider_keys = 120 + let mut key_provider_keys = 110 121 if let OAuthBackendConfig::ATProtocol { signing_keys } = &config.oauth_backend { 111 122 signing_keys 112 123 .as_ref() ··· 122 133 } else { 123 134 HashMap::new() // Empty for AIP backend 124 135 }; 136 + key_provider_keys.insert(public_service_key, service_key.0.clone()); 125 137 let key_provider = Arc::new(SimpleKeyProvider::new(key_provider_keys)); 126 138 127 139 // Create OAuth client config (only for AT Protocol backend) ··· 157 169 plc_hostname: config.plc_hostname.clone(), 158 170 })); 159 171 172 + // Create webhook channel if webhooks are enabled 173 + let webhook_sender = if config.enable_webhooks && config.enable_task_webhooks { 174 + let (sender, receiver) = mpsc::channel(100); 175 + Some((sender, receiver)) 176 + } else { 177 + None 178 + }; 179 + 160 180 let content_storage: Arc<dyn ContentStorage> = if config.content_storage.starts_with("s3://") { 161 181 #[cfg(feature = "s3")] 162 182 { ··· 194 214 supported_languages, 195 215 locales, 196 216 content_storage.clone(), 217 + webhook_sender.as_ref().map(|(sender, _)| sender.clone()), 218 + service_did, 219 + service_document, 220 + service_key, 197 221 ); 198 222 199 223 let app = build_router(web_context.clone()); ··· 371 395 tracker.spawn(async move { 372 396 if let Err(err) = cleanup_task.run().await { 373 397 tracing::error!("OAuth requests cleanup task failed: {}", err); 398 + } 399 + inner_token.cancel(); 400 + }); 401 + } 402 + 403 + // Spawn webhook processor task if enabled 404 + if let Some((_, receiver)) = webhook_sender { 405 + use axum::extract::FromRef; 406 + use smokesignal::task_webhooks::WebhookProcessor; 407 + 408 + let webhook_processor = WebhookProcessor::new( 409 + pool.clone(), 410 + document_storage.clone(), 411 + ServiceDID::from_ref(&web_context), 412 + ServiceKey::from_ref(&web_context), 413 + receiver, 414 + token.clone(), 415 + ); 416 + 417 + let inner_token = token.clone(); 418 + tracker.spawn(async move { 419 + if let Err(err) = webhook_processor.run().await { 420 + tracing::error!("Webhook processor task failed: {}", err); 374 421 } 375 422 inner_token.cancel(); 376 423 });
+38 -1
src/config.rs
··· 1 1 use anyhow::Result; 2 - use atproto_identity::key::{KeyType, identify_key, to_public}; 2 + use atproto_identity::key::{KeyData, KeyType, identify_key, to_public}; 3 3 use axum_extra::extract::cookie::Key; 4 4 use base64::{Engine as _, engine::general_purpose}; 5 5 use ordermap::OrderMap; ··· 37 37 } 38 38 39 39 #[derive(Clone)] 40 + pub struct ServiceKey(KeyData); 41 + 42 + #[derive(Clone)] 40 43 pub struct Config { 41 44 pub version: String, 42 45 pub http_port: HttpPort, ··· 55 58 pub enable_task_identity_refresh: bool, 56 59 pub enable_jetstream: bool, 57 60 pub content_storage: String, 61 + pub enable_webhooks: bool, 62 + pub enable_task_webhooks: bool, 63 + pub service_key: ServiceKey, 58 64 } 59 65 60 66 impl Config { ··· 123 129 124 130 let content_storage = require_env("CONTENT_STORAGE")?; 125 131 132 + // Parse webhook enablement flags 133 + let enable_webhooks = parse_bool_env("ENABLE_WEBHOOKS", false); 134 + let enable_task_webhooks = parse_bool_env("ENABLE_TASK_WEBHOOKS", false); 135 + 136 + let service_key: ServiceKey = require_env("SERVICE_KEY")?.try_into()?; 137 + 126 138 Ok(Self { 127 139 version: version()?, 128 140 http_port, ··· 141 153 enable_task_identity_refresh, 142 154 enable_jetstream, 143 155 content_storage, 156 + enable_webhooks, 157 + enable_task_webhooks, 158 + service_key, 144 159 }) 145 160 } 146 161 ··· 175 190 std::env::var(name).unwrap_or(default_value.to_string()) 176 191 } 177 192 193 + fn parse_bool_env(name: &str, default: bool) -> bool { 194 + match std::env::var(name) { 195 + Ok(value) => matches!(value.to_lowercase().as_str(), "true" | "ok" | "1"), 196 + Err(_) => default, 197 + } 198 + } 199 + 178 200 pub fn version() -> Result<String> { 179 201 option_env!("GIT_HASH") 180 202 .or(option_env!("CARGO_PKG_VERSION")) ··· 217 239 218 240 impl AsRef<Key> for HttpCookieKey { 219 241 fn as_ref(&self) -> &Key { 242 + &self.0 243 + } 244 + } 245 + 246 + impl TryFrom<String> for ServiceKey { 247 + type Error = anyhow::Error; 248 + fn try_from(value: String) -> Result<Self, Self::Error> { 249 + identify_key(&value) 250 + .map(ServiceKey) 251 + .map_err(|err| err.into()) 252 + } 253 + } 254 + 255 + impl AsRef<KeyData> for ServiceKey { 256 + fn as_ref(&self) -> &KeyData { 220 257 &self.0 221 258 } 222 259 }
+73
src/http/context.rs
··· 14 14 use axum_template::engine::Engine; 15 15 use cookie::Key; 16 16 use minijinja::context as template_context; 17 + use std::convert::Infallible; 17 18 use std::{ops::Deref, sync::Arc}; 19 + use tokio::sync::mpsc; 18 20 use unic_langid::LanguageIdentifier; 19 21 20 22 #[cfg(feature = "reload")] ··· 26 28 #[cfg(feature = "embed")] 27 29 use minijinja::Environment; 28 30 31 + use crate::service::{ServiceDID, ServiceDocument, ServiceKey}; 29 32 use crate::storage::content::ContentStorage; 33 + use crate::task_webhooks::TaskWork; 30 34 use crate::{ 31 35 config::Config, 32 36 http::middleware_auth::Auth, ··· 57 61 pub(crate) oauth_storage: Arc<dyn atproto_oauth::storage::OAuthRequestStorage>, 58 62 pub(crate) document_storage: Arc<dyn atproto_identity::storage::DidDocumentStorage>, 59 63 pub(crate) content_storage: Arc<dyn ContentStorage>, 64 + pub(crate) webhook_sender: Option<mpsc::Sender<TaskWork>>, 65 + pub(crate) service_did: ServiceDID, 66 + pub(crate) service_document: ServiceDocument, 67 + pub(crate) service_key: ServiceKey, 60 68 } 61 69 62 70 #[derive(Clone, FromRef)] ··· 86 94 supported_languages: Vec<LanguageIdentifier>, 87 95 locales: Locales, 88 96 content_storage: Arc<dyn ContentStorage>, 97 + webhook_sender: Option<mpsc::Sender<TaskWork>>, 98 + service_did: ServiceDID, 99 + service_document: ServiceDocument, 100 + service_key: ServiceKey, 89 101 ) -> Self { 90 102 Self(Arc::new(InnerWebContext { 91 103 pool, ··· 103 115 oauth_storage, 104 116 document_storage, 105 117 content_storage, 118 + webhook_sender, 119 + service_did, 120 + service_document, 121 + service_key, 106 122 })) 107 123 } 108 124 } ··· 140 156 impl FromRef<WebContext> for Arc<dyn KeyProvider + Send + Sync> { 141 157 fn from_ref(context: &WebContext) -> Self { 142 158 context.0.key_provider.clone() 159 + } 160 + } 161 + 162 + impl FromRef<WebContext> for ServiceDocument { 163 + fn from_ref(context: &WebContext) -> Self { 164 + context.0.service_document.clone() 165 + } 166 + } 167 + 168 + impl FromRef<WebContext> for ServiceDID { 169 + fn from_ref(context: &WebContext) -> Self { 170 + context.0.service_did.clone() 171 + } 172 + } 173 + 174 + impl FromRef<WebContext> for ServiceKey { 175 + fn from_ref(context: &WebContext) -> Self { 176 + context.0.service_key.clone() 177 + } 178 + } 179 + 180 + impl<S> FromRequestParts<S> for ServiceDocument 181 + where 182 + ServiceDocument: FromRef<S>, 183 + S: Send + Sync, 184 + { 185 + type Rejection = Infallible; 186 + 187 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 188 + let service_document = ServiceDocument::from_ref(state); 189 + Ok(service_document) 190 + } 191 + } 192 + 193 + impl<S> FromRequestParts<S> for ServiceDID 194 + where 195 + ServiceDID: FromRef<S>, 196 + S: Send + Sync, 197 + { 198 + type Rejection = Infallible; 199 + 200 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 201 + let service_did = ServiceDID::from_ref(state); 202 + Ok(service_did) 203 + } 204 + } 205 + 206 + impl<S> FromRequestParts<S> for ServiceKey 207 + where 208 + ServiceKey: FromRef<S>, 209 + S: Send + Sync, 210 + { 211 + type Rejection = Infallible; 212 + 213 + async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { 214 + let service_key = ServiceKey::from_ref(state); 215 + Ok(service_key) 143 216 } 144 217 } 145 218
+34
src/http/handle_create_event.rs
··· 10 10 use http::Method; 11 11 use http::StatusCode; 12 12 use minijinja::context as template_context; 13 + use serde_json::json; 13 14 use std::collections::HashMap; 14 15 15 16 use crate::atproto::auth::{ ··· 38 39 use crate::http::utils::url_from_aturi; 39 40 use crate::select_template; 40 41 use crate::storage::event::event_insert; 42 + use crate::storage::webhook::webhook_list_enabled_by_did; 43 + use crate::task_webhooks::TaskWork; 41 44 use atproto_client::com::atproto::repo::{ 42 45 CreateRecordRequest, CreateRecordResponse, create_record, 43 46 }; ··· 333 336 default_context, 334 337 err 335 338 ); 339 + } 340 + 341 + // Send webhooks if enabled 342 + if web_context.config.enable_webhooks { 343 + if let Some(webhook_sender) = &web_context.webhook_sender { 344 + // Get all enabled webhooks for the user 345 + if let Ok(webhooks) = 346 + webhook_list_enabled_by_did(&web_context.pool, &current_handle.did) 347 + .await 348 + { 349 + // Prepare context with email if shared 350 + let context = json!({}); 351 + 352 + let record_json = json!({ 353 + "uri": &create_record_response.uri, 354 + "cit": &create_record_response.cid, 355 + }); 356 + 357 + // Send webhook for each enabled webhook 358 + for webhook in webhooks { 359 + let _ = webhook_sender 360 + .send(TaskWork::EventCreated { 361 + identity: current_handle.did.clone(), 362 + service: webhook.service, 363 + record: record_json.clone(), 364 + context: context.clone(), 365 + }) 366 + .await; 367 + } 368 + } 369 + } 336 370 } 337 371 338 372 let event_url = url_from_aturi(
+45
src/http/handle_create_rsvp.rs
··· 1 1 use anyhow::Result; 2 + use atproto_record::aturi::ATURI; 2 3 use axum::{extract::State, response::IntoResponse}; 3 4 use axum_extra::extract::{Cached, Form}; 4 5 use axum_htmx::{HxBoosted, HxRequest}; ··· 7 8 use http::Method; 8 9 use metrohash::MetroHash64; 9 10 use minijinja::context as template_context; 11 + use serde_json; 10 12 use std::hash::Hasher; 13 + use std::str::FromStr; 11 14 12 15 use crate::atproto::auth::{ 13 16 create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session, ··· 29 32 }, 30 33 select_template, 31 34 storage::event::{RsvpInsertParams, rsvp_insert_with_metadata}, 35 + storage::webhook::webhook_list_enabled_by_did, 36 + task_webhooks::TaskWork, 32 37 }; 33 38 use atproto_client::com::atproto::repo::{PutRecordRequest, PutRecordResponse, put_record}; 34 39 ··· 228 233 default_context, 229 234 err 230 235 ); 236 + } 237 + 238 + // Send webhooks if enabled 239 + if web_context.config.enable_webhooks { 240 + if let Some(webhook_sender) = &web_context.webhook_sender { 241 + 242 + let webhook_identity = ATURI::from_str(build_rsvp_form.subject_aturi.as_ref().unwrap()).map(|value| value.authority).unwrap_or_default(); 243 + 244 + // Get all enabled webhooks for the user 245 + if let Ok(webhooks) = 246 + webhook_list_enabled_by_did(&web_context.pool, &webhook_identity) 247 + .await 248 + { 249 + // Prepare context with email if shared 250 + let mut context = serde_json::json!({}); 251 + if build_rsvp_form.email_shared.unwrap_or(false) && identity_has_email { 252 + if let Some(email) = &current_handle.email { 253 + context["email"] = serde_json::json!(email); 254 + } 255 + } 256 + 257 + // Convert the RSVP record to JSON 258 + let record_json = serde_json::json!({ 259 + "uri": &create_record_result.uri, 260 + "cit": &create_record_result.cid, 261 + }); 262 + 263 + // Send webhook for each enabled webhook 264 + for webhook in webhooks { 265 + let _ = webhook_sender 266 + .send(TaskWork::RSVPCreated { 267 + identity: current_handle.did.clone(), 268 + service: webhook.service, 269 + record: record_json.clone(), 270 + context: context.clone(), 271 + }) 272 + .await; 273 + } 274 + } 275 + } 231 276 } 232 277 233 278 let event_url = url_from_aturi(
+297 -2
src/http/handle_settings.rs
··· 1 1 use anyhow::Result; 2 + use atproto_identity::resolve::IdentityResolver; 2 3 use axum::{extract::State, response::IntoResponse}; 3 4 use axum_extra::extract::{Cached, Form}; 4 5 use axum_htmx::HxBoosted; ··· 16 17 timezones::supported_timezones, 17 18 }, 18 19 select_template, 19 - storage::identity_profile::{ 20 - HandleField, handle_for_did, handle_update_field, identity_profile_set_email, 20 + storage::{ 21 + identity_profile::{ 22 + HandleField, handle_for_did, handle_update_field, identity_profile_set_email, 23 + }, 24 + webhook::{webhook_delete, webhook_list_by_did, webhook_toggle_enabled, webhook_upsert}, 21 25 }, 26 + task_webhooks::TaskWork, 27 + webhooks::SMOKE_SIGNAL_AUTOMATION_SERVICE, 22 28 }; 23 29 24 30 #[derive(Deserialize, Clone, Debug)] ··· 46 52 discover_rsvps: Option<String>, 47 53 } 48 54 55 + #[derive(Deserialize, Clone, Debug)] 56 + pub(crate) struct WebhookForm { 57 + service: String, 58 + } 59 + 49 60 pub(crate) async fn handle_settings( 50 61 State(web_context): State<WebContext>, 51 62 Language(language): Language, ··· 74 85 .map(|lang| lang.to_string()) 75 86 .collect::<Vec<String>>(); 76 87 88 + // Get webhooks if enabled 89 + let webhooks = if web_context.config.enable_webhooks { 90 + webhook_list_by_did(&web_context.pool, &current_handle.did).await? 91 + } else { 92 + vec![] 93 + }; 94 + 77 95 // Render the form 78 96 Ok(( 79 97 StatusCode::OK, ··· 83 101 template_context! { 84 102 timezones => timezones, 85 103 languages => supported_languages, 104 + webhooks => webhooks, 105 + webhooks_enabled => web_context.config.enable_webhooks, 86 106 ..default_context, 87 107 }, 88 108 ), ··· 423 443 ) 424 444 .into_response()) 425 445 } 446 + 447 + #[tracing::instrument(skip_all, err)] 448 + pub(crate) async fn handle_add_webhook( 449 + State(web_context): State<WebContext>, 450 + identity_resolver: IdentityResolver, 451 + Language(language): Language, 452 + Cached(auth): Cached<Auth>, 453 + Form(webhook_form): Form<WebhookForm>, 454 + ) -> Result<impl IntoResponse, WebError> { 455 + // Check if webhooks are enabled 456 + if !web_context.config.enable_webhooks { 457 + return Ok((StatusCode::NOT_FOUND, "Not found").into_response()); 458 + } 459 + 460 + let current_handle = auth.require_flat()?; 461 + 462 + let default_context = template_context! { 463 + current_handle => current_handle.clone(), 464 + language => language.to_string(), 465 + }; 466 + 467 + let error_template = select_template!(false, true, language); 468 + 469 + // Validate service is not empty 470 + if webhook_form.service.is_empty() { 471 + return contextual_error!( 472 + web_context, 473 + language, 474 + error_template, 475 + default_context, 476 + "error-xxx Service cannot be empty" 477 + ); 478 + } 479 + 480 + // Check if service contains required the suffix 481 + if !webhook_form 482 + .service 483 + .ends_with(SMOKE_SIGNAL_AUTOMATION_SERVICE) 484 + { 485 + return contextual_error!( 486 + web_context, 487 + language, 488 + error_template, 489 + default_context, 490 + "error-xxx Only SmokeSignalAutomation services are supported" 491 + ); 492 + } 493 + 494 + // Extract DID by removing the suffix 495 + let service_did = webhook_form 496 + .service 497 + .strip_suffix(SMOKE_SIGNAL_AUTOMATION_SERVICE) 498 + .unwrap(); 499 + 500 + // Resolve the service DID using the identity resolver 501 + let document = match identity_resolver.resolve(service_did).await { 502 + Ok(doc) => doc, 503 + Err(err) => { 504 + tracing::error!(?err, "Failed to resolve service DID: {}", service_did); 505 + return contextual_error!( 506 + web_context, 507 + language, 508 + error_template, 509 + default_context, 510 + format!("Failed to resolve service identity: {}", err) 511 + ); 512 + } 513 + }; 514 + 515 + // Store the resolved document 516 + if let Err(err) = web_context 517 + .document_storage 518 + .store_document(document.clone()) 519 + .await 520 + { 521 + tracing::error!(?err, "Failed to store DID document for: {}", service_did); 522 + return contextual_error!( 523 + web_context, 524 + language, 525 + error_template, 526 + default_context, 527 + format!("Failed to store service document: {}", err) 528 + ); 529 + } 530 + 531 + if let Err(err) = webhook_upsert( 532 + &web_context.pool, 533 + &current_handle.did, 534 + &webhook_form.service, 535 + ) 536 + .await 537 + { 538 + tracing::error!(?err, "error inserting webhook?"); 539 + return contextual_error!(web_context, language, error_template, default_context, err); 540 + } 541 + tracing::info!("webhook added?"); 542 + 543 + // Trigger HTMX refresh 544 + match handle_list_webhooks(State(web_context), Language(language), Cached(auth)).await { 545 + Ok(response) => Ok(response.into_response()), 546 + Err(err) => Err(err), 547 + } 548 + } 549 + 550 + #[tracing::instrument(skip_all, err)] 551 + pub(crate) async fn handle_toggle_webhook( 552 + State(web_context): State<WebContext>, 553 + Language(language): Language, 554 + Cached(auth): Cached<Auth>, 555 + Form(webhook_form): Form<WebhookForm>, 556 + ) -> Result<impl IntoResponse, WebError> { 557 + // Check if webhooks are enabled 558 + if !web_context.config.enable_webhooks { 559 + return Ok((StatusCode::NOT_FOUND, "Not found").into_response()); 560 + } 561 + 562 + let current_handle = auth.require_flat()?; 563 + 564 + let default_context = template_context! { 565 + current_handle => current_handle.clone(), 566 + language => language.to_string(), 567 + }; 568 + 569 + let error_template = select_template!(false, true, language); 570 + 571 + // Toggle webhook in database7 572 + if let Err(err) = webhook_toggle_enabled( 573 + &web_context.pool, 574 + &current_handle.did, 575 + &webhook_form.service, 576 + ) 577 + .await 578 + { 579 + return contextual_error!(web_context, language, error_template, default_context, err); 580 + } 581 + 582 + // Trigger HTMX refresh 583 + match handle_list_webhooks(State(web_context), Language(language), Cached(auth)).await { 584 + Ok(response) => Ok(response.into_response()), 585 + Err(err) => Err(err), 586 + } 587 + } 588 + 589 + #[tracing::instrument(skip_all, err)] 590 + pub(crate) async fn handle_test_webhook( 591 + State(web_context): State<WebContext>, 592 + Language(language): Language, 593 + Cached(auth): Cached<Auth>, 594 + Form(webhook_form): Form<WebhookForm>, 595 + ) -> Result<impl IntoResponse, WebError> { 596 + // Check if webhooks are enabled 597 + if !web_context.config.enable_webhooks { 598 + return Ok((StatusCode::NOT_FOUND, "Not found").into_response()); 599 + } 600 + 601 + let current_handle = auth.require_flat()?; 602 + 603 + let default_context = template_context! { 604 + current_handle => current_handle.clone(), 605 + language => language.to_string(), 606 + }; 607 + 608 + let error_template = select_template!(false, true, language); 609 + 610 + // Send test webhook 611 + if let Some(webhook_sender) = &web_context.webhook_sender { 612 + if let Err(err) = webhook_sender 613 + .send(TaskWork::Test { 614 + identity: current_handle.did.clone(), 615 + service: webhook_form.service.clone(), 616 + }) 617 + .await 618 + { 619 + return contextual_error!( 620 + web_context, 621 + language, 622 + error_template, 623 + default_context, 624 + format!("Failed to send webhook: {}", err) 625 + ); 626 + } 627 + } else { 628 + return contextual_error!( 629 + web_context, 630 + language, 631 + error_template, 632 + default_context, 633 + "Webhook processing is not enabled" 634 + ); 635 + } 636 + 637 + // Trigger HTMX refresh 638 + match handle_list_webhooks(State(web_context), Language(language), Cached(auth)).await { 639 + Ok(response) => Ok(response.into_response()), 640 + Err(err) => Err(err), 641 + } 642 + } 643 + 644 + #[tracing::instrument(skip_all, err)] 645 + pub(crate) async fn handle_remove_webhook( 646 + State(web_context): State<WebContext>, 647 + Language(language): Language, 648 + Cached(auth): Cached<Auth>, 649 + Form(webhook_form): Form<WebhookForm>, 650 + ) -> Result<impl IntoResponse, WebError> { 651 + // Check if webhooks are enabled 652 + if !web_context.config.enable_webhooks { 653 + return Ok((StatusCode::NOT_FOUND, "Not found").into_response()); 654 + } 655 + 656 + let current_handle = auth.require_flat()?; 657 + 658 + let default_context = template_context! { 659 + current_handle => current_handle.clone(), 660 + language => language.to_string(), 661 + }; 662 + 663 + let error_template = select_template!(false, true, language); 664 + 665 + // Remove webhook from database 666 + if let Err(err) = webhook_delete( 667 + &web_context.pool, 668 + &current_handle.did, 669 + &webhook_form.service, 670 + ) 671 + .await 672 + { 673 + return contextual_error!(web_context, language, error_template, default_context, err); 674 + } 675 + 676 + // Trigger HTMX refresh 677 + match handle_list_webhooks(State(web_context), Language(language), Cached(auth)).await { 678 + Ok(response) => Ok(response.into_response()), 679 + Err(err) => Err(err), 680 + } 681 + } 682 + 683 + #[tracing::instrument(skip_all, err)] 684 + pub(crate) async fn handle_list_webhooks( 685 + State(web_context): State<WebContext>, 686 + Language(language): Language, 687 + Cached(auth): Cached<Auth>, 688 + ) -> Result<impl IntoResponse, WebError> { 689 + // Check if webhooks are enabled 690 + if !web_context.config.enable_webhooks { 691 + return Ok((StatusCode::NOT_FOUND, "Not found").into_response()); 692 + } 693 + 694 + let current_handle = auth.require("/settings")?; 695 + 696 + let render_template = format!( 697 + "{}/settings.webhooks.html", 698 + language.to_string().to_lowercase() 699 + ); 700 + 701 + // Get webhooks 702 + let webhooks = webhook_list_by_did(&web_context.pool, &current_handle.did) 703 + .await 704 + .unwrap_or_default(); 705 + 706 + Ok(( 707 + StatusCode::OK, 708 + RenderHtml( 709 + &render_template, 710 + web_context.engine.clone(), 711 + template_context! { 712 + current_handle => current_handle.clone(), 713 + language => language.to_string(), 714 + webhooks => webhooks, 715 + webhooks_enabled => web_context.config.enable_webhooks, 716 + }, 717 + ), 718 + ) 719 + .into_response()) 720 + }
+9
src/http/handle_wellknown.rs
··· 1 + use axum::Json; 2 + 3 + use crate::service::ServiceDocument; 4 + 5 + pub(super) async fn handle_wellknown_did_web( 6 + ServiceDocument(service_document): ServiceDocument, 7 + ) -> Json<serde_json::Value> { 8 + Json(service_document) 9 + }
+6 -3
src/http/handle_xrpc_get_event.rs
··· 8 8 use serde::{Deserialize, Serialize}; 9 9 use serde_json::json; 10 10 11 - use crate::http::{context::WebContext, utils::url_from_aturi}; 12 11 use crate::http::errors::WebError; 12 + use crate::http::{context::WebContext, utils::url_from_aturi}; 13 13 use crate::storage::event::event_get; 14 14 use crate::{ 15 15 atproto::lexicon::community::lexicon::calendar::event::NSID as CommunityLexiconCalendarEventNSID, ··· 98 98 let mut record = event.record.0.clone(); 99 99 100 100 if let Some(value) = record.as_object_mut() { 101 - value.insert("$type".to_string(), json!("community.lexicon.calendar.eventView")); 101 + value.insert( 102 + "$type".to_string(), 103 + json!("community.lexicon.calendar.eventView"), 104 + ); 102 105 } 103 106 104 107 let url = url_from_aturi(&web_context.config.external_base, &event.aturi)?; ··· 109 112 count_going, 110 113 count_interested, 111 114 count_not_going, 112 - url 115 + url, 113 116 }; 114 117 115 118 Ok(Json(response).into_response())
+1
src/http/mod.rs
··· 33 33 pub mod handle_set_language; 34 34 pub mod handle_settings; 35 35 pub mod handle_view_event; 36 + pub mod handle_wellknown; 36 37 pub mod handle_xrpc_get_event; 37 38 pub mod import_utils; 38 39 pub mod location_edit_status;
+22 -9
src/http/server.rs
··· 6 6 }; 7 7 use axum_htmx::AutoVaryLayer; 8 8 use http::{ 9 - HeaderName, 10 - Method, 9 + HeaderName, Method, 11 10 header::{ACCEPT, ACCEPT_LANGUAGE, CONTENT_TYPE}, 12 11 }; 13 12 use tower_http::trace::{self, TraceLayer}; ··· 49 48 handle_profile::handle_profile_view, 50 49 handle_set_language::handle_set_language, 51 50 handle_settings::{ 52 - handle_discover_events_update, handle_discover_rsvps_update, handle_email_update, 53 - handle_language_update, handle_settings, handle_timezone_update, 51 + handle_add_webhook, handle_discover_events_update, handle_discover_rsvps_update, 52 + handle_email_update, handle_language_update, handle_list_webhooks, handle_remove_webhook, 53 + handle_settings, handle_test_webhook, handle_timezone_update, handle_toggle_webhook, 54 54 }, 55 55 handle_view_event::handle_view_event, 56 + handle_wellknown::handle_wellknown_did_web, 56 57 handle_xrpc_get_event::handle_xrpc_get_event, 57 58 }; 58 59 use crate::{config::OAuthBackendConfig, http::handle_view_event::handle_event_ics}; ··· 72 73 .route("/terms-of-service", get(handle_terms_of_service)) 73 74 .route("/cookie-policy", get(handle_cookie_policy)) 74 75 .route("/acknowledgement", get(handle_acknowledgement)) 75 - .route("/xrpc/community.lexicon.calendar.GetEvent", get(handle_xrpc_get_event)); 76 + .route("/.well-known/did.json", get(handle_wellknown_did_web)) 77 + .route( 78 + "/xrpc/community.lexicon.calendar.GetEvent", 79 + get(handle_xrpc_get_event), 80 + ); 76 81 77 82 // Add OAuth metadata route only for AT Protocol backend 78 83 if matches!( ··· 125 130 "/settings/discover_rsvps", 126 131 post(handle_discover_rsvps_update), 127 132 ) 133 + .route("/settings/webhooks", get(handle_list_webhooks)) 134 + .route("/settings/webhooks/add", post(handle_add_webhook)) 135 + .route("/settings/webhooks/toggle", post(handle_toggle_webhook)) 136 + .route("/settings/webhooks/test", post(handle_test_webhook)) 137 + .route("/settings/webhooks/remove", post(handle_remove_webhook)) 128 138 .route("/import", get(handle_import)) 129 139 .route("/import", post(handle_import_submit)) 130 140 .route("/event", get(handle_create_event)) ··· 173 183 )) 174 184 .layer( 175 185 CorsLayer::new() 176 - .allow_origin( 177 - tower_http::cors::Any 178 - ) 186 + .allow_origin(tower_http::cors::Any) 179 187 .allow_methods([Method::GET]) 180 - .allow_headers([ACCEPT_LANGUAGE, ACCEPT, CONTENT_TYPE, HeaderName::from_lowercase(b"x-widget-version").unwrap()]), 188 + .allow_headers([ 189 + ACCEPT_LANGUAGE, 190 + ACCEPT, 191 + CONTENT_TYPE, 192 + HeaderName::from_lowercase(b"x-widget-version").unwrap(), 193 + ]), 181 194 ) 182 195 .layer(AutoVaryLayer) 183 196 .with_state(web_context.clone())
+4 -2
src/lib.rs
··· 6 6 pub mod http; 7 7 pub mod i18n; 8 8 pub mod key_provider; 9 + pub mod processor; 9 10 pub mod refresh_tokens_errors; 11 + pub mod service; 10 12 pub mod storage; 11 - 12 - pub mod processor; 13 13 pub mod task_identity_refresh; 14 14 pub mod task_oauth_requests_cleanup; 15 15 pub mod task_refresh_tokens; 16 + pub mod task_webhooks; 17 + pub mod webhooks;
+26
src/service.rs
··· 1 + use atproto_identity::key::KeyData; 2 + 3 + #[derive(Clone)] 4 + pub struct ServiceDocument(pub serde_json::Value); 5 + 6 + #[derive(Clone)] 7 + pub struct ServiceDID(pub String); 8 + 9 + #[derive(Clone)] 10 + pub struct ServiceKey(pub KeyData); 11 + 12 + pub fn build_service_document(external_base: &str, public_service_key: &str) -> ServiceDocument { 13 + ServiceDocument(serde_json::json!({ 14 + "@context": vec!["https://www.w3.org/ns/did/v1","https://w3id.org/security/multikey/v1"], 15 + "id": format!("did:web:{external_base}"), 16 + "alsoKnownAs": [], 17 + "verificationMethod":[{ 18 + "id": format!("did:web:{external_base}#atproto"), 19 + "type":"Multikey", 20 + "controller": format!("did:web:{external_base}"), 21 + "publicKeyMultibase": public_service_key 22 + }], 23 + "service":[] 24 + } 25 + )) 26 + }
+1
src/storage/mod.rs
··· 7 7 pub mod identity_profile; 8 8 pub mod oauth; 9 9 pub mod types; 10 + pub mod webhook; 10 11 pub use types::*;
+211
src/storage/webhook.rs
··· 1 + use chrono::Utc; 2 + 3 + use crate::storage::{StoragePool, errors::StorageError}; 4 + 5 + pub(crate) mod model { 6 + use chrono::{DateTime, Utc}; 7 + use serde::{Deserialize, Serialize}; 8 + use sqlx::FromRow; 9 + 10 + #[derive(Clone, FromRow, Deserialize, Serialize, Debug)] 11 + pub struct IdentityWebhook { 12 + pub did: String, 13 + pub service: String, 14 + pub created_at: DateTime<Utc>, 15 + pub enabled: bool, 16 + pub errored_at: Option<DateTime<Utc>>, 17 + pub error: Option<String>, 18 + } 19 + } 20 + 21 + pub use model::IdentityWebhook; 22 + 23 + pub async fn webhook_upsert( 24 + pool: &StoragePool, 25 + did: &str, 26 + service: &str, 27 + ) -> Result<(), StorageError> { 28 + let mut tx = pool 29 + .begin() 30 + .await 31 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 32 + 33 + let now = Utc::now(); 34 + 35 + sqlx::query( 36 + r#" 37 + INSERT INTO identity_webhooks (did, service, created_at, enabled, errored_at, error) 38 + VALUES ($1, $2, $3, true, NULL, NULL) 39 + ON CONFLICT (did, service) DO UPDATE SET 40 + enabled = true, 41 + errored_at = NULL, 42 + error = NULL 43 + "#, 44 + ) 45 + .bind(did) 46 + .bind(service) 47 + .bind(now) 48 + .execute(tx.as_mut()) 49 + .await 50 + .map_err(StorageError::UnableToExecuteQuery)?; 51 + 52 + tx.commit() 53 + .await 54 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 55 + 56 + Ok(()) 57 + } 58 + 59 + pub async fn webhook_toggle_enabled( 60 + pool: &StoragePool, 61 + did: &str, 62 + service: &str, 63 + ) -> Result<(), StorageError> { 64 + let mut tx = pool 65 + .begin() 66 + .await 67 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 68 + 69 + sqlx::query( 70 + r#" 71 + UPDATE identity_webhooks 72 + SET enabled = NOT enabled 73 + WHERE did = $1 AND service = $2 74 + "#, 75 + ) 76 + .bind(did) 77 + .bind(service) 78 + .execute(tx.as_mut()) 79 + .await 80 + .map_err(StorageError::UnableToExecuteQuery)?; 81 + 82 + tx.commit() 83 + .await 84 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 85 + 86 + Ok(()) 87 + } 88 + 89 + pub async fn webhook_list_by_did( 90 + pool: &StoragePool, 91 + did: &str, 92 + ) -> Result<Vec<IdentityWebhook>, StorageError> { 93 + let mut tx = pool 94 + .begin() 95 + .await 96 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 97 + 98 + let webhooks = sqlx::query_as::<_, model::IdentityWebhook>( 99 + r#" 100 + SELECT did, service, created_at, enabled, errored_at, error 101 + FROM identity_webhooks 102 + WHERE did = $1 103 + ORDER BY created_at DESC 104 + "#, 105 + ) 106 + .bind(did) 107 + .fetch_all(tx.as_mut()) 108 + .await 109 + .map_err(StorageError::UnableToExecuteQuery)?; 110 + 111 + tx.commit() 112 + .await 113 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 114 + 115 + Ok(webhooks) 116 + } 117 + 118 + pub async fn webhook_delete( 119 + pool: &StoragePool, 120 + did: &str, 121 + service: &str, 122 + ) -> Result<(), StorageError> { 123 + let mut tx = pool 124 + .begin() 125 + .await 126 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 127 + 128 + sqlx::query( 129 + r#" 130 + DELETE FROM identity_webhooks 131 + WHERE did = $1 AND service = $2 132 + "#, 133 + ) 134 + .bind(did) 135 + .bind(service) 136 + .execute(tx.as_mut()) 137 + .await 138 + .map_err(StorageError::UnableToExecuteQuery)?; 139 + 140 + tx.commit() 141 + .await 142 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 143 + 144 + Ok(()) 145 + } 146 + 147 + pub async fn webhook_failed( 148 + pool: &StoragePool, 149 + did: &str, 150 + service: &str, 151 + error: &str, 152 + ) -> Result<(), StorageError> { 153 + let mut tx = pool 154 + .begin() 155 + .await 156 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 157 + 158 + let now = Utc::now(); 159 + 160 + sqlx::query( 161 + r#" 162 + UPDATE identity_webhooks 163 + SET enabled = false, 164 + errored_at = $4, 165 + error = $3 166 + WHERE did = $1 AND service = $2 167 + "#, 168 + ) 169 + .bind(did) 170 + .bind(service) 171 + .bind(error) 172 + .bind(now) 173 + .execute(tx.as_mut()) 174 + .await 175 + .map_err(StorageError::UnableToExecuteQuery)?; 176 + 177 + tx.commit() 178 + .await 179 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 180 + 181 + Ok(()) 182 + } 183 + 184 + pub async fn webhook_list_enabled_by_did( 185 + pool: &StoragePool, 186 + did: &str, 187 + ) -> Result<Vec<IdentityWebhook>, StorageError> { 188 + let mut tx = pool 189 + .begin() 190 + .await 191 + .map_err(StorageError::CannotBeginDatabaseTransaction)?; 192 + 193 + let webhooks = sqlx::query_as::<_, model::IdentityWebhook>( 194 + r#" 195 + SELECT did, service, created_at, enabled, errored_at, error 196 + FROM identity_webhooks 197 + WHERE did = $1 AND enabled = true 198 + ORDER BY created_at DESC 199 + "#, 200 + ) 201 + .bind(did) 202 + .fetch_all(tx.as_mut()) 203 + .await 204 + .map_err(StorageError::UnableToExecuteQuery)?; 205 + 206 + tx.commit() 207 + .await 208 + .map_err(StorageError::CannotCommitDatabaseTransaction)?; 209 + 210 + Ok(webhooks) 211 + }
+257
src/task_webhooks.rs
··· 1 + use anyhow::Result; 2 + use atproto_identity::storage::DidDocumentStorage; 3 + use atproto_oauth::jwt::{Claims, Header, mint}; 4 + use chrono::Utc; 5 + use rand::distributions::{Alphanumeric, DistString}; 6 + use serde::{Deserialize, Serialize}; 7 + use serde_json::json; 8 + use sqlx::PgPool; 9 + use std::sync::Arc; 10 + use std::time::Duration; 11 + use tokio::sync::mpsc; 12 + use tokio_util::sync::CancellationToken; 13 + 14 + use crate::{ 15 + service::{ServiceDID, ServiceKey}, 16 + storage::webhook::webhook_failed, 17 + webhooks::{ 18 + EVENT_CREATED_EVENT, RSVP_CREATED_EVENT, SMOKE_SIGNAL_AUTOMATION_SERVICE, TEST_EVENT, 19 + }, 20 + }; 21 + 22 + #[derive(Debug, Clone)] 23 + pub enum TaskWork { 24 + Test { 25 + identity: String, 26 + service: String, 27 + }, 28 + RSVPCreated { 29 + identity: String, 30 + service: String, 31 + record: serde_json::Value, 32 + context: serde_json::Value, 33 + }, 34 + EventCreated { 35 + identity: String, 36 + service: String, 37 + record: serde_json::Value, 38 + context: serde_json::Value, 39 + }, 40 + } 41 + 42 + #[derive(Debug, Serialize, Deserialize)] 43 + struct WebhookPayload { 44 + record: serde_json::Value, 45 + context: serde_json::Value, 46 + event: String, 47 + } 48 + 49 + pub struct WebhookProcessor { 50 + pool: PgPool, 51 + document_storage: Arc<dyn DidDocumentStorage>, 52 + service_did: ServiceDID, 53 + service_key: ServiceKey, 54 + receiver: mpsc::Receiver<TaskWork>, 55 + cancel_token: CancellationToken, 56 + } 57 + 58 + impl WebhookProcessor { 59 + pub fn new( 60 + pool: PgPool, 61 + document_storage: Arc<dyn DidDocumentStorage>, 62 + service_did: ServiceDID, 63 + service_key: ServiceKey, 64 + receiver: mpsc::Receiver<TaskWork>, 65 + cancel_token: CancellationToken, 66 + ) -> Self { 67 + Self { 68 + pool, 69 + document_storage, 70 + service_did, 71 + service_key, 72 + receiver, 73 + cancel_token, 74 + } 75 + } 76 + 77 + pub async fn run(mut self) -> Result<()> { 78 + tracing::info!("Webhook processor task started"); 79 + 80 + loop { 81 + tokio::select! { 82 + _ = self.cancel_token.cancelled() => { 83 + tracing::info!("Webhook processor task shutting down"); 84 + break; 85 + } 86 + Some(work) = self.receiver.recv() => { 87 + if let Err(e) = self.process_work(work).await { 88 + tracing::error!("Error processing webhook work: {:?}", e); 89 + } 90 + } 91 + } 92 + } 93 + 94 + Ok(()) 95 + } 96 + 97 + async fn process_work(&self, work: TaskWork) -> Result<()> { 98 + match work { 99 + TaskWork::Test { identity, service } => { 100 + self.send_webhook( 101 + &identity, 102 + &service, 103 + WebhookPayload { 104 + record: json!({}), 105 + context: json!({}), 106 + event: TEST_EVENT.to_string(), 107 + }, 108 + ) 109 + .await 110 + } 111 + TaskWork::RSVPCreated { 112 + identity, 113 + service, 114 + record, 115 + context, 116 + } => { 117 + self.send_webhook( 118 + &identity, 119 + &service, 120 + WebhookPayload { 121 + record, 122 + context, 123 + event: RSVP_CREATED_EVENT.to_string(), 124 + }, 125 + ) 126 + .await 127 + } 128 + TaskWork::EventCreated { 129 + identity, 130 + service, 131 + record, 132 + context, 133 + } => { 134 + self.send_webhook( 135 + &identity, 136 + &service, 137 + WebhookPayload { 138 + record, 139 + context, 140 + event: EVENT_CREATED_EVENT.to_string(), 141 + }, 142 + ) 143 + .await 144 + } 145 + } 146 + } 147 + 148 + async fn send_webhook( 149 + &self, 150 + identity: &str, 151 + service: &str, 152 + payload: WebhookPayload, 153 + ) -> Result<()> { 154 + // Remove the suffix from service 155 + if !service.ends_with(SMOKE_SIGNAL_AUTOMATION_SERVICE) { 156 + return Err(anyhow::anyhow!( 157 + "Service must end with {}", 158 + SMOKE_SIGNAL_AUTOMATION_SERVICE 159 + )); 160 + } 161 + 162 + let service_did = service 163 + .strip_suffix(SMOKE_SIGNAL_AUTOMATION_SERVICE) 164 + .unwrap(); 165 + 166 + // Get the DID document from document storage 167 + let document = self 168 + .document_storage 169 + .get_document_by_did(service_did) 170 + .await 171 + .map_err(|e| anyhow::anyhow!("Failed to get DID document for {}: {}", service_did, e))? 172 + .ok_or_else(|| anyhow::anyhow!("DID document not found for {}", service_did))?; 173 + 174 + // Extract the service endpoint 175 + let automation_service = document 176 + .service 177 + .iter() 178 + .find(|service| service.id.ends_with(SMOKE_SIGNAL_AUTOMATION_SERVICE)) 179 + .ok_or_else(|| anyhow::anyhow!("service not found in DID document"))?; 180 + 181 + // Get the service endpoint - it should be a string URL 182 + let endpoint_url = &automation_service.service_endpoint; 183 + 184 + // Construct the webhook URL 185 + let webhook_url = format!( 186 + "{}/xrpc/events.smokesignal.automation.InvokeWebhook", 187 + endpoint_url 188 + ); 189 + 190 + // Create signed JWT 191 + let header: Header = self.service_key.0.clone().try_into()?; 192 + 193 + let now = Utc::now(); 194 + let jti = Alphanumeric.sample_string(&mut rand::thread_rng(), 30); 195 + 196 + // Create base claims 197 + let mut claims = Claims::default(); 198 + claims.jose.issuer = Some(self.service_did.0.clone()); 199 + claims.jose.audience = Some(service_did.to_string()); 200 + claims.jose.issued_at = Some(now.timestamp() as u64); 201 + claims.jose.expiration = Some((now.timestamp() as u64) + 60); 202 + claims.jose.json_web_token_id = Some(jti); 203 + claims.private.insert( 204 + "lxm".to_string(), 205 + serde_json::Value::String("events.smokesignal.automation.InvokeWebhook".to_string()), 206 + ); 207 + 208 + let token = mint(&self.service_key.0, &header, &claims) 209 + .map_err(|e| anyhow::anyhow!("Failed to create JWT: {}", e))?; 210 + 211 + // Prepare headers with JWT authorization 212 + let mut headers = reqwest::header::HeaderMap::new(); 213 + headers.insert("authorization", format!("Bearer {}", token).parse()?); 214 + headers.insert("content-type", "application/json".parse()?); 215 + 216 + // Send the webhook 217 + let client = reqwest::Client::new(); 218 + let response = client 219 + .post(&webhook_url) 220 + .headers(headers) 221 + .json(&payload) 222 + .timeout(Duration::from_secs(30)) 223 + .send() 224 + .await; 225 + 226 + match response { 227 + Ok(resp) if resp.status().is_success() => { 228 + tracing::info!("Webhook sent successfully to {} for {}", service, identity); 229 + Ok(()) 230 + } 231 + Ok(resp) => { 232 + let status = resp.status(); 233 + let error_text = resp 234 + .text() 235 + .await 236 + .unwrap_or_else(|_| "Unknown error".to_string()); 237 + let error_msg = format!("HTTP {}: {}", status, error_text); 238 + 239 + tracing::error!("Webhook failed: {}", error_msg); 240 + 241 + // Update database to mark webhook as failed 242 + webhook_failed(&self.pool, identity, service, &error_msg).await?; 243 + 244 + Err(anyhow::anyhow!("Webhook failed: {}", error_msg)) 245 + } 246 + Err(e) => { 247 + let error_msg = format!("Request failed: {}", e); 248 + tracing::error!("Webhook request error: {}", error_msg); 249 + 250 + // Update database to mark webhook as failed 251 + webhook_failed(&self.pool, identity, service, &error_msg).await?; 252 + 253 + Err(anyhow::anyhow!("Webhook request failed: {}", e)) 254 + } 255 + } 256 + } 257 + }
+5
src/webhooks.rs
··· 1 + pub(crate) const SMOKE_SIGNAL_AUTOMATION_SERVICE: &str = "#SmokeSignalAutomation"; 2 + 3 + pub(crate) const RSVP_CREATED_EVENT: &str = "rsvp.created"; 4 + pub(crate) const EVENT_CREATED_EVENT: &str = "event.created"; 5 + pub(crate) const TEST_EVENT: &str = "test";
+18
static/widget.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>One Year Anniversary</title> 7 + <link href="https://ngerakines.me/bootstrap.min.css" rel="stylesheet" integrity="sha384-g3eLCUsMEdqvnjhHex/RLRmQVoT3aVo1L9wi+Se4o5I+J4g9Thg6WNM6svKmKI8F" crossorigin="anonymous"> 8 + </head> 9 + <body> 10 + <div class="container"> 11 + <div class="row"><h1>Smoke Signal Widget</h1></div> 12 + <div class="row"> 13 + <div data-smoke-signal-widget data-api-url="https://smokesignal.events" data-aturi="at://did:plc:tgudj2fjm77pzkuawquqhsxm/community.lexicon.calendar.event/3kxbvxj7blk2t" data-theme="light"></div> 14 + <script src="https://smokesignal.events/static/widget_v1.js"></script> 15 + </div> 16 + </div> 17 + </body> 18 + </html>
+35
templates/en-us/settings.common.html
··· 58 58 </div> 59 59 </div> 60 60 </div> 61 + 62 + {% if webhooks_enabled %} 63 + <hr> 64 + 65 + <div class="columns"> 66 + <div class="column"> 67 + <h2 class="subtitle">Webhooks</h2> 68 + <p class="mb-4">Configure webhook endpoints to receive notifications when events occur.</p> 69 + 70 + <div id="webhooks-list"> 71 + {% include "en-us/settings.webhooks.html" %} 72 + </div> 73 + 74 + <div class="box mt-4"> 75 + <h3 class="subtitle is-5">Add New Webhook</h3> 76 + <form hx-post="/settings/webhooks/add" hx-target="#webhooks-list" hx-swap="innerHTML"> 77 + <div class="field has-addons"> 78 + <div class="control is-expanded"> 79 + <input class="input" type="text" name="service" placeholder="webhook-service-name" required> 80 + </div> 81 + <div class="control"> 82 + <button class="button is-primary" type="submit"> 83 + <span class="icon"> 84 + <i class="fas fa-plus"></i> 85 + </span> 86 + <span>Add Webhook</span> 87 + </button> 88 + </div> 89 + </div> 90 + <p class="help">Enter the service name for your webhook endpoint (e.g., "my-automation-service")</p> 91 + </form> 92 + </div> 93 + </div> 94 + </div> 95 + {% endif %} 61 96 </div> 62 97 </div> 63 98 </div>
+76
templates/en-us/settings.webhooks.html
··· 1 + {% if webhooks %} 2 + <div class="table-container"> 3 + <table class="table is-fullwidth is-striped"> 4 + <thead> 5 + <tr> 6 + <th>Service</th> 7 + <th>Status</th> 8 + <th>Created</th> 9 + <th>Last Error</th> 10 + <th>Actions</th> 11 + </tr> 12 + </thead> 13 + <tbody> 14 + {% for webhook in webhooks %} 15 + <tr> 16 + <td>{{ webhook.service }}</td> 17 + <td> 18 + {% if webhook.enabled %} 19 + <span class="tag is-success">Enabled</span> 20 + {% else %} 21 + <span class="tag is-danger">Disabled</span> 22 + {% endif %} 23 + </td> 24 + <td>{{ webhook.created_at }}</td> 25 + <td> 26 + {% if webhook.error %} 27 + <span class="has-text-danger" title="{{ webhook.error }}"> 28 + {{ webhook.errored_at }} 29 + </span> 30 + {% else %} 31 + <span class="has-text-grey">-</span> 32 + {% endif %} 33 + </td> 34 + <td> 35 + <div class="buttons are-small"> 36 + <form hx-post="/settings/webhooks/toggle" hx-target="#webhooks-list" hx-swap="innerHTML" style="display: inline;"> 37 + <input type="hidden" name="service" value="{{ webhook.service }}"> 38 + <button class="button is-small {% if webhook.enabled %}is-warning{% else %}is-success{% endif %}" type="submit"> 39 + <span class="icon"> 40 + <i class="fas {% if webhook.enabled %}fa-pause{% else %}fa-play{% endif %}"></i> 41 + </span> 42 + <span>{% if webhook.enabled %}Disable{% else %}Enable{% endif %}</span> 43 + </button> 44 + </form> 45 + 46 + <form hx-post="/settings/webhooks/test" hx-target="#webhooks-list" hx-swap="innerHTML" style="display: inline;"> 47 + <input type="hidden" name="service" value="{{ webhook.service }}"> 48 + <button class="button is-small is-info" type="submit" {% if not webhook.enabled %}disabled{% endif %}> 49 + <span class="icon"> 50 + <i class="fas fa-vial"></i> 51 + </span> 52 + <span>Test</span> 53 + </button> 54 + </form> 55 + 56 + <form hx-post="/settings/webhooks/remove" hx-target="#webhooks-list" hx-swap="innerHTML" hx-confirm="Are you sure you want to remove this webhook?" style="display: inline;"> 57 + <input type="hidden" name="service" value="{{ webhook.service }}"> 58 + <button class="button is-small is-danger" type="submit"> 59 + <span class="icon"> 60 + <i class="fas fa-trash"></i> 61 + </span> 62 + <span>Remove</span> 63 + </button> 64 + </form> 65 + </div> 66 + </td> 67 + </tr> 68 + {% endfor %} 69 + </tbody> 70 + </table> 71 + </div> 72 + {% else %} 73 + <div class="notification is-info is-light"> 74 + <p>No webhooks configured yet. Add your first webhook using the form below.</p> 75 + </div> 76 + {% endif %}