//! Page: repo webhooks (`/{owner}/{repo}/settings/hooks`). Admins of the repo (its owner, an instance //! admin, or a collaborator with the Admin role) list the hooks, add and edit them, send a test ping, //! redeliver a delivery and delete a hook. The hook page lists the last 50 deliveries with their request //! and response. A hook's secret is sealed with the instance key and is never shown again: a blank //! secret on edit keeps the stored one. Archived repos show everything but change nothing. use forge_core::AuditAction; use forge_core::secret; use forge_core::tz::format_in_tz; use forge_core::webhooks::{Delivery, DeliveryState, Webhook, WebhookEvent}; use serde::Deserialize; use serde_json::Value; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{SeeOther, forbidden, not_found, see_other}; use topcoat::router::request::uri; use topcoat::router::{page, path_param, route}; use topcoat::view::{View, component, view}; use crate::audit::{self, Actor, Target}; use crate::auth::{require_csrf, require_user}; use crate::i18n::{ self, Lang, integrations::{self as g, K}, }; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo}; use crate::pages::repo_settings::settings_nav_for; use crate::state::AppState; use crate::views::{alert, enc, shell}; use crate::webhooks::client::{WebhookPolicy, validate_url}; use crate::webhooks::emit::emit_ping; path_param!(owner); path_param!(repo); path_param!(hook_id); path_param!(delivery_id); /// Longest secret accepted, in characters. const MAX_SECRET_CHARS: usize = 256; /// Deliveries listed on a hook page. const DELIVERIES_SHOWN: u32 = 50; /// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors. const NOTICES: [(&str, K, bool); 6] = [ ("created", K::HookNoticeCreated, false), ("saved", K::HookNoticeSaved, false), ("tested", K::HookNoticeTested, false), ("redelivered", K::HookNoticeRedelivered, false), ("deleted", K::HookNoticeDeleted, false), ("confirm", K::HookNoticeConfirm, true), ]; /// The events a hook can subscribe to in the form, with their labels. `ping` is sent by the forge /// itself, so it is not offered. Each checkbox is named `ev_`. const EVENT_CHOICES: [(WebhookEvent, K); 8] = [ (WebhookEvent::Push, K::EvPush), (WebhookEvent::PullRequest, K::EvPullRequest), (WebhookEvent::Issues, K::EvIssues), (WebhookEvent::IssueComment, K::EvIssueComment), (WebhookEvent::PullRequestReview, K::EvPullRequestReview), (WebhookEvent::Create, K::EvCreate), (WebhookEvent::Delete, K::EvDelete), (WebhookEvent::Release, K::EvRelease), ]; /// The hook form. Unticked boxes are absent from the body, so each event has an `Option`. #[derive(Deserialize)] struct HookForm { #[serde(default)] csrf: String, #[serde(default)] url: String, /// Empty on edit keeps the stored secret. #[serde(default)] secret: String, #[serde(default)] active: Option, #[serde(default)] ev_push: Option, #[serde(default)] ev_pull_request: Option, #[serde(default)] ev_issues: Option, #[serde(default)] ev_issue_comment: Option, #[serde(default)] ev_pull_request_review: Option, #[serde(default)] ev_create: Option, #[serde(default)] ev_delete: Option, #[serde(default)] ev_release: Option, } impl HookForm { /// The ticked events, in the form's order. fn events(&self) -> Vec { [ (WebhookEvent::Push, &self.ev_push), (WebhookEvent::PullRequest, &self.ev_pull_request), (WebhookEvent::Issues, &self.ev_issues), (WebhookEvent::IssueComment, &self.ev_issue_comment), ( WebhookEvent::PullRequestReview, &self.ev_pull_request_review, ), (WebhookEvent::Create, &self.ev_create), (WebhookEvent::Delete, &self.ev_delete), (WebhookEvent::Release, &self.ev_release), ] .into_iter() .filter(|(_, ticked)| ticked.is_some()) .map(|(event, _)| event) .collect() } } #[derive(Deserialize)] struct CsrfForm { #[serde(default)] csrf: String, } #[derive(Deserialize)] struct DeleteForm { #[serde(default)] csrf: String, #[serde(default)] confirm: Option, } /// The values the hook form shows, plus the validation error to show above it. struct HookDraft { url: String, events: Vec, active: bool, error: Option, } impl HookDraft { fn of_hook(hook: &Webhook) -> Self { Self { url: hook.url.clone(), events: hook.events.clone(), active: hook.active, error: None, } } /// The new-hook form starts with every event ticked and the hook active. fn blank() -> Self { Self { url: String::new(), events: EVENT_CHOICES.iter().map(|(event, _)| *event).collect(), active: true, error: None, } } fn of_form(form: &HookForm, error: String) -> Self { Self { url: form.url.trim().to_owned(), events: form.events(), active: form.active.is_some(), error: Some(error), } } } /// One event checkbox as the form shows it. struct EventBox { name: String, label: &'static str, checked: bool, } fn event_boxes(lang: Lang, selected: &[WebhookEvent]) -> Vec { EVENT_CHOICES .iter() .map(|(event, label)| EventBox { name: format!("ev_{}", event.as_str()), label: g::t(lang, *label), checked: selected.contains(event), }) .collect() } /// The hook URL as the audit log may keep it: scheme, host, port and path only. Userinfo, query and /// fragment often carry tokens, so they are dropped. An unparsable URL is not logged at all. fn audit_url(raw: &str) -> String { match reqwest::Url::parse(raw) { Ok(mut url) => { let _ = url.set_username(""); let _ = url.set_password(None); url.set_query(None); url.set_fragment(None); url.to_string() } Err(_) => String::new(), } } /// The webhook policy from config. A bad `[webhooks]` section is a server error, not a user error. fn policy(state: &AppState) -> Result { Ok(WebhookPolicy::from_config(&state.config.webhooks).map_err(forge_core::Error::Config)?) } /// Checks the form values. The message is shown to the user, so it says what to fix. fn check_form( lang: Lang, url: &str, secret: &str, events: &[WebhookEvent], policy: &WebhookPolicy, ) -> std::result::Result<(), String> { if url.is_empty() { return Err(g::t(lang, K::ErrUrlMissing).to_owned()); } validate_url(url, policy).map_err(|e| e.to_string())?; if events.is_empty() { return Err(g::t(lang, K::ErrNoEvent).to_owned()); } if secret.chars().count() > MAX_SECRET_CHARS { return Err(g::secret_too_long(lang, MAX_SECRET_CHARS)); } Ok(()) } /// The viewer's language for a repo page. fn lang_of(cx: &Cx, ctx: &RepoCtx) -> Lang { i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)) } /// The repo's `owner/name`, as the settings pages build it. fn title_of(ctx: &RepoCtx) -> String { format!("{}/{}", ctx.repo.owner_name, ctx.repo.name) } fn hooks_href(title: &str) -> String { format!("/{title}/settings/hooks") } fn hook_href(title: &str, hook_id: &str) -> String { format!("{}/{}", hooks_href(title), enc(hook_id)) } /// The notice from `?notice=`. Anything else is ignored. fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> { let query = uri(cx).query()?; let word = form_urlencoded::parse(query.as_bytes()) .find(|(key, _)| key == "notice") .map(|(_, value)| value.into_owned())?; NOTICES .iter() .find(|(name, _, _)| *name == word) .map(|(_, key, is_error)| (g::t(lang, *key), *is_error)) } fn back_to_hook(title: &str, hook_id: &str, notice: &str) -> SeeOther { see_other(format!("{}?notice={notice}", hook_href(title, hook_id))) } /// Reads an RFC 3339 stamp and shows it in the viewer's timezone. An unreadable stamp is shown as is. fn local_stamp(stamp: &str, tz: &str) -> String { match OffsetDateTime::parse(stamp, &Rfc3339) { Ok(at) => format_in_tz(at.unix_timestamp(), tz), Err(_) => stamp.to_owned(), } } /// Pretty-prints a JSON body. Anything that is not JSON is shown as it is. fn pretty_body(raw: &str) -> String { match serde_json::from_str::(raw) { Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_owned()), Err(_) => raw.to_owned(), } } /// Header lines `name: value` from a JSON object of headers. Anything else is shown as it is. fn header_lines(raw: &str) -> String { match serde_json::from_str::>(raw) { Ok(map) => map .iter() .map(|(name, value)| match value.as_str() { Some(text) => format!("{name}: {text}"), None => format!("{name}: {value}"), }) .collect::>() .join("\n"), Err(_) => raw.to_owned(), } } fn state_label(lang: Lang, state: DeliveryState) -> (&'static str, &'static str) { match state { DeliveryState::Pending => ("pending", g::t(lang, K::StatePending)), DeliveryState::Ok => ("ok", g::t(lang, K::StateOk)), DeliveryState::Failed => ("failed", g::t(lang, K::StateFailed)), DeliveryState::GaveUp => ("gave_up", g::t(lang, K::StateGaveUp)), } } /// One hook row on the list page. struct HookRow { href: String, url: String, events: String, active: bool, last: Option<(&'static str, &'static str)>, } /// One delivery on the hook page, with every value already formatted for display. struct DeliveryRow { state_class: &'static str, state_text: &'static str, event: String, guid: String, time: String, duration: String, status: String, request_headers: String, request_body: String, response_headers: String, response_body: String, error: String, redeliver_action: String, } fn delivery_row( lang: Lang, delivery: &Delivery, tz: &str, redeliver_action: String, ) -> DeliveryRow { let (state_class, state_text) = state_label(lang, delivery.state); let event = if delivery.action.is_empty() { delivery.event.as_str().to_owned() } else { format!("{}: {}", delivery.event.as_str(), delivery.action) }; let status = match (delivery.response_status, &delivery.error) { (Some(code), _) => code.to_string(), (None, Some(_)) => g::t(lang, K::NoResponse).to_owned(), (None, None) => g::t(lang, K::NoneYet).to_owned(), }; DeliveryRow { state_class, state_text, event, guid: delivery.guid.clone(), time: local_stamp(&delivery.created_at, tz), duration: delivery .duration_ms .map_or_else(|| "-".to_owned(), |ms| format!("{ms} ms")), status, request_headers: header_lines(&delivery.request_headers), request_body: pretty_body(&delivery.request_body), response_headers: delivery .response_headers .as_deref() .map(header_lines) .unwrap_or_default(), response_body: delivery .response_body .as_deref() .map(pretty_body) .unwrap_or_default(), error: delivery.error.clone().unwrap_or_default(), redeliver_action, } } /// The repo fields the hook pages show, read once from the repo context. struct Frame { page_title: String, title: String, archived: bool, head: RepoHead, } impl Frame { async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result { let title = title_of(ctx); Ok(Self { page_title: format!("{title} \u{b7} {}", g::t(lang, K::Webhooks)), title, archived: ctx.repo.archived, head: ctx.head(state, "settings").await?, }) } } /// Header, sub-navigation, the one-line notice, and the archived note. #[component] async fn frame_top( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, lang: Lang, ) -> Result { Ok(view! { repo_head(head: head) settings_nav_for(lang: lang, title: title, current: "webhooks") if let Some((text, is_error)) = notice { if is_error { alert(message: text) } else {
(text)
} } if archived {
(g::t(lang, K::HookArchivedNotice))
} }) } /// The add or edit form. `secret_hint` says what the secret field does for this form. #[component] async fn hook_form( action: String, csrf: String, url: String, events: Vec, active: bool, secret_hint: String, error: Option, submit: &'static str, lang: Lang, ) -> Result { Ok(view! {
if let Some(message) = error { alert(message: message.as_str()) }

(g::t(lang, K::UrlHintNew))

(secret_hint.as_str()) (g::t(lang, K::SignedWith))

(g::t(lang, K::Events)) for event in events.iter() { }
}) } #[component] async fn hooks_list_body( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, rows: Vec, lang: Lang, ) -> Result { let new_href = format!("{}/new", hooks_href(&title)); Ok(view! { frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)

(g::t(lang, K::Webhooks))

(g::t(lang, K::WebhooksIntro))

if !archived {

(g::t(lang, K::AddWebhook))

} if rows.is_empty() {

(g::t(lang, K::NoWebhooks))

} else {
for row in rows.iter() { }
"URL" (g::t(lang, K::ColEvents)) (g::t(lang, K::ColActive)) (g::t(lang, K::ColLastDelivery))
(row.url.as_str()) (row.events.as_str()) (if row.active { g::t(lang, K::Yes) } else { g::t(lang, K::No) }) if let Some((class, text)) = row.last { (text) } else { (g::t(lang, K::NoneYet)) }
}
}) } #[component] async fn delivery_list( deliveries: Vec, csrf: String, archived: bool, lang: Lang, ) -> Result { Ok(view! { if deliveries.is_empty() {

(g::t(lang, K::NoDeliveries))

} for row in deliveries.iter() {
(row.state_text) " " (row.event.as_str()) " " (row.time.as_str()) " " (row.duration.as_str()) " " (g::t(lang, K::StatusLower))" " (row.status.as_str())

"Guid " (row.guid.as_str())

(g::t(lang, K::RequestHeaders))

(row.request_headers.as_str())

(g::t(lang, K::RequestBody))

(row.request_body.as_str())

(g::t(lang, K::Response))

if !row.error.is_empty() { }

(g::t(lang, K::Status))" " (row.status.as_str())

(g::t(lang, K::ResponseHeaders))

(row.response_headers.as_str())

(g::t(lang, K::ResponseBody))

(row.response_body.as_str())
if !archived {
}
} }) } #[component] async fn hook_body( head: RepoHead, title: String, notice: Option<(&'static str, bool)>, archived: bool, csrf: String, hook_id: String, draft: HookDraft, secret_hint: String, deliveries: Vec, lang: Lang, ) -> Result { let edit_action = hook_href(&title, &hook_id); let test_action = format!("{edit_action}/test"); let delete_action = format!("{edit_action}/delete"); let events = event_boxes(lang, &draft.events); Ok(view! { frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)

(g::t(lang, K::EditWebhook))

if archived {

(g::t(lang, K::ArchivedHookNote))

(draft.url.as_str())

} else { hook_form(action: edit_action.clone(), csrf: csrf.clone(), url: draft.url, events: events, active: draft.active, secret_hint: secret_hint, error: draft.error, submit: g::t(lang, K::SaveWebhook), lang: lang) }
if !archived {

(g::t(lang, K::Test))

(g::t(lang, K::TestIntro))

}

(g::t(lang, K::RecentDeliveries))

(g::recent_deliveries(lang, DELIVERIES_SHOWN))

delivery_list(deliveries: deliveries, csrf: csrf.clone(), archived: archived, lang: lang)
if !archived {

(g::t(lang, K::DeleteWebhook))

(g::t(lang, K::DeleteIntro))

} }) } /// Lists the repo's hooks. Admins only: a hidden repo is 404, any other viewer is 403. #[page("/{owner}/{repo}/settings/hooks")] async fn hooks_list_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; require_user(cx).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let state = app_context::(cx); let hooks = state.store.list_webhooks(&ctx.repo.id).await?; let lang = lang_of(cx, &ctx); let frame = Frame::new(&ctx, state, lang).await?; let rows: Vec = hooks .into_iter() .map(|(hook, last)| HookRow { href: hook_href(&frame.title, &hook.id), url: hook.url, events: hook .events .iter() .map(|e| e.as_str()) .collect::>() .join(", "), active: hook.active, last: last.map(|last| state_label(lang, last)), }) .collect(); let notice = notice_from_query(cx, lang); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), hooks_list_body(head: frame.head, title: frame.title, notice: notice, archived: frame.archived, rows: rows, lang: lang)) }) } /// The add form. #[page("/{owner}/{repo}/settings/hooks/new")] async fn hook_new_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (_, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let state = app_context::(cx); let lang = lang_of(cx, &ctx); let frame = Frame::new(&ctx, state, lang).await?; let csrf = session.csrf_token.clone(); let draft = HookDraft::blank(); let action = format!("{}/new", hooks_href(&frame.title)); Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), hook_new_body(head: frame.head, title: frame.title, archived: frame.archived, csrf: csrf, action: action, draft: draft, lang: lang)) }) } #[component] async fn hook_new_body( head: RepoHead, title: String, archived: bool, csrf: String, action: String, draft: HookDraft, lang: Lang, ) -> Result { let events = event_boxes(lang, &draft.events); let back = hooks_href(&title); Ok(view! { frame_top(head: head, title: title.clone(), notice: None, archived: archived, lang: lang)

(g::t(lang, K::AddWebhook))

(g::t(lang, K::AddWebhookIntro))

hook_form(action: action, csrf: csrf, url: draft.url, events: events, active: draft.active, secret_hint: g::t(lang, K::SecretHintNew).to_owned(), error: draft.error, submit: g::t(lang, K::CreateWebhook), lang: lang)

(g::t(lang, K::BackToWebhooks))

}) } /// Creates a hook. The secret is sealed before it is stored. A ping is queued on success. #[page(POST "/{owner}/{repo}/settings/hooks/new")] async fn hook_create(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() || ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let url = form.url.trim().to_owned(); let events = form.events(); let lang = lang_of(cx, &ctx); let checked = check_form(lang, &url, &form.secret, &events, &policy(state)?); if let Err(message) = checked { let frame = Frame::new(&ctx, state, lang).await?; let csrf = session.csrf_token.clone(); let action = format!("{}/new", hooks_href(&frame.title)); let draft = HookDraft::of_form(&form, message); return Ok(view! { shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), hook_new_body(head: frame.head, title: frame.title, archived: frame.archived, csrf: csrf, action: action, draft: draft, lang: lang)) }); } let sealed = secret::seal(&state.key, form.secret.as_bytes())?; let hook = state .store .create_webhook( &ctx.repo.id, &user.id, &url, sealed.as_bytes(), &events, form.active.is_some(), ) .await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::WebhookCreated, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({"hook_id": hook.id, "url": audit_url(&hook.url)}), ) .await; if let Err(err) = emit_ping(state, &hook).await { tracing::error!(hook_id = %hook.id, error = %err, "could not queue the ping for a new webhook"); } tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, hook_id = %hook.id, "webhook created" ); Err(back_to_hook(&title_of(&ctx), &hook.id, "created").into()) } /// The hook in the path, when it belongs to this repo. Any other hook answers 404, so another /// repo's hook id reveals nothing. async fn repo_hook(state: &AppState, ctx: &RepoCtx, id: &str) -> Result { match state.store.webhook(id).await? { Some(hook) if hook.repo_id == ctx.repo.id => Ok(hook), _ => Err(not_found().into()), } } /// The hint above the secret field: whether a secret is stored. The secret is opened only to see /// whether it is empty, and is not kept. async fn secret_hint(lang: Lang, state: &AppState, hook_id: &str) -> Result { let set = !state .store .webhook_secret(hook_id, &state.key) .await? .is_empty(); Ok(if set { g::t(lang, K::SecretHintSet) } else { g::t(lang, K::SecretHintUnset) } .to_owned()) } /// The last deliveries of a hook, newest first, each with its redeliver action. async fn delivery_rows( lang: Lang, state: &AppState, ctx: &RepoCtx, hook: &Webhook, ) -> Result> { let title = title_of(ctx); let tz = ctx.viewer_tz(); let deliveries = state .store .list_deliveries(&hook.id, DELIVERIES_SHOWN) .await?; Ok(deliveries .iter() .map(|delivery| { let action = format!( "{}/deliveries/{}/redeliver", hook_href(&title, &hook.id), enc(&delivery.id) ); delivery_row(lang, delivery, tz, action) }) .collect()) } /// What the hook page shows besides the form: the frame, the deliveries and the secret hint. struct HookPage { frame: Frame, deliveries: Vec, hint: String, } async fn hook_page_data( lang: Lang, ctx: &RepoCtx, state: &AppState, hook: &Webhook, ) -> Result { Ok(HookPage { frame: Frame::new(ctx, state, lang).await?, deliveries: delivery_rows(lang, state, ctx, hook).await?, hint: secret_hint(lang, state, &hook.id).await?, }) } /// The hook page: its form, a test button, the last deliveries and the delete form. #[page("/{owner}/{repo}/settings/hooks/{hook_id}")] async fn hook_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (_, session) = require_user(cx).await?; if !ctx.can_admin() { return Err(forbidden().into()); } let state = app_context::(cx); let hook = repo_hook(state, &ctx, id).await?; let draft = HookDraft::of_hook(&hook); let csrf = session.csrf_token.clone(); let lang = lang_of(cx, &ctx); let notice = notice_from_query(cx, lang); let page = hook_page_data(lang, &ctx, state, &hook).await?; let hook_id = hook.id.clone(); Ok(view! { shell(title: page.frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), hook_body(head: page.frame.head, title: page.frame.title, notice: notice, archived: page.frame.archived, csrf: csrf, hook_id: hook_id, draft: draft, secret_hint: page.hint, deliveries: page.deliveries, lang: lang)) }) } /// Saves a hook. A blank secret keeps the stored one. A refused form shows the error on the hook /// page with the submitted values. #[page(POST "/{owner}/{repo}/settings/hooks/{hook_id}")] async fn hook_update(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() || ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let hook = repo_hook(state, &ctx, id).await?; let url = form.url.trim().to_owned(); let events = form.events(); let lang = lang_of(cx, &ctx); if let Err(message) = check_form(lang, &url, &form.secret, &events, &policy(state)?) { let draft = HookDraft::of_form(&form, message); let csrf = session.csrf_token.clone(); let page = hook_page_data(lang, &ctx, state, &hook).await?; let hook_id = hook.id.clone(); return Ok(view! { shell(title: page.frame.page_title.as_str(), viewer: ctx.viewer.as_ref(), hook_body(head: page.frame.head, title: page.frame.title, notice: None, archived: page.frame.archived, csrf: csrf, hook_id: hook_id, draft: draft, secret_hint: page.hint, deliveries: page.deliveries, lang: lang)) }); } let sealed = if form.secret.is_empty() { None } else { Some(secret::seal(&state.key, form.secret.as_bytes())?) }; state .store .update_webhook( &hook.id, &url, sealed.as_ref().map(String::as_bytes), &events, form.active.is_some(), ) .await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::WebhookUpdated, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({"hook_id": hook.id, "url": audit_url(&url)}), ) .await; tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, hook_id = %hook.id, secret_changed = sealed.is_some(), "webhook updated" ); Err(back_to_hook(&title_of(&ctx), &hook.id, "saved").into()) } /// Sends a ping to the hook now. #[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/test")] async fn hook_test(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() || ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let hook = repo_hook(state, &ctx, id).await?; emit_ping(state, &hook).await?; Ok(back_to_hook(&title_of(&ctx), &hook.id, "tested")) } /// Queues a new delivery with the same payload as an earlier one, then wakes the worker. #[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/deliveries/{delivery_id}/redeliver")] async fn hook_redeliver(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let delivery = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() || ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let hook = repo_hook(state, &ctx, id).await?; match state.store.delivery(delivery).await? { Some(original) if original.hook_id == hook.id => {} _ => return Err(not_found().into()), } state.store.redeliver(delivery).await?; state.webhook_waker.notify_one(); Ok(back_to_hook(&title_of(&ctx), &hook.id, "redelivered")) } /// Deletes a hook and its deliveries. The box must be ticked. #[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/delete")] async fn hook_delete(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let id = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if !ctx.can_admin() || ctx.repo.archived { return Err(forbidden().into()); } let state = app_context::(cx); let hook = repo_hook(state, &ctx, id).await?; let title = title_of(&ctx); if form.confirm.is_none() { return Ok(back_to_hook(&title, &hook.id, "confirm")); } state.store.delete_webhook(&hook.id).await?; audit::record( &state.store, Some(cx), Actor::User(&user), AuditAction::WebhookDeleted, Target { kind: "repo", id: &ctx.repo.id, name: &title_of(&ctx), }, serde_json::json!({"hook_id": hook.id, "url": audit_url(&hook.url)}), ) .await; tracing::info!( actor_id = %user.id, repo_id = %ctx.repo.id, hook_id = %hook.id, "webhook deleted" ); Ok(see_other(format!("{}?notice=deleted", hooks_href(&title)))) } #[cfg(test)] mod tests { use std::path::Path; use super::audit_url; use forge_core::AuditAction; use forge_core::secret; use forge_core::webhooks::{DeliveryState, WebhookEvent}; use forge_core::{Role, Store, User, Visibility}; use forge_git::repo::{init_bare, repo_path}; use serde_json::Value; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::test_support::{KEY, app_router, create_session, test_state}; const CSRF: &str = "hooks-test-csrf"; const URL: &str = "https://hooks.example.com/in"; #[test] fn audit_url_strips_userinfo_query_and_fragment() { assert_eq!( audit_url("https://user:s3cret@hooks.example.com:8443/a/b?token=abc&x=1#frag"), "https://hooks.example.com:8443/a/b" ); assert_eq!( audit_url("https://hooks.example.com/in?key=tok"), "https://hooks.example.com/in" ); assert_eq!(audit_url(URL), URL); assert_eq!(audit_url("not a url"), ""); } /// Alice owns `proj` (private), bob is a Write collaborator, carol is outside the repo. struct Fx { store: Store, app: Router, repo: forge_core::Repo, alice: User, } async fn fixture(dir: &Path) -> Fx { let state = test_state(dir).await; let store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); store .create_user("carol", None, "unused", false) .await .expect("carol"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); store .set_collaborator(&repo.id, &bob.id, Role::Write) .await .expect("collaborator"); create_session(&store, &alice.id, "alice-token", CSRF).await; create_session(&store, &bob.id, "bob-token", CSRF).await; create_session(&store, &user_id(&store, "carol").await, "carol-token", CSRF).await; let app = app_router(state.clone()); Fx { store, app, repo, alice, } } async fn user_id(store: &Store, name: &str) -> String { store .user_by_username(name) .await .expect("lookup") .expect("user") .id } fn cookie_for(token: &str) -> String { format!("__Host-{SESSION_COOKIE}={token}") } /// Sends a request. A POST gets the CSRF token and the given fields as a form body. /// Returns the status, the Location header (if any) and the body. async fn send( app: &Router, method: &str, uri: &str, cookie: &str, fields: &[(&str, &str)], ) -> (u16, Option, String) { let mut form = form_urlencoded::Serializer::new(String::new()); if method == "POST" { form.append_pair("csrf", CSRF); } for (name, value) in fields { form.append_pair(name, value); } let mut builder = Request::builder() .method(method) .uri(uri) .header("cookie", cookie); if method == "POST" { builder = builder.header("content-type", "application/x-www-form-urlencoded"); } let req = builder.body(Body::from(form.finish())).expect("request"); let res = app.handle(req).await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); ( status, location, String::from_utf8_lossy(&bytes).into_owned(), ) } fn hook_fields(url: &str, events: &[&'static str]) -> Vec<(&'static str, String)> { let mut fields = vec![("url", url.to_owned()), ("active", "on".to_owned())]; for event in events { fields.push((event, "on".to_owned())); } fields } fn as_refs<'a>(fields: &'a [(&'static str, String)]) -> Vec<(&'a str, &'a str)> { fields.iter().map(|(k, v)| (*k, v.as_str())).collect() } #[tokio::test] async fn admin_creates_hook_and_secret_is_sealed() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let cookie = cookie_for("alice-token"); let secret_text = "plain-secret-7f3a9c"; let mut fields = hook_fields(URL, &["ev_push", "ev_issues"]); fields.push(("secret", secret_text.to_owned())); let (status, location, body) = send( &fx.app, "POST", "/alice/proj/settings/hooks/new", &cookie, &as_refs(&fields), ) .await; assert_eq!(status, 303, "body: {body}"); let location = location.expect("location"); assert!( location.starts_with("/alice/proj/settings/hooks/"), "{location}" ); assert!(location.ends_with("?notice=created"), "{location}"); let hooks = fx.store.list_webhooks(&fx.repo.id).await.expect("hooks"); assert_eq!(hooks.len(), 1); let hook = &hooks[0].0; assert_eq!(hook.url, URL); assert!(hook.active); assert_eq!(hook.events, vec![WebhookEvent::Push, WebhookEvent::Issues]); assert_eq!( fx.store .webhook_secret(&hook.id, &KEY) .await .expect("secret"), secret_text ); // The audit entry names the hook and its URL, never the secret. let filter = forge_core::AuditFilter { action: Some(AuditAction::WebhookCreated), ..Default::default() }; let entries = fx.store.list_audit(&filter, None, 10).await.expect("audit"); assert_eq!(entries.len(), 1); assert_eq!(entries[0].target_kind, "repo"); assert_eq!(entries[0].target_name, "alice/proj"); assert!(!entries[0].data.contains(secret_text)); let data: Value = serde_json::from_str(&entries[0].data).expect("json"); assert_eq!(data["hook_id"], hook.id.as_str()); assert_eq!(data["url"], URL); // The plaintext never reaches the database file (or its write-ahead log). for name in ["forge.db", "forge.db-wal"] { let path = tmp.path().join(name); if let Ok(bytes) = std::fs::read(&path) { assert!( !bytes .windows(secret_text.len()) .any(|w| w == secret_text.as_bytes()), "plaintext secret found in {name}" ); } } } #[tokio::test] async fn invalid_urls_and_empty_event_list_show_errors() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let cookie = cookie_for("alice-token"); let cases = [ ( "http://hooks.example.com/in", vec!["ev_push"], "not allowed", ), ( "https://127.0.0.1/in", vec!["ev_push"], "destination not allowed", ), (URL, vec![], "Choose at least one event."), ]; for (url, events, expected) in cases { let fields = hook_fields(url, &events); let (status, _, body) = send( &fx.app, "POST", "/alice/proj/settings/hooks/new", &cookie, &as_refs(&fields), ) .await; assert_eq!(status, 200, "{url}: {body}"); assert!(body.contains(expected), "{url}: {body}"); } assert!( fx.store .list_webhooks(&fx.repo.id) .await .expect("hooks") .is_empty() ); } #[tokio::test] async fn long_secret_is_refused() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let cookie = cookie_for("alice-token"); let long = "x".repeat(257); let mut fields = hook_fields(URL, &["ev_push"]); fields.push(("secret", long)); let (status, _, body) = send( &fx.app, "POST", "/alice/proj/settings/hooks/new", &cookie, &as_refs(&fields), ) .await; assert_eq!(status, 200); assert!(body.contains("at most 256 characters"), "{body}"); assert!( fx.store .list_webhooks(&fx.repo.id) .await .expect("hooks") .is_empty() ); } #[tokio::test] async fn collaborator_is_forbidden_and_outsider_gets_404() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let bob = cookie_for("bob-token"); let carol = cookie_for("carol-token"); let (status, _, _) = send(&fx.app, "GET", "/alice/proj/settings/hooks", &bob, &[]).await; assert_eq!(status, 403); let fields = hook_fields(URL, &["ev_push"]); let (status, _, _) = send( &fx.app, "POST", "/alice/proj/settings/hooks/new", &bob, &as_refs(&fields), ) .await; assert_eq!(status, 403); assert!( fx.store .list_webhooks(&fx.repo.id) .await .expect("hooks") .is_empty() ); // A private repo is invisible to someone outside it. let (status, _, _) = send(&fx.app, "GET", "/alice/proj/settings/hooks", &carol, &[]).await; assert_eq!(status, 404); } #[tokio::test] async fn hook_of_another_repo_is_404() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let other = fx .store .create_repo(&fx.alice.id, "other", "", Visibility::Private, "main") .await .expect("other repo"); let sealed = secret::seal(&KEY, b"s").expect("seal"); let hook = fx .store .create_webhook( &other.id, &fx.alice.id, URL, sealed.as_bytes(), &[WebhookEvent::Push], true, ) .await .expect("hook"); let cookie = cookie_for("alice-token"); let uri = format!("/alice/proj/settings/hooks/{}", hook.id); let (status, _, _) = send(&fx.app, "GET", &uri, &cookie, &[]).await; assert_eq!(status, 404); let (status, _, _) = send( &fx.app, "POST", &format!("{uri}/delete"), &cookie, &[("confirm", "on")], ) .await; assert_eq!(status, 404); assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_some()); } #[tokio::test] async fn edit_with_blank_secret_keeps_the_secret() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let sealed = secret::seal(&KEY, b"old-secret").expect("seal"); let hook = fx .store .create_webhook( &fx.repo.id, &fx.alice.id, URL, sealed.as_bytes(), &[WebhookEvent::Push], true, ) .await .expect("hook"); let cookie = cookie_for("alice-token"); let uri = format!("/alice/proj/settings/hooks/{}", hook.id); let new_url = "https://hooks.example.com/moved"; let fields = hook_fields(new_url, &["ev_create"]); let (status, location, body) = send(&fx.app, "POST", &uri, &cookie, &as_refs(&fields)).await; assert_eq!(status, 303, "{body}"); assert!(location.unwrap_or_default().ends_with("?notice=saved")); let saved = fx .store .webhook(&hook.id) .await .expect("lookup") .expect("hook"); assert_eq!(saved.url, new_url); assert_eq!(saved.events, vec![WebhookEvent::Create]); assert!(saved.active); assert_eq!( fx.store .webhook_secret(&hook.id, &KEY) .await .expect("secret"), "old-secret" ); // A typed secret replaces it. let mut fields = hook_fields(new_url, &["ev_create"]); fields.push(("secret", "new-secret".to_owned())); let (status, _, _) = send(&fx.app, "POST", &uri, &cookie, &as_refs(&fields)).await; assert_eq!(status, 303); assert_eq!( fx.store .webhook_secret(&hook.id, &KEY) .await .expect("secret"), "new-secret" ); } #[tokio::test] async fn delete_needs_confirmation_then_removes_the_hook() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let sealed = secret::seal(&KEY, b"s").expect("seal"); let hook = fx .store .create_webhook( &fx.repo.id, &fx.alice.id, URL, sealed.as_bytes(), &[WebhookEvent::Push], true, ) .await .expect("hook"); let cookie = cookie_for("alice-token"); let uri = format!("/alice/proj/settings/hooks/{}/delete", hook.id); let (status, location, _) = send(&fx.app, "POST", &uri, &cookie, &[]).await; assert_eq!(status, 303); assert!(location.unwrap_or_default().ends_with("?notice=confirm")); assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_some()); let (status, location, _) = send(&fx.app, "POST", &uri, &cookie, &[("confirm", "on")]).await; assert_eq!(status, 303); assert!(location.unwrap_or_default().ends_with("?notice=deleted")); assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_none()); } #[tokio::test] async fn delivery_body_is_escaped_on_the_hook_page() { let tmp = tempfile::tempdir().expect("tempdir"); let fx = fixture(tmp.path()).await; let sealed = secret::seal(&KEY, b"s").expect("seal"); let hook = fx .store .create_webhook( &fx.repo.id, &fx.alice.id, URL, sealed.as_bytes(), &[WebhookEvent::Issues], true, ) .await .expect("hook"); fx.store .enqueue_delivery( &hook.id, WebhookEvent::Issues, "opened", "{\"X-Forge-Event\":\"issues\"}", "{\"title\":\"\"}", ) .await .expect("delivery"); let cookie = cookie_for("alice-token"); let uri = format!("/alice/proj/settings/hooks/{}", hook.id); let (status, _, body) = send(&fx.app, "GET", &uri, &cookie, &[]).await; assert_eq!(status, 200); assert!(body.contains("<script>alert(1)"), "{body}"); assert!(!body.contains("