sys/sysmod/http/
github.rs1use std::sync::Arc;
6
7use super::WebResult;
8use crate::taskserver::{self, Control};
9use actix_web::{HttpRequest, HttpResponse, Responder, http::header::ContentType, web};
10use anyhow::{Result, anyhow};
11use log::{error, info};
12use serde_json::Value;
13use utils::netutil;
14
15#[actix_web::get("/github/")]
16async fn index_get() -> impl Responder {
17 let body = r#"<!DOCTYPE html>
18<html lang="en">
19 <head>
20 <title>Github Webhook</title>
21 </head>
22 <body>
23 <h1>Github Webhook</h1>
24 <p>Your request is GET.</p>
25 </body>
26</html>
27"#;
28
29 HttpResponse::Ok()
30 .content_type(ContentType::html())
31 .body(body)
32}
33
34#[actix_web::post("/github/")]
36async fn index_post(req: HttpRequest, body: String, ctrl: web::Data<Control>) -> WebResult {
37 info!("POST github webhook");
38
39 let headers = req.headers();
40 let event = headers.get("X-GitHub-Event");
41 let delivery = headers.get("X-GitHub-Delivery");
42 let signature256 = headers.get("X-Hub-Signature-256");
43 if event.is_none() || delivery.is_none() || signature256.is_none() {
44 error!("400: X-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256 required");
45 return Ok(HttpResponse::BadRequest()
46 .content_type(ContentType::plaintext())
47 .body("X-GitHub-Event, X-GitHub-Delivery, X-Hub-Signature-256 required"));
48 }
49
50 let event = event.unwrap().to_str();
51 let delivery = delivery.unwrap().to_str();
52 let signature256 = signature256.unwrap().to_str();
53 if event.is_err() || delivery.is_err() || signature256.is_err() {
54 error!("400: Bad HTTP header");
55 return Ok(HttpResponse::BadRequest()
56 .content_type(ContentType::plaintext())
57 .body("Bad X-Hub header"));
58 }
59 let event = event.unwrap();
60 let delivery = delivery.unwrap();
61 let signature256 = signature256.unwrap();
62 info!("X-GitHub-Event: {event}");
63 info!("X-GitHub-Delivery: {delivery}");
64 info!("X-Hub-Signature-256: {signature256}");
65
66 if event != "push" {
68 info!(r#"event "{event}" ignored"#);
69 return Ok(HttpResponse::BadRequest()
70 .content_type(ContentType::plaintext())
71 .body(""));
72 }
73
74 let prefix = "sha256=";
76 if !signature256.starts_with(prefix) {
77 error!("400: Invalid signature");
78 return Ok(HttpResponse::BadRequest()
79 .content_type(ContentType::plaintext())
80 .body("Invalid signature"));
81 }
82 let signature256 = &signature256[prefix.len()..];
83
84 if !signature256.len().is_multiple_of(2) {
86 return Ok(HttpResponse::BadRequest()
87 .content_type(ContentType::plaintext())
88 .body("Invalid signature"));
89 }
90 let mut hash: Vec<u8> = Vec::new();
91 for i in (0..signature256.len()).step_by(2) {
92 let bytestr = &signature256[i..i + 2];
93 let byte = u8::from_str_radix(bytestr, 16).map_err(|_| anyhow!("Invalid signature"))?;
94 hash.push(byte);
95 }
96
97 let secret = ctrl
99 .sysmods()
100 .http
101 .lock()
102 .await
103 .config
104 .ghhook_secret
105 .clone();
106 if netutil::hmac_sha256_verify(secret.as_bytes(), body.as_bytes(), &hash).is_err() {
107 error!("SHA256 verify error (see github webhook settings)");
108 return Ok(HttpResponse::BadRequest()
109 .content_type(ContentType::plaintext())
110 .body("SHA256 verify error"));
111 }
112 info!("verify request body OK");
113
114 process_post(&ctrl, &body).await;
115
116 Ok(HttpResponse::Ok()
117 .content_type(ContentType::plaintext())
118 .body(""))
119}
120
121async fn process_post(ctrl: &Control, json_body: &str) {
122 match create_msg_from_json(json_body) {
123 Ok(msg) => {
124 let ctrl_clone = Arc::clone(ctrl);
125 let msg_clone = msg.clone();
126 taskserver::spawn_oneshot_fn(ctrl, "http-github-tweet", async move {
127 ctrl_clone
128 .sysmods()
129 .twitter
130 .lock()
131 .await
132 .tweet(&msg_clone)
133 .await
134 });
135
136 let ctrl_clone = ctrl.clone();
137 taskserver::spawn_oneshot_fn(ctrl, "http-github-discord", async move {
138 ctrl_clone.sysmods().discord.lock().await.say(&msg).await
139 });
140 }
141 Err(why) => {
142 error!("{why:#?}");
143 }
144 }
145}
146
147fn create_msg_from_json(json_body: &str) -> Result<String> {
148 let root: Value = serde_json::from_str(json_body)?;
149
150 let refstr = root["ref"]
151 .as_str()
152 .ok_or_else(|| anyhow!("ref not found"))?;
153 let compare = root["compare"]
154 .as_str()
155 .ok_or_else(|| anyhow!("compare not found"))?;
156
157 Ok(format!("Pushed to Github: {refstr}\n{compare}"))
158}
159
160#[cfg(test)]
161mod tests {
162 use super::*;
163
164 #[test]
165 fn webhook_simple_push() {
166 let jsonstr = include_str!(concat!(
167 env!("CARGO_MANIFEST_DIR"),
168 "/res/test/github/simplepush.json"
169 ));
170 let msg = create_msg_from_json(jsonstr).unwrap();
171
172 assert_eq!(
173 msg,
174 "Pushed to Github: refs/heads/rust\nhttps://github.com/yappy/DollsKit/compare/ac61a0d5b3e5...2faf7b5f1bb6"
175 );
176 }
177
178 #[test]
179 fn webhook_empty_branch() {
180 let jsonstr = include_str!(concat!(
181 env!("CARGO_MANIFEST_DIR"),
182 "/res/test/github/emptybranch.json"
183 ));
184 let msg = create_msg_from_json(jsonstr).unwrap();
185
186 assert_eq!(
187 msg,
188 "Pushed to Github: refs/heads/newbranch\nhttps://github.com/yappy/DollsKit/compare/newbranch"
189 );
190 }
191
192 #[test]
193 fn webhook_delete_branch() {
194 let jsonstr = include_str!(concat!(
195 env!("CARGO_MANIFEST_DIR"),
196 "/res/test/github/deletebranch.json"
197 ));
198 let msg = create_msg_from_json(jsonstr).unwrap();
199
200 assert_eq!(
201 msg,
202 "Pushed to Github: refs/heads/newbranch\nhttps://github.com/yappy/DollsKit/compare/9591d010ba32...000000000000"
203 );
204 }
205}