tag2upload_service_manager/
webhook.rs

1//! webhook handling (forge-agnostic)
2//!
3//! Processing happens as follows:
4//!
5//!  * HHTP POST data
6//!    1. processed by Rocket route handler
7//!    2. `FromData for RoutePayloadParameter<F>`
8//!       * Checks the vhost.
9//!       * Extracts the client IP address and checks it
10//!        against the union of the forge ACLs.
11//!       * Deserialises the JSON into serde_json::Value.
12//!    2. Per-forge webhook implementation route calls
13//!       `RoutePayloadParameter<F>::webhook_impl`.
14//!    3. Global implementation `webhook_impl`, here:
15//!       * Further deserialises the payload into
16//!         the forge-specific data `We3bhookForge::Payload`.
17//!       * Calls `ForgeKind::analyse_payload` to get a
18//!         `webhook::AnalysedPayloadData`.
19//!       * Checks the host name and IP address against the specific ACL.
20//!       * Parses the tag metadata.
21//!       * Makes a `JobRow` and Inserts it into the database.
22use crate::prelude::*;
23
24use rocket::data::{Data, FromData, Outcome};
25use rocket::request::Request;
26use rocket::serde::json::Json;
27
28/// Used in `#[rocket::post]`
29pub struct RoutePayloadParameter<F> {
30    payload: Result<RawPayload, WebError>,
31    forge: PhantomData<F>,
32}
33
34/// Payload, JSON deserialised but not yet decoded by forge impl
35struct RawPayload {
36    client: ActualClient,
37    #[allow(dead_code)]
38    unified_acl_checked: IsAllowedClient,
39    #[allow(dead_code)]
40    vhost_checked: ui_vhost::IsWebhook,
41    raw: serde_json::Value,
42}
43
44#[derive(Deftly, Debug)]
45pub struct AnalysedPayload<F: ForgeKind> {
46    pub repo_git_url: String,
47    pub tag_name: String,
48    pub tag_objectid: GitObjectId,
49    pub tag_message: String,
50    pub forge_data: F::DbData,
51}
52
53struct RequestBeingProcessed<'a, F: ForgeKind> {
54    globals: &'a Globals,
55    forge: &'a F,
56    payload: AnalysedPayload<F>,
57    raw: &'a RawPayload,
58}
59
60impl<'r> RawPayload {
61    /// First non-Rocket call site for a webhook POST request
62    ///
63    /// Here we try to handle the POST data, but only if we decide
64    /// that's appropriate.  We might just return `Err`.
65    async fn from_data(
66        req: &'r Request<'_>,
67        data: Data<'r>,
68    ) -> Result<Self, WE> {
69        use Outcome as O;
70        let gl = globals();
71
72        let vhost_checked = ui_vhost::IsWebhook::from_request(req).await
73            .map_err(|e| WE::PageNotFoundHere(e.into()))?;
74
75        let client = req.client_ip()
76            .ok_or_else(|| internal!("missing client IP addr"))?;
77        let client = ActualClient::new(client);
78
79        let unified_acl_checked: IsAllowedClient = client.allowed_by(
80            &gl.computed_config.unified_webhook_acl
81        ).await?;
82
83        // Now we know the caller is authorised, somehow,
84        // and we can reasonably safely process the POST data.
85
86        let raw = match Json::from_data(req, data).await {
87            O::Success(Json(raw)) => Ok(raw),
88            O::Error((s, e)) => Err(WebError::MisconfiguredWebhook(
89                anyhow!("body parsing failed ({s}): {e}")
90            )),
91            x @ O::Forward(_) => Err(
92                internal!("forwarded?! {x:?}").into()
93            ),
94        }?;
95
96        Ok(RawPayload { vhost_checked, client, unified_acl_checked, raw })
97    }
98}
99
100#[async_trait]
101impl<'r, F: ForgeKind> FromData<'r> for RoutePayloadParameter<F> {
102    /// Errors go into `RawWebhookPayload.payload`
103    ///
104    /// This is because Rocket throws the error away when generating
105    /// a response!
106    type Error = Void;
107    
108    async fn from_data(
109        req: &'r Request<'_>,
110        data: Data<'r>,
111    ) -> Outcome<'r, Self, Self::Error> {
112        use Outcome as O;
113
114        let payload = RawPayload::from_data(req, data).await;
115
116        match &payload {
117            Ok(y) => trace!(client=?y.client, raw=?y.raw, "webhook"),
118            Err(err) => trace!(client=?req.client_ip(), %err, "webhook"),
119        };
120
121        let forge = PhantomData;
122
123        O::Success(RoutePayloadParameter { payload, forge })
124    }
125}
126
127impl<F: ForgeKind> RoutePayloadParameter<F> {
128    /// Implementation of the webhook, main entrypoint
129    ///
130    /// All POST requests to the right path are handled here,
131    /// so this function is responsible for most error handling.
132    ///
133    /// Errors that are checked for *before* processing POST data
134    /// (including client IP addresses that aren't on any of our ACLs)
135    /// show up as `self.payload` being `Err`.
136    pub async fn webhook_impl(self) -> Result<String, WebError> {
137        // We're going to log something, probably.
138        // These IEFE's ensure that.
139
140        let raw = (|| {
141
142            self.payload
143
144        })().inspect_err(|e| {
145            debug!("rejected early: {e}");
146        })?;
147
148        let mut log_info = format!("from {}: ", &raw.client);
149        async {
150
151            let forge = F::default();
152            raw.webhook_impl_inner(&forge, &mut log_info).await
153
154        }.await.inspect_err(|e| {
155            write_string!(log_info, "{e}");
156            match e {
157                WE::Throttled { .. } |
158                WE::DisallowedClient { .. } |
159                WE::PageNotFoundHere { .. } |
160                // reported as Error when we generated it
161                WE::InternalError { .. } => 
162                    info!("rejected: {log_info}"),
163                WE::MisconfiguredWebhook { .. } |
164                WE::NotForUs { .. } => {
165                    info!("ignored: {log_info}");
166                    debug!("ignored: {log_info}, raw={:?}", &raw.raw);
167                }
168            }
169        })
170    }
171}
172
173impl RawPayload {
174    async fn webhook_impl_inner<F: ForgeKind>(
175        &self,
176        forge: &F,
177        log_info: &mut String,
178    ) -> Result<String, WebError> {
179        let payload = serde_json::from_value(self.raw.clone())
180            .context("JSON payload does not conform to expected schema")
181            .map_err(WE::MisconfiguredWebhook)?;
182
183        let payload = forge.analyse_payload(payload)?;
184
185        write_string!(log_info,
186                      "url={} tag={} objectid={}: ",
187                      payload.repo_git_url,
188                      payload.tag_name,
189                      payload.tag_objectid);
190
191        let req = RequestBeingProcessed {
192            globals: &globals(),
193            payload,
194            forge,
195            raw: self,
196        };
197
198        let forge_host = req.check_permission().await?;
199
200        // precheck, so we don't do a bunch of work if we are throttled
201        db_transaction(TN::Readonly, |dbt| check_not_throttled(dbt))??;
202
203        let forge_namever = forge.namever_str().to_owned().into();
204
205        req.check_tag_name()?;
206
207        let AnalysedPayload {
208            repo_git_url,
209            tag_name,
210            tag_objectid,
211            forge_data,
212            tag_message,
213        } = req.payload;
214
215        let tag_meta = t2umeta::Parsed::from_tag_message(&tag_message)?;
216
217        let validated_data = JobData {
218            repo_git_url,
219            tag_objectid,
220            tag_name,
221            forge_host,
222            forge_namever,
223            forge_data: ForgeData::from_raw_string(forge_data.to_string()),
224            tag_meta,
225        };
226
227        let now = req.globals.now();
228
229        let job_row = JobRow {
230            jid: JobId::none(),
231            data: validated_data,
232            received: now,
233            last_update: now,
234            tag_data: None.into(),
235            status: JobStatus::Noticed,
236            info: format!("job received, tag not yet fetched"),
237            processing: None.into(),
238            duplicate_of: None,
239        };
240
241        let jid = db_transaction(TN::Update { 
242            this_jid: None,
243            tag_objectid: &job_row.data.tag_objectid,
244        }, |dbt| {
245            check_not_throttled(dbt)?;
246
247            let jid = dbt.bsql_insert(bsql!(
248                "INSERT INTO jobs " +~(job_row) ""
249            )).into_internal("insert into jobs failed")?;
250
251            Ok::<_, WebError>(jid)
252        })??;
253
254        let msg = format!("job received, jid={jid}");
255
256        info!(jid=%jid, now=?job_row.status, info=%job_row.info,
257              "[{}] received", job_row.data.forge_host);
258
259        Ok(msg)
260    }
261}
262
263impl<'a, F: ForgeKind> RequestBeingProcessed<'a, F> {
264    async fn check_permission(&self) -> Result<Hostname, WE> {
265        let forge_host = (|| {
266            let rhs = if let Some(fake) = &self.globals.config
267                .testing.fake_https_dir
268            {
269                let strip = format!("file://{fake}/");
270                self.payload.repo_git_url
271                    .strip_prefix(&strip)
272                    .ok_or_else(|| anyhow!(
273                        "failed to strip expected faked {strip:?} from {:?}",
274                        self.payload.repo_git_url
275                    ))?
276            } else {
277                self.payload.repo_git_url
278                    .strip_prefix("https://")
279                    .ok_or_else(|| anyhow!("scheme not https"))?
280            };
281            let (host, rhs) = rhs.split_once('/')
282                .ok_or_else(|| anyhow!("missing / after host"))?;
283
284            rhs.chars().all(|c| c.is_ascii_graphic()).then_some(())
285                .ok_or_else(|| anyhow!("nonprintable characters in url"))?;
286
287            let host: Hostname = host.parse()?;
288
289            Ok::<_, AE>(host)
290        })()
291            .context("bad project repository URL")
292            .map_err(WE::MisconfiguredWebhook)?;
293
294        let correct_host_forges = self.globals.config.t2u.forges.iter()
295            .filter(|cf| cf.host == forge_host);
296
297        let check_kind = |cf: &config::Forge| {
298            (cf.kind == self.forge.kind_name()).then(|| ())
299                .ok_or_else(|| anyhow!(
300                    "wrong webhook path used, expected /hook/{}",
301                    cf.kind,
302                ))
303        };
304
305        let forge: &config::Forge =
306            correct_host_forges.clone()
307            .find(|cf| check_kind(cf).is_ok())
308            .ok_or_else(|| {
309                let mut emsg = format!("no matching forge in config");
310                for cf in correct_host_forges.clone() {
311                    let wrong = check_kind(cf).expect_err("suddenly good?");
312                    write!(emsg, "; forge host {:?}: {wrong}", cf.host)
313                        .unwrap();
314                }
315                if correct_host_forges.clone().next().is_none() {
316                    write!(emsg, "; no matching forge hosts")
317                        .unwrap();
318                }
319                anyhow!("{}", emsg)
320            })
321            // TODO this is perhaps anthe actual permission denied variant,
322            // log them at lower severity ?
323            .map_err(WE::MisconfiguredWebhook)?;
324
325        let _: IsAllowedClient = self.raw.client.allowed_by(&forge.allow)
326            // TODO these are the actual permission denied variants,
327            // log them at lower severity ?
328            .await?;
329
330        Ok(forge.host.clone())
331    }
332
333    fn check_tag_name(&self) -> Result<(), NotForUsReason> {
334        let app_config = &self.globals.config.t2u;
335
336        let (distro, version) = self.payload.tag_name.split('/')
337            .collect_tuple()
338            .ok_or_else(|| NFR::TagNameUnexpectedSyntax)?;
339        (distro == app_config.distro).then(||())
340            .ok_or_else(|| NFR::TagNameNotOurDistro)?;
341        if !version.chars().all(
342            |c| c.is_ascii_alphanumeric() || ".+-%_#".chars().contains(&c)
343        ) {
344            return Err(NFR::TagNameUnexpectedSyntax)
345        }
346        if version == "." || version == ".." {
347            return Err(NFR::TagNameUnexpectedSyntax)
348        }
349        Ok(())
350    }
351}