tag2upload_service_manager/
webhook.rs1use crate::prelude::*;
23
24use rocket::data::{Data, FromData, Outcome};
25use rocket::request::Request;
26use rocket::serde::json::Json;
27
28pub struct RoutePayloadParameter<F> {
30 payload: Result<RawPayload, WebError>,
31 forge: PhantomData<F>,
32}
33
34struct 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 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 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 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 pub async fn webhook_impl(self) -> Result<String, WebError> {
137 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 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 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 .map_err(WE::MisconfiguredWebhook)?;
324
325 let _: IsAllowedClient = self.raw.client.allowed_by(&forge.allow)
326 .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}