sys/sysmod/http/
github.rs

1//! Github Webhook.
2//!
3//! <https://docs.github.com/ja/developers/webhooks-and-events/webhooks>
4
5use 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/// Github webhook 設定で Content type = application/json に設定すること。
35#[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    // "push" 以外は無視する
67    if event != "push" {
68        info!(r#"event "{event}" ignored"#);
69        return Ok(HttpResponse::BadRequest()
70            .content_type(ContentType::plaintext())
71            .body(""));
72    }
73
74    // "sha256=" の部分を取り除く
75    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    // 16 進文字列を 2 文字ずつ u8 配列に変換する
85    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    // json body の SHA256 を計算して検証する
98    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}