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 4a2b541..8ea97ab 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 dfdb683..e69b61e 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -751,7 +751,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..f8d4884 100644 --- a/crates/server-actor/src/handlers/notify_user.rs +++ b/crates/server-actor/src/handlers/notify_user.rs @@ -1,5 +1,6 @@ // use actix::prelude::*; +use git_next_config::server::NotificationType; use git_next_repo_actor::messages::NotifyUser; use crate::ServerActor; @@ -7,8 +8,32 @@ 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 = &server_config.notification(); + match notification.r#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) = notification.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_valid_server_config.rs b/crates/server-actor/src/handlers/receive_valid_server_config.rs index e743ddd..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,8 +17,29 @@ impl Handler for ServerActor { socket_address, server_storage, } = msg.unwrap(); - if let Some(webhook) = self.webhook.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 @@ -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.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/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 131a3cc..88b549f 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, @@ -71,7 +71,7 @@ impl ServerActor { let generation = Generation::default(); Self { generation, - webhook: None, + webhook_actor_addr: None, fs, net, repository_factory: repo,