tag2upload_service_manager/
webhook.rsuse crate::prelude::*;
use rocket::data::{Data, FromData, Outcome};
use rocket::http::Status;
use rocket::request::Request;
use rocket::serde::json::Json;
#[derive(Deftly, Debug)]
#[derive_deftly_adhoc]
pub struct RawWebhookPayloadData<FD> {
pub repo_git_url: String,
pub tag_name: String,
pub tag_objectid: GitObjectId,
pub tag_meta: t2umeta::Parsed,
#[deftly(validate_special)]
pub forge_data: FD,
}
struct UnvalidatedWebhookRequest<'g, FD> {
globals: &'g Globals,
meta: RawWebhookMetadata,
data: RawWebhookPayloadData<FD>,
}
struct RawWebhookMetadata {
forge_namever: ForgeNamever,
client_ip: IpAddr,
kind_name: &'static str,
}
#[async_trait]
pub trait SomeWebhookPayload: for <'a> Deserialize<'a>
+ TryInto<RawWebhookPayloadData<<Self::Forge as SomeForge>::DbData>,
Error=WebError>
{
type Forge: SomeForge + Default;
}
pub struct RawSpecificWebhookPayload<P> {
data: P,
client_ip: IpAddr,
}
#[async_trait]
impl<'r, P> FromData<'r> for RawSpecificWebhookPayload<P>
where
P: SomeWebhookPayload
{
type Error = anyhow::Error;
async fn from_data(
req: &'r Request<'_>,
data: Data<'r>,
) -> Outcome<'r, Self, Self::Error> {
use Outcome as O;
let client_ip = req.client_ip();
let r = async {
let data = match FromData::from_data(req, data).await {
O::Success(Json(data)) => data,
O::Error((s, e)) => return O::Error(
(s, anyhow!("body parsing failed: {e}"))
),
x @ O::Forward(_) => return O::Error(
(Status::InternalServerError, anyhow!("forwarded?! {x:?}"))
)
};
let Some(client_ip) = client_ip
else { return O::Error((
Status::InternalServerError,
anyhow!("missing client IP addr")
)) };
O::Success(RawSpecificWebhookPayload { data, client_ip })
}.await;
match &r {
O::Error((_s, error)) =>
debug!(?client_ip, %error, "unprocessable reqeust"),
O::Success { .. } | O::Forward { .. } => {}
}
r
}
}
impl<P: SomeWebhookPayload> RawSpecificWebhookPayload<P> {
pub async fn webhook_impl(self) -> Result<String, WebError> {
let some_forge = P::Forge::default();
let forge_namever = some_forge
.namever_str().to_owned().into();
let data = self.data.try_into();
let log_info = if let Ok(d) = &data {
format!(
"source={} version={} repo={:?} tag_objid={}",
d.tag_meta.source, d.tag_meta.version,
d.repo_git_url, d.tag_objectid,
)
} else {
format!("unprocessable")
};
let r = async {
let meta = RawWebhookMetadata {
forge_namever,
kind_name: P::Forge::default().kind_name(),
client_ip: self.client_ip,
};
meta.webhook_impl(&some_forge, data).await
}.await;
if let Err(e) = &r {
match e {
WE::MisconfiguredWebhook { .. } |
WE::NetworkError { .. } =>
debug!("rejected, {log_info}"),
WE::MalfunctioningWebhook { .. } |
WE::NotForUs { .. } |
WE::InternalError { .. } =>
info!("rejected, {log_info}")
}
}
r
}
}
impl RawWebhookMetadata {
async fn webhook_impl<SF: SomeForge>(
self,
_some_forge: &SF,
data: Result<RawWebhookPayloadData<SF::DbData>, WebError>,
) -> Result<String, WebError> {
let globals = globals();
let data = match async {
let data = data?;
let erased_payload = UnvalidatedWebhookRequest {
meta: self,
globals: &globals,
data,
};
erased_payload.validate_payload().await
}.await {
Ok(y) => y,
Err(e) => return Err(e),
};
let now = globals.now();
let job_row = JobRow {
jid: JobId::none(),
data: data,
received: now,
last_update: now,
tag_data: None.into(),
status: JobStatus::Noticed,
info: format!("job received, tag not yet fetched"),
processing: None.into(),
duplicate_of: None,
};
let jid = db_transaction(TN::Update {
this_jid: None,
tag_objectid: &job_row.data.tag_objectid,
}, |dbt| {
let jid = dbt.bsql_insert(bsql!(
"INSERT INTO jobs " +~(job_row) ""
)).into_internal("insert into jobs failed")?;
Ok::<_, WebError>(jid)
})??;
let msg = format!("job received, jid={jid}");
info!(jid=%jid, now=?job_row.status, info=%job_row.info,
"[{}] received", job_row.data.forge_host);
Ok(msg)
}
}
impl<FD: Display> UnvalidatedWebhookRequest<'_, FD> {
async fn check_permission(&self) -> Result<Hostname, WE> {
let forge_host = (|| {
let rhs = if let Some(fake) = &self.globals.config
.testing.fake_https_dir
{
let strip = format!("file://{fake}/");
self.data.repo_git_url
.strip_prefix(&strip)
.ok_or_else(|| anyhow!(
"failed to strip expected faked {strip:?} from {:?}", self.data.repo_git_url
))?
} else {
self.data.repo_git_url
.strip_prefix("https://")
.ok_or_else(|| anyhow!("scheme not https"))?
};
let (host, rhs) = rhs.split_once('/')
.ok_or_else(|| anyhow!("missing / after host"))?;
rhs.chars().all(|c| c.is_ascii_graphic()).then_some(())
.ok_or_else(|| anyhow!("nonprintable characters in url"))?;
let host: Hostname = host.parse()?;
Ok::<_, AE>(host)
})()
.context("bad project repository URL")
.map_err(WE::MisconfiguredWebhook)?;
let correct_host_forges = self.globals.config.t2u.forges.iter()
.filter(|cf| cf.host == forge_host);
let check_kind = |cf: &config::Forge| {
(cf.kind == self.meta.kind_name).then(|| ())
.ok_or_else(|| anyhow!(
"wrong webhook path used, expected /hook/{}",
cf.kind,
))
};
let forge: &config::Forge =
correct_host_forges.clone()
.find(|cf| check_kind(cf).is_ok())
.ok_or_else(|| {
let mut emsg = format!("no matching forge in config");
for cf in correct_host_forges.clone() {
let wrong = check_kind(cf).expect_err("suddenly good?");
write!(emsg, "; forge host {:?}: {wrong}", cf.host)
.unwrap();
}
if correct_host_forges.clone().next().is_none() {
write!(emsg, "; no matching forge hosts")
.unwrap();
}
anyhow!("{}", emsg)
})
.map_err(WE::MisconfiguredWebhook)?;
let _: IsAllowedCaller = AllowedCaller::list_contains(
&forge.allow,
self.meta.client_ip,
)
.await.map_err(WE::NetworkError)?
.map_err(|wrong| WE::MisconfiguredWebhook(wrong.into()))?;
Ok(forge.host.clone())
}
fn check_tag_name(&self) -> Result<(), NotForUsReason> {
let app_config = &self.globals.config.t2u;
let (distro, version) = self.data.tag_name.split('/').collect_tuple()
.ok_or_else(|| NFR::TagNameUnexpectedSyntax)?;
(distro == app_config.distro).then(||())
.ok_or_else(|| NFR::TagNameNotOurDistro)?;
if !version.chars().all(
|c| c.is_ascii_alphanumeric() || ".+-%_#".chars().contains(&c)
) {
return Err(NFR::TagNameUnexpectedSyntax)
}
if version == "." || version == ".." {
return Err(NFR::TagNameUnexpectedSyntax)
}
Ok(())
}
async fn validate_payload(self) -> Result<JobData, WE> {
let forge_host = self.check_permission().await?;
self.check_tag_name()?;
let forge_data = ForgeData::from_raw_string(
self.data.forge_data.to_string()
);
let validated = derive_deftly_adhoc! {
RawWebhookPayloadData expect expr:
JobData {
$(
${when not(fmeta(validate_special))}
$fname: self.data.$fname,
)
forge_host,
forge_data,
forge_namever: self.meta.forge_namever,
}
};
Ok(validated)
}
}