From 9f41c85ce61e2774243503ce2a88f934fa2ea491 Mon Sep 17 00:00:00 2001 From: Jerboa-app Date: Sat, 1 Jun 2024 12:17:29 +0100 Subject: [PATCH] Adds github webhook integration When push event is recieved, pulls requires github token --- src/config.rs | 3 + src/integrations/git/mod.rs | 50 ++++++++++++++-- src/integrations/git/refresh.rs | 62 ++++++++++++++++---- src/integrations/github/mod.rs | 100 ++++++++++++++++++++++++++++++++ src/integrations/mod.rs | 1 + tests/test_git.rs | 53 ++++++++++++++--- 6 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 src/integrations/github/mod.rs diff --git a/src/config.rs b/src/config.rs index 6ec7bde..e61e53f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -128,11 +128,14 @@ pub struct GitAuthConfig /// - ```remote```: the url (public or private) /// - ```branch```: the tracked branch /// - ```auth```: if present either ssh key or passphrase will be used +/// - ```checkout_schedule```: schedule for checking for new commits on [GitConfig::branch] +/// - ```remote_webhook_token```: optional webhook token to recieve push events #[derive(Clone, Serialize, Deserialize)] pub struct GitConfig { pub remote: String, pub branch: String, + pub remote_webhook_token: Option, pub checkout_schedule: Option, pub auth: Option } diff --git a/src/integrations/git/mod.rs b/src/integrations/git/mod.rs index 3adee19..6fc1e44 100644 --- a/src/integrations/git/mod.rs +++ b/src/integrations/git/mod.rs @@ -44,7 +44,7 @@ impl From for GitError /// Attempt to clone a remote repo from a [crate::config::GitConfig] pub fn from_clone(path: &str, config: &GitConfig) -> Result { - if let GitConfig{auth: Some(_), remote: _, checkout_schedule: _, branch: _} = config + if let GitConfig{auth: Some(_), remote: _, checkout_schedule: _, branch: _, remote_webhook_token: _} = config { let auth = config.auth.clone().unwrap(); let result = match &auth.key_path @@ -87,7 +87,7 @@ pub fn from_clone(path: &str, config: &GitConfig) -> Result Ok(repo), Err(e) => { - crate::debug(format!("Error {} while cloning (authenticated) repo at {}", e, config.remote), None); + crate::debug(format!("Error {} while cloning (authenticated) repo at {}", e, config.remote), Some("GIT")); Err(GitError::from(e)) } } @@ -99,7 +99,7 @@ pub fn from_clone(path: &str, config: &GitConfig) -> Result Ok(repo), Err(e) => { - crate::debug(format!("Error {} while cloning (pub) repo at {}", e, config.remote), None); + crate::debug(format!("Error {} while cloning (pub) repo at {}", e, config.remote), Some("GIT")); Err(GitError::from(e)) } } @@ -139,7 +139,7 @@ pub fn clean_and_clone(dir: &str, config: GitConfig) -> Result Result<(), GitError> +pub fn fast_forward_pull(repo: Repository, branch: &str) -> Result, GitError> { // modified from https://stackoverflow.com/questions/58768910/how-to-perform-git-pull-with-the-rust-git2-crate repo.find_remote("origin")?.fetch(&[branch], None, None)?; @@ -150,7 +150,7 @@ pub fn fast_forward_pull(repo: Repository, branch: &str) -> Result<(), GitError> if analysis.is_up_to_date() { - Ok(()) + Ok(None) } else if analysis.is_fast_forward() { @@ -158,10 +158,48 @@ pub fn fast_forward_pull(repo: Repository, branch: &str) -> Result<(), GitError> let mut reference = repo.find_reference(&refname)?; reference.set_target(fetch_commit.id(), "Fast-Forward")?; repo.set_head(&refname)?; - Ok(repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?) + repo.checkout_head(Some(git2::build::CheckoutBuilder::default().force()))?; + Ok(head_info(&repo)) } else { Err(GitError{why: "Cannot fastforward".to_owned()}) } +} + +pub struct HeadInfo +{ + pub hash: git2::Oid, + pub author: String, + pub datetime: String +} + +pub fn head_info(repo: &Repository) -> Option +{ + let head = match repo.head() + { + Ok(h) => match h.target() + { + Some(h) => h, + None => return None + }, + Err(_) => return None + }; + + match repo.find_commit(head) + { + Ok(c) => + { + Some + ( + HeadInfo + { + hash: c.id(), + author: c.author().to_string(), + datetime: format!("{:?}", c.time()) + } + ) + }, + Err(_) => None + } } \ No newline at end of file diff --git a/src/integrations/git/refresh.rs b/src/integrations/git/refresh.rs index 398ce25..ec0df28 100644 --- a/src/integrations/git/refresh.rs +++ b/src/integrations/git/refresh.rs @@ -4,11 +4,12 @@ use axum::async_trait; use chrono::{DateTime, Utc}; use cron::Schedule; use git2::Repository; + use tokio::sync::Mutex; -use crate::{config::{Config, CONFIG_PATH}, task::{next_job_time, schedule_from_option, Task}}; +use crate::{config::{Config, CONFIG_PATH}, integrations::discord::post::try_post, task::{next_job_time, schedule_from_option, Task}}; -use super::{clean_and_clone, fast_forward_pull}; +use super::{clean_and_clone, fast_forward_pull, HeadInfo}; pub struct GitRefreshTask { @@ -34,18 +35,13 @@ impl GitRefreshTask schedule } } -} -#[async_trait] -impl Task for GitRefreshTask -{ - async fn run(&mut self) -> Result<(), crate::task::TaskError> + pub fn pull(config: &Config) -> Option { - let _ = self.lock.lock().await; - let config = Config::load_or_default(CONFIG_PATH); + if config.git.is_some() { - let git = config.git.unwrap(); + let git = config.git.clone().unwrap(); let path = Path::new(&config.content.path); if path.is_dir() { @@ -54,7 +50,7 @@ impl Task for GitRefreshTask Ok(repo) => fast_forward_pull(repo, &git.branch), Err(e) => { - crate::debug(format!("{}, {:?} is not a git repo", e, path), None); + crate::debug(format!("{}, {:?} is not a git repo", e, path), Some("GIT")); match clean_and_clone(&config.content.path, git.clone()) { Ok(repo) => fast_forward_pull(repo, &git.branch), @@ -65,10 +61,52 @@ impl Task for GitRefreshTask if result.is_err() { - crate::debug(format!("{:?}", result.err()), None); + crate::debug(format!("{:?}", result.err()), Some("GIT")); + } + else + { + return result.unwrap() } } } + None + } + + pub async fn notify_pull(info: Option, config: &Config) + { + match info + { + Some(info) => + { + let msg = format! + ( + "Checked out new commit for {}:\n {}\n {}\n {}", + config.domain, + info.hash, + info.author, + info.datetime + ); + crate::debug(msg.clone(), Some("GIT")); + try_post + ( + config.notification_endpoint.clone(), + &msg + ).await; + }, + None => {} + } + + } +} + +#[async_trait] +impl Task for GitRefreshTask +{ + async fn run(&mut self) -> Result<(), crate::task::TaskError> + { + let _ = self.lock.lock().await; + let config = Config::load_or_default(CONFIG_PATH); + GitRefreshTask::notify_pull(GitRefreshTask::pull(&config), &config).await; self.schedule = schedule_from_option(config.stats.save_schedule.clone()); diff --git a/src/integrations/github/mod.rs b/src/integrations/github/mod.rs new file mode 100644 index 0000000..576414a --- /dev/null +++ b/src/integrations/github/mod.rs @@ -0,0 +1,100 @@ +use axum::{body::Bytes, http::{HeaderMap, Request}, middleware::Next, response::{IntoResponse, Response}}; +use regex::Regex; +use reqwest::StatusCode; + +use crate::config::{Config, CONFIG_PATH}; + +use super::git::refresh::GitRefreshTask; + +pub async fn filter_github +( + headers: HeaderMap, + request: Request, + next: Next +) -> Result +where B: axum::body::HttpBody +{ + let user_agent = match std::str::from_utf8(headers["user-agent"].as_bytes()) + { + Ok(u) => u, + Err(_) => + { + return Ok(next.run(request).await) + } + }; + + if Regex::new(r"GitHub-Hookshot").unwrap().captures(user_agent).is_some() + { + + let authentic = is_authentic(&headers, request).await; + if authentic != StatusCode::ACCEPTED + { + return Ok(authentic.into_response()); + } + + crate::debug("Authentic github event".to_string(), Some("GITHUB")); + + if !headers.contains_key("x-github-event") + { + return Ok(StatusCode::BAD_REQUEST.into_response()); + } + + match std::str::from_utf8(headers["x-github-event"].as_bytes()) + { + Ok(s) => + { + if s.to_lowercase() == "push" + { + let config = Config::load_or_default(CONFIG_PATH); + GitRefreshTask::notify_pull(GitRefreshTask::pull(&config), &config).await; + } + }, + Err(e) => + { + crate::debug(format!("Invalid utf8 in x-github-event, {}", e), Some("GITHUB")); + return Ok(StatusCode::BAD_REQUEST.into_response()); + } + } + + return Ok(StatusCode::ACCEPTED.into_response()); + } + else + { + return Ok(next.run(request).await) + } +} + +async fn is_authentic(headers: &HeaderMap, request: Request) -> StatusCode +where B: axum::body::HttpBody +{ + let config = Config::load_or_default(CONFIG_PATH); + + let token = if config.git.is_some() + { + config.git.unwrap().remote_webhook_token + } + else + { + None + }; + + if token.is_none() + { + return StatusCode::METHOD_NOT_ALLOWED; + } + + let body = request.into_body(); + let bytes = match body.collect().await { + Ok(collected) => collected.to_bytes(), + Err(_) => { + return StatusCode::BAD_REQUEST + } + }; + + super::is_authentic + ( + &headers, "x-hub-signature-256", + token.unwrap(), + &bytes + ) +} \ No newline at end of file diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index ae1be42..24ffe36 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -9,6 +9,7 @@ use regex::Regex; use crate::util::{read_bytes, dump_bytes}; pub mod discord; +pub mod github; pub mod git; pub mod webhook; diff --git a/tests/test_git.rs b/tests/test_git.rs index 1cc3d69..70226fd 100644 --- a/tests/test_git.rs +++ b/tests/test_git.rs @@ -3,9 +3,9 @@ mod common; #[cfg(test)] mod git { - use std::{fs::remove_dir_all, path::Path}; + use std::path::Path; - use busser::{config::{GitAuthConfig, GitConfig}, integrations::git::{clean_and_clone, fast_forward_pull, from_clone}}; + use busser::{config::{Config, GitAuthConfig, GitConfig}, integrations::git::{clean_and_clone, fast_forward_pull, from_clone, refresh::GitRefreshTask, remove_repository}}; #[test] pub fn test_clone() @@ -16,6 +16,7 @@ mod git branch: "main".into(), checkout_schedule: None, auth: None, + remote_webhook_token: None }; let path = "tests/test_clone"; @@ -27,7 +28,7 @@ mod git if Path::exists(Path::new(path)) { - let _ = remove_dir_all(Path::new(path)); + let _ = remove_repository(&path); } } @@ -40,6 +41,7 @@ mod git branch: "main".into(), checkout_schedule: None, auth: None, + remote_webhook_token: None }; let path = "tests/test_clean_and_clone"; @@ -61,7 +63,7 @@ mod git if Path::exists(Path::new(path)) { - let _ = remove_dir_all(Path::new(path)); + let _ = remove_repository(&path); } } @@ -73,6 +75,7 @@ mod git remote: "https://github.com/JerboaBurrow/test".into(), branch: "main".into(), checkout_schedule: None, + remote_webhook_token: None, auth: Some(GitAuthConfig { key_path: Some("not_a_key".into()), @@ -92,7 +95,7 @@ mod git if Path::exists(Path::new(path)) { - let _ = remove_dir_all(Path::new(path)); + let _ = remove_repository(&path); } } @@ -104,6 +107,7 @@ mod git remote: "https://github.com/JerboaBurrow/test".into(), branch: "main".into(), checkout_schedule: None, + remote_webhook_token: None, auth: Some(GitAuthConfig { key_path: None, @@ -119,22 +123,23 @@ mod git if Path::exists(Path::new(path)) { - let _ = remove_dir_all(Path::new(path)); + let _ = remove_repository(&path); } } #[test] - pub fn test_pull() + pub fn test_fast_forward_pull() { let config = GitConfig { remote: "https://github.com/JerboaBurrow/Busser".into(), branch: "main".into(), checkout_schedule: None, + remote_webhook_token: None, auth: None, }; - let path = "tests/test_pull"; + let path = "tests/test_fast_forward_pull"; let repo = from_clone(path.into(), &config); assert!(repo.is_ok()); @@ -145,7 +150,37 @@ mod git if Path::exists(Path::new(path)) { - let _ = remove_dir_all(Path::new(path)); + let _ = remove_repository(&path); + } + } + + #[test] + pub fn test_pull() + { + let git_config = GitConfig + { + remote: "https://github.com/JerboaBurrow/Busser".into(), + branch: "main".into(), + checkout_schedule: None, + remote_webhook_token: None, + auth: None, + }; + + let mut config = Config::default(); + config.git = Some(git_config); + + let path = "tests/test_pull"; + config.content.path = path.to_owned(); + + std::fs::create_dir(path).unwrap(); + GitRefreshTask::pull(&config); + + assert!(Path::exists(Path::new(path))); + + if Path::exists(Path::new(path)) + { + let _ = remove_repository(&path); } + } } \ No newline at end of file