autopulse_service/settings/webhooks/
hookshot.rs

1use super::{transport, EventType, WebhookBatch};
2use autopulse_utils::sify;
3use html_escape::encode_text;
4use serde::{Deserialize, Serialize};
5
6#[derive(Serialize, Clone)]
7struct HookshotPayload {
8    text: String,
9    html: String,
10    username: String,
11}
12
13#[derive(Serialize, Deserialize, Clone)]
14pub struct HookshotWebhook {
15    /// Webhook URL
16    pub url: String,
17    /// Optional username (default: autopulse)
18    pub username: Option<String>,
19}
20
21impl HookshotWebhook {
22    fn summary_line(event: &EventType, trigger: Option<&str>, files: &[String]) -> String {
23        trigger.map_or_else(
24            || {
25                format!(
26                    "[{event}] - {} file{} {}",
27                    files.len(),
28                    sify(files),
29                    event.action()
30                )
31            },
32            |trigger| {
33                format!(
34                    "[{event}] - [{trigger}] - {} file{} {}",
35                    files.len(),
36                    sify(files),
37                    event.action()
38                )
39            },
40        )
41    }
42
43    fn generate_payload(&self, batch: &WebhookBatch) -> HookshotPayload {
44        let username = self
45            .username
46            .clone()
47            .unwrap_or_else(|| "autopulse".to_string());
48        let sections = batch
49            .iter()
50            .map(|(event, trigger, files)| {
51                let summary = Self::summary_line(event, trigger.as_deref(), files);
52                let files = files.join("\n");
53
54                format!("{summary}\n{files}")
55            })
56            .collect::<Vec<_>>();
57        let text = sections.join("\n\n");
58        let html = batch
59            .iter()
60            .map(|(event, trigger, files)| {
61                let raw_summary = Self::summary_line(event, trigger.as_deref(), files);
62                let summary = encode_text(&raw_summary);
63                let files = files
64                    .iter()
65                    .map(|file| format!("<li><code>{}</code></li>", encode_text(file)))
66                    .collect::<Vec<_>>()
67                    .join("");
68
69                format!("<li><strong>{summary}</strong><ul>{files}</ul></li>")
70            })
71            .collect::<Vec<_>>()
72            .join("");
73        let html = format!("<ul>{html}</ul>");
74
75        HookshotPayload {
76            text,
77            html,
78            username,
79        }
80    }
81
82    pub async fn send(
83        &self,
84        batch: &WebhookBatch,
85        retries: u8,
86        timeout_secs: u64,
87    ) -> anyhow::Result<()> {
88        let payload = self.generate_payload(batch);
89
90        transport::shared_sender(std::time::Duration::from_secs(timeout_secs))?
91            .send_json(&self.url, &[payload], retries)
92            .await
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn sample_batch() -> WebhookBatch {
101        vec![
102            (
103                EventType::Processed,
104                Some("sonarr".to_string()),
105                vec!["/media/tv/show-01.mkv".to_string()],
106            ),
107            (
108                EventType::Failed,
109                None,
110                vec!["/media/movies/movie-01.mkv".to_string()],
111            ),
112        ]
113    }
114
115    #[test]
116    fn generate_payload_defaults_username_and_summarizes_each_batch_item() {
117        let webhook = HookshotWebhook {
118            url: "https://example.com/webhooks/hookshot".to_string(),
119            username: None,
120        };
121
122        let payload = webhook.generate_payload(&sample_batch());
123
124        assert_eq!(payload.username, "autopulse");
125        assert!(payload
126            .text
127            .contains("[PROCESSED] - [sonarr] - 1 file processed"));
128        assert!(payload.text.contains("/media/tv/show-01.mkv"));
129        assert!(payload.text.contains("[FAILED] - 1 file failed"));
130        assert!(payload
131            .html
132            .contains("<code>/media/movies/movie-01.mkv</code>"));
133    }
134
135    #[test]
136    fn html_entities_in_filenames_are_escaped() {
137        let batch: WebhookBatch = vec![(
138            EventType::Processed,
139            Some("sonarr".to_string()),
140            vec![
141                r#"/media/tv/<script>alert("xss")</script>.mkv"#.to_string(),
142                "/media/tv/Tom & Jerry's Show.mkv".to_string(),
143            ],
144        )];
145
146        let webhook = HookshotWebhook {
147            url: "https://example.com/hook".to_string(),
148            username: None,
149        };
150
151        let payload = webhook.generate_payload(&batch);
152
153        // Angle brackets escaped
154        assert!(payload.html.contains("&lt;script&gt;"));
155        assert!(payload.html.contains("&lt;/script&gt;"));
156        // Ampersand escaped
157        assert!(payload.html.contains("Tom &amp; Jerry"));
158        // Quotes are safe in text nodes and left unescaped by encode_text
159        assert!(payload.html.contains(r#"alert("xss")"#));
160        assert!(payload.html.contains("Jerry's"));
161        // Raw angle brackets must NOT appear in HTML output
162        assert!(!payload.html.contains("<script>"));
163    }
164}