From eb298a13be7cbb959fce0aa98a8d0a9de317df56 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Sun, 21 Jul 2024 13:44:44 +0100 Subject: [PATCH] feat: post webhook notifications to user Closes kemitix/git-next#91 --- Cargo.toml | 2 + crates/config/src/branch_name.rs | 4 +- crates/config/src/forge_alias.rs | 4 +- crates/config/src/repo_alias.rs | 3 +- crates/config/src/server.rs | 6 +- crates/config/src/tests.rs | 2 +- crates/git/Cargo.toml | 6 +- crates/git/src/commit.rs | 6 +- crates/repo-actor/Cargo.toml | 1 + crates/repo-actor/src/lib.rs | 1 + crates/repo-actor/src/notifications.rs | 72 ++++++++++++++++++ crates/server-actor/Cargo.toml | 8 ++ .../server-actor/src/handlers/notify_user.rs | 73 ++++++++++++++++++- .../handlers/receive_valid_server_config.rs | 5 +- crates/server-actor/src/handlers/shutdown.rs | 2 +- crates/server-actor/src/lib.rs | 4 +- 16 files changed, 182 insertions(+), 17 deletions(-) create mode 100644 crates/repo-actor/src/notifications.rs diff --git a/Cargo.toml b/Cargo.toml index 940ed14..dd0828f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,8 @@ git-conventional = "0.12" 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/branch_name.rs b/crates/config/src/branch_name.rs index 2d4ac67..192e740 100644 --- a/crates/config/src/branch_name.rs +++ b/crates/config/src/branch_name.rs @@ -1 +1,3 @@ -crate::newtype!(BranchName: String, derive_more::Display, Default: "The name of a Git branch"); +use serde::Serialize; + +crate::newtype!(BranchName: String, derive_more::Display, Default, Serialize: "The name of a Git branch"); diff --git a/crates/config/src/forge_alias.rs b/crates/config/src/forge_alias.rs index a665b6f..f27a7a8 100644 --- a/crates/config/src/forge_alias.rs +++ b/crates/config/src/forge_alias.rs @@ -1,4 +1,6 @@ -crate::newtype!(ForgeAlias: String, Hash, PartialOrd, Ord, derive_more::Display, Default: "The name of a Forge to connect to"); +use serde::Serialize; + +crate::newtype!(ForgeAlias: String, Hash, PartialOrd, Ord, derive_more::Display, Default, Serialize: "The name of a Forge to connect to"); impl From<&ForgeAlias> for std::path::PathBuf { fn from(value: &ForgeAlias) -> Self { Self::from(&value.0) diff --git a/crates/config/src/repo_alias.rs b/crates/config/src/repo_alias.rs index bd42984..7cedbf5 100644 --- a/crates/config/src/repo_alias.rs +++ b/crates/config/src/repo_alias.rs @@ -1,7 +1,8 @@ use derive_more::Display; +use serde::Serialize; use crate::newtype; -newtype!(RepoAlias: String, Display, Default, PartialOrd, Ord: r#"The alias of a repo. +newtype!(RepoAlias: String, Display, Default, PartialOrd, Ord, Serialize: r#"The alias of a repo. This is the alias for the repo within `git-next-server.toml`."#); diff --git a/crates/config/src/server.rs b/crates/config/src/server.rs index e02ba71..199fd43 100644 --- a/crates/config/src/server.rs +++ b/crates/config/src/server.rs @@ -199,7 +199,7 @@ impl Notification { webhook: None, } } - pub const fn webhook(webhook: OutboundWebhook) -> Self { + pub const fn new_webhook(webhook: OutboundWebhook) -> Self { Self { r#type: NotificationType::Webhook, webhook: Some(webhook), @@ -210,6 +210,10 @@ impl Notification { self.r#type } + pub const fn webhook(&self) -> Option<&OutboundWebhook> { + self.webhook.as_ref() + } + pub fn webhook_url(&self) -> Option { self.webhook.clone().map(|x| x.url) } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 5e81ee6..cecc8b5 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -753,7 +753,7 @@ mod given { ServerStorage::new(a_name().into()) } pub fn a_notification_config() -> Notification { - Notification::webhook(an_outbound_webhook()) + Notification::new_webhook(an_outbound_webhook()) } pub fn some_forge_configs() -> BTreeMap { [(a_name(), a_forge_config())].into() diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 48d9666..e60d092 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -25,11 +25,13 @@ async-trait = { workspace = true } # fs/network kxio = { workspace = true } -# # TOML parsing -# serde = { workspace = true } +# TOML parsing serde_json = { workspace = true } # toml = { workspace = true } +# webhooks - user notification +serde = { workspace = true } + # Secrets and Password secrecy = { workspace = true } diff --git a/crates/git/src/commit.rs b/crates/git/src/commit.rs index 045d223..443a780 100644 --- a/crates/git/src/commit.rs +++ b/crates/git/src/commit.rs @@ -2,6 +2,7 @@ use config::newtype; use derive_more::Display; // use git_next_config as config; +use serde::Serialize; #[derive( Clone, @@ -13,6 +14,7 @@ use git_next_config as config; Ord, derive_more::Constructor, derive_more::Display, + Serialize, )] #[display("{}", sha)] pub struct Commit { @@ -37,8 +39,8 @@ impl From for Commit { } } -newtype!(Sha: String, Display, Hash,PartialOrd, Ord: "The unique SHA for a git commit."); -newtype!(Message: String, Hash, PartialOrd, Ord: "The commit message for a git commit."); +newtype!(Sha: String, Display, Hash,PartialOrd, Ord, Serialize: "The unique SHA for a git commit."); +newtype!(Message: String, Display, Hash, PartialOrd, Ord, Serialize: "The commit message for a git commit."); #[derive(Clone, Debug)] pub struct Histories { diff --git a/crates/repo-actor/Cargo.toml b/crates/repo-actor/Cargo.toml index 11e7418..45fdba0 100644 --- a/crates/repo-actor/Cargo.toml +++ b/crates/repo-actor/Cargo.toml @@ -43,6 +43,7 @@ git-conventional = { workspace = true } # Webhooks bytes = { workspace = true } ulid = { workspace = true } +time = { workspace = true } # boilerplate derive_more = { workspace = true } diff --git a/crates/repo-actor/src/lib.rs b/crates/repo-actor/src/lib.rs index 73c2bfc..37767d7 100644 --- a/crates/repo-actor/src/lib.rs +++ b/crates/repo-actor/src/lib.rs @@ -2,6 +2,7 @@ mod branch; pub mod handlers; mod load; pub mod messages; +mod notifications; #[cfg(test)] mod tests; diff --git a/crates/repo-actor/src/notifications.rs b/crates/repo-actor/src/notifications.rs new file mode 100644 index 0000000..b3bfd7d --- /dev/null +++ b/crates/repo-actor/src/notifications.rs @@ -0,0 +1,72 @@ +use derive_more::Deref as _; +use git_next_git::UserNotification; +use serde_json::json; + +use crate::messages::NotifyUser; + +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, + commit, + } => json!({ + "type": "cicheck.failed", + "timestamp": timestamp, + "data": { + "forge_alias": forge_alias, + "repo_alias": repo_alias, + "commit": { + "sha": commit.sha(), + "message": commit.message() + } + } + }), + UserNotification::RepoConfigLoadFailure { + forge_alias, + repo_alias, + reason, + } => json!({ + "type": "config.load.failed", + "timestamp": timestamp, + "data": { + "forge_alias": forge_alias, + "repo_alias": repo_alias, + "reason": reason + } + }), + UserNotification::WebhookRegistration { + forge_alias, + repo_alias, + reason, + } => json!({ + "type": "webhook.registration.failed", + "timestamp": timestamp, + "data": { + "forge_alias": forge_alias, + "repo_alias": repo_alias, + "reason": reason + } + }), + UserNotification::DevNotBasedOnMain { + forge_alias, + repo_alias, + dev_branch, + main_branch, + } => json!({ + "type": "branch.dev.not-on-main", + "timestamp": timestamp, + "data": { + "forge_alias": forge_alias, + "repo_alias": repo_alias, + "branches": { + "dev": dev_branch, + "main": main_branch + } + } + }), + } + } +} diff --git a/crates/server-actor/Cargo.toml b/crates/server-actor/Cargo.toml index e0a42eb..4a247e5 100644 --- a/crates/server-actor/Cargo.toml +++ b/crates/server-actor/Cargo.toml @@ -28,6 +28,14 @@ derive-with = { workspace = true } # Actors 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 # assert2 = { workspace = true } diff --git a/crates/server-actor/src/handlers/notify_user.rs b/crates/server-actor/src/handlers/notify_user.rs index 23ee70f..118cd42 100644 --- a/crates/server-actor/src/handlers/notify_user.rs +++ b/crates/server-actor/src/handlers/notify_user.rs @@ -1,14 +1,81 @@ // use actix::prelude::*; +use git_next_config::server::{Notification, NotificationType}; use git_next_repo_actor::messages::NotifyUser; +use secrecy::ExposeSecret; +use standardwebhooks::Webhook; +use tracing::Instrument; use crate::ServerActor; impl Handler for ServerActor { type Result = (); - fn handle(&mut self, _msg: NotifyUser, _ctx: &mut Self::Context) -> Self::Result { - // TODO: (#95) should notify user - // send post to notification webhook url + fn handle(&mut self, msg: NotifyUser, ctx: &mut Self::Context) -> Self::Result { + let Some(server_config) = &self.server_config else { + return; + }; + let notification_config = server_config.notification().clone(); + let net = self.net.clone(); + async move { + match notification_config.r#type() { + NotificationType::None => { /* do nothing */ } + NotificationType::Webhook => send_webhook(msg, notification_config, net).await, + } + } + .in_current_span() + .into_actor(self) + .wait(ctx); } } + +async fn send_webhook( + msg: NotifyUser, + notification_config: Notification, + net: kxio::network::Network, +) { + let Some(webhook_config) = notification_config.webhook() else { + tracing::warn!("Invalid notification configuration (config) - can't sent notification"); + return; + }; + let Ok(webhook) = Webhook::new(webhook_config.secret().expose_secret()) else { + tracing::warn!("Invalid notification configuration (signer) - can't sent notification"); + return; + }; + do_send_webhook(msg, webhook, webhook_config, net).await; +} + +async fn do_send_webhook( + msg: NotifyUser, + webhook: Webhook, + webhook_config: &git_next_config::server::OutboundWebhook, + net: kxio::network::Network, +) { + let message_id = format!("msg_{}", ulid::Ulid::new()); + let timestamp = time::OffsetDateTime::now_utc(); + let payload = msg.as_json(timestamp); + let timestamp = timestamp.unix_timestamp(); + let to_sign = format!("{message_id}.{timestamp}.{payload}"); + tracing::info!(?to_sign, ""); + #[allow(clippy::expect_used)] + let signature = webhook + .sign(&message_id, timestamp, payload.to_string().as_ref()) + .expect("signature"); + tracing::info!(?signature, ""); + let url = webhook_config.url(); + use kxio::network::{NetRequest, NetUrl, RequestBody, ResponseType}; + let net_url = NetUrl::new(url.to_string()); + let request = NetRequest::post(net_url) + .body(RequestBody::Json(payload)) + .header("webhook-id", &message_id) + .header("webhook-timestamp", ×tamp.to_string()) + .header("webhook-signature", &signature) + .response_type(ResponseType::None) + .build(); + net.post_json::<()>(request).await.map_or_else( + |err| { + tracing::warn!(?err, "sending webhook"); + }, + |_| (), + ); +} 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 8f9d277..f2fd8b4 100644 --- a/crates/server-actor/src/handlers/receive_valid_server_config.rs +++ b/crates/server-actor/src/handlers/receive_valid_server_config.rs @@ -52,8 +52,9 @@ impl Handler for ServerActor { .insert((forge_alias.clone(), repo_alias), addr); }); } - let webhook = WebhookActor::new(socket_address, webhook_router.recipient()).start(); + 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); - self.webhook.replace(webhook); } } diff --git a/crates/server-actor/src/handlers/shutdown.rs b/crates/server-actor/src/handlers/shutdown.rs index f046037..c421149 100644 --- a/crates/server-actor/src/handlers/shutdown.rs +++ b/crates/server-actor/src/handlers/shutdown.rs @@ -16,7 +16,7 @@ impl Handler for ServerActor { tracing::debug!(%forge_alias, %repo_alias, "removed webhook"); }); tracing::debug!("server shutdown"); - if let Some(webhook) = self.webhook.take() { + if let Some(webhook) = self.webhook_actor_addr.take() { tracing::debug!("shuting down webhook"); webhook.do_send(ShutdownWebhook); tracing::debug!("webhook shutdown"); diff --git a/crates/server-actor/src/lib.rs b/crates/server-actor/src/lib.rs index 7d743bd..2bca343 100644 --- a/crates/server-actor/src/lib.rs +++ b/crates/server-actor/src/lib.rs @@ -47,7 +47,7 @@ type Result = core::result::Result; pub struct ServerActor { server_config: Option, generation: Generation, - webhook: Option>, + webhook_actor_addr: Option>, fs: FileSystem, net: Network, repository_factory: Box, @@ -72,7 +72,7 @@ impl ServerActor { Self { server_config: None, generation, - webhook: None, + webhook_actor_addr: None, fs, net, repository_factory: repo,