diff --git a/Cargo.toml b/Cargo.toml index f136d51..dd0828f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ bytes = "1.6" ulid = "1.1" warp = "0.3" time = "0.3" +standardwebhooks = "1.0" # boilerplate derive_more = { version = "1.0.0-beta.6", features = [ diff --git a/crates/config/src/server.rs b/crates/config/src/server.rs index df69b14..4a2b541 100644 --- a/crates/config/src/server.rs +++ b/crates/config/src/server.rs @@ -8,6 +8,7 @@ use std::{ }; use kxio::fs::FileSystem; +use secrecy::Secret; use tracing::info; use crate::{newtype, ForgeAlias, ForgeConfig, RepoAlias}; @@ -42,7 +43,7 @@ type Result = core::result::Result; pub struct ServerConfig { http: Http, webhook: InboundWebhook, - notifications: Notification, + notification: Notification, storage: ServerStorage, pub forge: BTreeMap, } @@ -65,11 +66,11 @@ impl ServerConfig { &self.storage } - pub const fn notifications(&self) -> &Notification { - &self.notifications + pub const fn notification(&self) -> &Notification { + &self.notification } - pub const fn webhook(&self) -> &InboundWebhook { + pub const fn inbound_webhook(&self) -> &InboundWebhook { &self.webhook } @@ -205,9 +206,17 @@ impl Notification { } } + pub const fn r#type(&self) -> NotificationType { + self.r#type + } + pub fn webhook_url(&self) -> Option { self.webhook.clone().map(|x| x.url) } + + pub fn webhook_secret(&self) -> Option> { + self.webhook.clone().map(|x| x.secret).map(Secret::new) + } } impl Default for Notification { fn default() -> Self { @@ -223,10 +232,10 @@ impl Default for Notification { Eq, PartialOrd, Ord, - derive_more::AsRef, serde::Deserialize, derive_more::Constructor, )] pub struct OutboundWebhook { url: String, + secret: String, } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 45b6e57..6a77012 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -468,10 +468,10 @@ mod server { let http = &server_config.http()?; let http_addr = http.ip(); let http_port = server_config.http()?.port(); - let webhook_url = server_config.webhook().base_url(); + let webhook_url = server_config.inbound_webhook().base_url(); let storage_path = server_config.storage().path(); let notification_webhook_url = server_config - .notifications() + .notification() .webhook_url() .unwrap_or_default(); let forge_alias = server_config diff --git a/crates/repo-actor/src/notifications.rs b/crates/repo-actor/src/notifications.rs index 0adbd84..b3bfd7d 100644 --- a/crates/repo-actor/src/notifications.rs +++ b/crates/repo-actor/src/notifications.rs @@ -4,10 +4,10 @@ use serde_json::json; use crate::messages::NotifyUser; -impl From for serde_json::Value { - fn from(value: NotifyUser) -> Self { - let timestamp = time::OffsetDateTime::now_utc().unix_timestamp().to_string(); - match value.deref() { +impl NotifyUser { + pub fn as_json(self, timestamp: time::OffsetDateTime) -> serde_json::Value { + let timestamp = timestamp.unix_timestamp().to_string(); + match self.deref() { UserNotification::CICheckFailed { forge_alias, repo_alias, diff --git a/crates/server-actor/Cargo.toml b/crates/server-actor/Cargo.toml index 924e824..4a247e5 100644 --- a/crates/server-actor/Cargo.toml +++ b/crates/server-actor/Cargo.toml @@ -31,6 +31,10 @@ actix = { workspace = true } # Webhooks serde = { workspace = true } serde_json = { workspace = true } +ulid = { workspace = true } +time = { workspace = true } +secrecy = { workspace = true } +standardwebhooks = { workspace = true } [dev-dependencies] # Testing diff --git a/crates/server-actor/src/handlers/notify_user.rs b/crates/server-actor/src/handlers/notify_user.rs index ac62591..a7a82fb 100644 --- a/crates/server-actor/src/handlers/notify_user.rs +++ b/crates/server-actor/src/handlers/notify_user.rs @@ -1,6 +1,7 @@ // use actix::prelude::*; +use git_next_config::server::NotificationType; use git_next_repo_actor::messages::NotifyUser; use crate::ServerActor; @@ -9,9 +10,31 @@ impl Handler for ServerActor { type Result = (); fn handle(&mut self, msg: NotifyUser, _ctx: &mut Self::Context) -> Self::Result { - let _payload = serde_json::Value::from(msg); - tracing::info!("{}", _payload.to_string()); - // TODO: (#95) should notify user - // send post to notification webhook url + let Some(server_config) = &self.server_config else { + return; + }; + let notification_type = server_config.notification().r#type(); + match notification_type { + NotificationType::None => { /* do nothing */ } + NotificationType::Webhook => { + let message_id = format!("msg_{}", ulid::Ulid::new()); + let timestamp = time::OffsetDateTime::now_utc(); //.unix_timestamp().to_string(); + let payload = msg.as_json(timestamp).to_string(); + let timestamp = timestamp.unix_timestamp(); + let to_sign = format!("{message_id}.{timestamp}.{payload}"); + tracing::info!(?to_sign, ""); + let Some(webhook) = self.webhook.as_ref() else { + tracing::warn!("Invalid notification configuration - can't sent notification"); + return; + }; + #[allow(clippy::expect_used)] + let signature = webhook + .sign(&message_id, timestamp, payload.as_ref()) + .expect("signature"); + tracing::info!(?signature, ""); + // TODO: (#95) should notify user + // send post to notification webhook url + } + } } } diff --git a/crates/server-actor/src/handlers/receive_server_config.rs b/crates/server-actor/src/handlers/receive_server_config.rs index dbf26e6..5465d8c 100644 --- a/crates/server-actor/src/handlers/receive_server_config.rs +++ b/crates/server-actor/src/handlers/receive_server_config.rs @@ -21,7 +21,7 @@ impl Handler for ServerActor { return; }; - if msg.webhook().base_url().ends_with('/') { + if msg.inbound_webhook().base_url().ends_with('/') { tracing::error!("webhook.url must not end with a '/'"); return; } diff --git a/crates/server-actor/src/handlers/receive_valid_server_config.rs b/crates/server-actor/src/handlers/receive_valid_server_config.rs index 990930e..4a8a32e 100644 --- a/crates/server-actor/src/handlers/receive_valid_server_config.rs +++ b/crates/server-actor/src/handlers/receive_valid_server_config.rs @@ -1,5 +1,7 @@ use actix::prelude::*; +use git_next_config::server::NotificationType; use git_next_webhook_actor::{AddWebhookRecipient, ShutdownWebhook, WebhookActor, WebhookRouter}; +use standardwebhooks::Webhook; use crate::{ messages::{ReceiveValidServerConfig, ValidServerConfig}, @@ -15,14 +17,35 @@ impl Handler for ServerActor { socket_address, server_storage, } = msg.unwrap(); - if let Some(webhook) = self.webhook_actor_addr.take() { - webhook.do_send(ShutdownWebhook); + // shutdown any existing webhook actor + if let Some(webhook_actor_addr) = self.webhook_actor_addr.take() { + webhook_actor_addr.do_send(ShutdownWebhook); + } + match server_config.notification().r#type() { + NotificationType::None => { /* do nothing */ } + NotificationType::Webhook => { + // Create webhook signer + use secrecy::ExposeSecret; + let webhook = server_config + .notification() + .webhook_secret() + .map(|secret| Webhook::new(secret.expose_secret())) + .transpose() + .map_err(|e| { + tracing::error!( + "Invalid notification webhook secret (will not send notifications): {e}" + ) + }) + .ok() + .flatten(); + self.webhook = webhook; + } } self.generation.inc(); // Webhook Server tracing::info!("Starting Webhook Server..."); let webhook_router = WebhookRouter::default().start(); - let webhook = server_config.webhook(); + let inbound_webhook = server_config.inbound_webhook(); // Forge Actors for (forge_alias, forge_config) in server_config.forges() { let repo_actors = self @@ -30,7 +53,7 @@ impl Handler for ServerActor { forge_config, forge_alias.clone(), &server_storage, - webhook, + inbound_webhook, ctx.address().recipient(), ) .into_iter() @@ -51,7 +74,9 @@ impl Handler for ServerActor { .insert((forge_alias.clone(), repo_alias), addr); }); } - let webhook = WebhookActor::new(socket_address, webhook_router.recipient()).start(); - self.webhook_actor_addr.replace(webhook); + let webhook_actor_addr = + WebhookActor::new(socket_address, webhook_router.recipient()).start(); + self.webhook_actor_addr.replace(webhook_actor_addr); + self.server_config.replace(server_config); } } diff --git a/crates/server-actor/src/lib.rs b/crates/server-actor/src/lib.rs index b544304..d55b7df 100644 --- a/crates/server-actor/src/lib.rs +++ b/crates/server-actor/src/lib.rs @@ -14,6 +14,7 @@ use git_next_repo_actor::messages::NotifyUser; use git_next_repo_actor::{messages::CloneRepo, RepoActor}; use git_next_webhook_actor as webhook; use kxio::{fs::FileSystem, network::Network}; +use standardwebhooks::Webhook; use std::{ collections::BTreeMap, path::PathBuf, @@ -45,6 +46,7 @@ type Result = core::result::Result; #[derive(derive_with::With)] #[with(message_log)] pub struct ServerActor { + server_config: Option, generation: Generation, webhook_actor_addr: Option>, fs: FileSystem, @@ -52,6 +54,7 @@ pub struct ServerActor { repository_factory: Box, sleep_duration: std::time::Duration, repo_actors: BTreeMap<(ForgeAlias, RepoAlias), Addr>, + webhook: Option, // testing message_log: Option>>>, @@ -69,6 +72,7 @@ impl ServerActor { ) -> Self { let generation = Generation::default(); Self { + server_config: None, generation, webhook_actor_addr: None, fs, @@ -76,6 +80,7 @@ impl ServerActor { repository_factory: repo, sleep_duration, repo_actors: BTreeMap::new(), + webhook: None, message_log: None, } }