git-next/crates/repo-actor/src/webhook.rs
Paul Campbell db9b4220ee
All checks were successful
Rust / build (push) Successful in 2m1s
ci/woodpecker/push/push-next Pipeline was successful
ci/woodpecker/push/cron-docker-builder Pipeline was successful
ci/woodpecker/push/tag-created Pipeline was successful
refactor: extract repo-actor and gitforge crates
2024-05-22 19:57:48 +01:00

334 lines
11 KiB
Rust

use actix::prelude::*;
use git_next_config::{
server::{Webhook, WebhookUrl},
BranchName, RepoAlias, RepoBranches,
};
use git_next_git::{self as git, RepoDetails};
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<Self, DecodeError> {
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: 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: RepoDetails,
webhook: Webhook,
addr: actix::prelude::Addr<super::RepoActor>,
net: network::Network,
) {
let Some(repo_config) = repo_details.repo_config.clone() else {
return;
};
let webhook_url = webhook.url();
// 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 repo_alias = &repo_details.repo_alias;
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": format!("{}/{}", webhook_url.as_ref(), repo_alias),
},
"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::<Hook>(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: &RepoDetails,
webhook_url: &WebhookUrl,
net: &network::Network,
) -> Vec<WebhookId> {
let mut ids: Vec<WebhookId> = 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::<Vec<Hook>>(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.starts_with(webhook_url.as_ref()) {
ids.push(hook.id());
}
}
}
}
}
page += 1;
}
}
#[derive(Debug, serde::Deserialize)]
struct Hook {
id: i64,
config: HashMap<String, String>,
}
impl Hook {
fn id(&self) -> WebhookId {
WebhookId(format!("{}", self.id))
}
}
impl Handler<WebhookMessage> 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.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::<Push>(body.as_str()) {
Err(err) => warn!(?err, ?body, "Not a 'push'"),
Ok(push) => {
if let Some(config) = &self.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<Branch> {
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 // TODO: (#58) differentiate between multiple forges
repo_alias: RepoAlias,
authorisation: WebhookAuth,
body: Body,
}
impl WebhookMessage {
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()
}
}