use actix::prelude::*; use git_next_config::{ server::{Webhook, WebhookUrl}, BranchName, ForgeAlias, RepoAlias, RepoBranches, }; use git_next_git as git; use kxio::network::{self, json}; use tracing::{info, warn}; use ulid::DecodeError; use std::{collections::HashMap, str::FromStr}; use crate::{RepoActor, ValidateRepo, WebhookRegistered}; #[derive( Clone, Debug, PartialEq, Eq, derive_more::Constructor, derive_more::Deref, derive_more::Display, )] pub struct WebhookId(String); #[derive(Clone, Debug, PartialEq, Eq, derive_more::Deref, derive_more::Display)] pub struct WebhookAuth(ulid::Ulid); impl WebhookAuth { pub fn new(authorisation: &str) -> Result { let id = ulid::Ulid::from_str(authorisation)?; info!("Parse auth token: {}", id); Ok(Self(id)) } fn generate() -> Self { Self(ulid::Ulid::new()) } fn header_value(&self) -> String { format!("Basic {}", self) } } #[tracing::instrument(skip_all, fields(%webhook_id))] pub async fn unregister( webhook_id: WebhookId, repo_details: git::RepoDetails, net: network::Network, ) { let hostname = &repo_details.forge.hostname(); let repo_path = repo_details.repo_path; use secrecy::ExposeSecret; let token = repo_details.forge.token().expose_secret(); let url = network::NetUrl::new(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks/{webhook_id}?token={token}" )); let request = network::NetRequest::new( network::RequestMethod::Delete, url, network::NetRequestHeaders::new(), network::RequestBody::None, network::ResponseType::None, None, network::NetRequestLogging::None, ); let result = net.delete(request).await; match result { Ok(_) => info!("unregistered webhook"), Err(err) => warn!(?err, "Failed to unregister webhook"), } } #[tracing::instrument(skip_all)] pub async fn register( repo_details: git::RepoDetails, webhook: Webhook, addr: actix::prelude::Addr, net: network::Network, ) { let Some(repo_config) = repo_details.repo_config.clone() else { return; }; let forge_alias = repo_details.forge.forge_alias(); let repo_alias = &repo_details.repo_alias; let webhook_url = webhook.url(forge_alias, repo_alias); // remove any lingering webhooks for the same URL let existing_webhook_ids = find_existing_webhooks(&repo_details, &webhook_url, &net).await; for webhook_id in existing_webhook_ids { unregister(webhook_id, repo_details.clone(), net.clone()).await; } let hostname = &repo_details.forge.hostname(); let repo_path = repo_details.repo_path; use secrecy::ExposeSecret; let token = repo_details.forge.token().expose_secret(); let url = network::NetUrl::new(format!( "https://{hostname}/api/v1/repos/{repo_path}/hooks?token={token}" )); let headers = network::NetRequestHeaders::new().with("Content-Type", "application/json"); let authorisation = WebhookAuth::generate(); let body = json!({ "active": true, "authorization_header": authorisation.header_value(), "branch_filter": format!("{{{},{},{}}}", repo_config.branches().main(), repo_config.branches().next(), repo_config.branches().dev()), "config": { "content_type": "json", "url": webhook_url.as_ref(), }, "events": [ "push" ], "type": "forgejo" }); let request = network::NetRequest::new( network::RequestMethod::Post, url, headers, network::RequestBody::Json(body), network::ResponseType::Json, None, network::NetRequestLogging::None, ); let result = net.post_json::(request).await; match result { Ok(response) => { if let Some(hook) = response.response_body() { info!(webhook_id = %hook.id, "Webhook registered"); addr.do_send(WebhookRegistered(hook.id(), authorisation)); } } Err(_) => warn!("Failed to register webhook"), } } async fn find_existing_webhooks( repo_details: &git::RepoDetails, webhook_url: &WebhookUrl, net: &network::Network, ) -> Vec { let mut ids: Vec = vec![]; let hostname = &repo_details.forge.hostname(); let repo_path = &repo_details.repo_path; let mut page = 1; loop { use secrecy::ExposeSecret; let token = &repo_details.forge.token().expose_secret(); let url = format!("https://{hostname}/api/v1/repos/{repo_path}/hooks?page={page}&token={token}"); let net_url = network::NetUrl::new(url); let request = network::NetRequest::new( network::RequestMethod::Get, net_url, network::NetRequestHeaders::new(), network::RequestBody::None, network::ResponseType::Json, None, network::NetRequestLogging::None, ); let result = net.get::>(request).await; if let Ok(response) = result { if let Some(list) = response.response_body() { if list.is_empty() { return ids; } for hook in list { if let Some(existing_url) = hook.config.get("url") { if existing_url == webhook_url.as_ref() { ids.push(hook.id()); } } } } } page += 1; } } #[derive(Debug, serde::Deserialize)] struct Hook { id: i64, config: HashMap, } impl Hook { fn id(&self) -> WebhookId { WebhookId(format!("{}", self.id)) } } impl Handler for RepoActor { type Result = (); #[allow(clippy::cognitive_complexity)] // TODO: (#49) reduce complexity #[tracing::instrument(name = "RepoActor::WebhookMessage", skip_all, fields(token = %self.message_token, repo = %self.repo_details))] fn handle(&mut self, msg: WebhookMessage, ctx: &mut Self::Context) -> Self::Result { let Some(expected_authorization) = &self.webhook_auth else { warn!("Don't know what authorization to expect"); return; }; if msg.authorisation() != expected_authorization { warn!( "Invalid authorization - expected {}", expected_authorization ); return; } let body = msg.body(); match serde_json::from_str::(body.as_str()) { Err(err) => warn!(?err, ?body, "Not a 'push'"), Ok(push) => { if let Some(config) = &self.repo_details.repo_config { match push.branch(config.branches()) { None => warn!( ?push, "Unrecognised branch, we should be filtering to only the ones we want" ), Some(branch) => { match branch { Branch::Main => { if self.last_main_commit == Some(push.commit()) { info!( branch = %config.branches().main(), commit = %push.commit(), "Ignoring - already aware of branch at commit", ); return; } self.last_main_commit.replace(push.commit()) } Branch::Next => { if self.last_next_commit == Some(push.commit()) { info!( branch = %config.branches().next(), commit = %push.commit(), "Ignoring - already aware of branch at commit", ); return; } self.last_next_commit.replace(push.commit()) } Branch::Dev => { if self.last_dev_commit == Some(push.commit()) { info!( branch = %config.branches().dev(), commit = %push.commit(), "Ignoring - already aware of branch at commit", ); return; } self.last_dev_commit.replace(push.commit()) } }; let message_token = self.message_token.next(); info!( token = %message_token, ?branch, commit = %push.commit(), "New commit" ); ctx.address().do_send(ValidateRepo { message_token }); } } } } } } } pub fn split_ref(reference: &str) -> (&str, &str) { reference.split_at(11) } #[derive(Debug, serde::Deserialize)] struct Push { #[serde(rename = "ref")] reference: String, after: String, head_commit: HeadCommit, } impl Push { pub fn branch(&self, repo_branches: &RepoBranches) -> Option { if !self.reference.starts_with("refs/heads/") { warn!(r#ref = self.reference, "Unexpected ref"); return None; } let (_, branch) = split_ref(&self.reference); let branch = BranchName::new(branch); if branch == repo_branches.main() { return Some(Branch::Main); } if branch == repo_branches.next() { return Some(Branch::Next); } if branch == repo_branches.dev() { return Some(Branch::Dev); } warn!(%branch, "Unexpected branch"); None } pub fn commit(&self) -> git::Commit { git::Commit::new( git::commit::Sha::new(self.after.clone()), git::commit::Message::new(self.head_commit.message.clone()), ) } } #[derive(Debug)] pub enum Branch { Main, Next, Dev, } #[derive(Debug, serde::Deserialize)] struct HeadCommit { message: String, } #[derive(Message, Debug, Clone, derive_more::Constructor)] #[rtype(result = "()")] pub struct WebhookMessage { forge_alias: ForgeAlias, repo_alias: RepoAlias, authorisation: WebhookAuth, body: Body, } impl WebhookMessage { pub const fn forge_alias(&self) -> &ForgeAlias { &self.forge_alias } pub const fn repo_alias(&self) -> &RepoAlias { &self.repo_alias } pub const fn body(&self) -> &Body { &self.body } pub const fn authorisation(&self) -> &WebhookAuth { &self.authorisation } } #[derive(Clone, Debug, derive_more::Constructor)] pub struct Body(String); impl Body { pub fn as_str(&self) -> &str { self.0.as_str() } }