forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
56.4 KiB
1//! Page: repo webhooks (`/{owner}/{repo}/settings/hooks`). Admins of the repo (its owner, an instance
2//! admin, or a collaborator with the Admin role) list the hooks, add and edit them, send a test ping,
3//! redeliver a delivery and delete a hook. The hook page lists the last 50 deliveries with their request
4//! and response. A hook's secret is sealed with the instance key and is never shown again: a blank
5//! secret on edit keeps the stored one. Archived repos show everything but change nothing.
6
7use forge_core::AuditAction;
8use forge_core::secret;
9use forge_core::tz::format_in_tz;
10use forge_core::webhooks::{Delivery, DeliveryState, Webhook, WebhookEvent};
11use serde::Deserialize;
12use serde_json::Value;
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15use topcoat::Result;
16use topcoat::context::{Cx, app_context};
17use topcoat::router::content::Form;
18use topcoat::router::error::{SeeOther, forbidden, not_found, see_other};
19use topcoat::router::request::uri;
20use topcoat::router::{page, path_param, route};
21use topcoat::view::{View, component, view};
22
23use crate::audit::{self, Actor, Target};
24use crate::auth::{require_csrf, require_user};
25use crate::i18n::{
26 self, Lang,
27 integrations::{self as g, K},
28};
29use crate::pages::repo::repo_head;
30use crate::pages::repo_ctx::{RepoCtx, RepoHead, load_repo};
31use crate::pages::repo_settings::settings_nav_for;
32use crate::state::AppState;
33use crate::views::{alert, enc, shell};
34use crate::webhooks::client::{WebhookPolicy, validate_url};
35use crate::webhooks::emit::emit_ping;
36
37path_param!(owner);
38path_param!(repo);
39path_param!(hook_id);
40path_param!(delivery_id);
41
42/// Longest secret accepted, in characters.
43const MAX_SECRET_CHARS: usize = 256;
44/// Deliveries listed on a hook page.
45const DELIVERIES_SHOWN: u32 = 50;
46
47/// Notice words (the only text a `?notice=` query can show), their text key, and whether they are errors.
48const NOTICES: [(&str, K, bool); 6] = [
49 ("created", K::HookNoticeCreated, false),
50 ("saved", K::HookNoticeSaved, false),
51 ("tested", K::HookNoticeTested, false),
52 ("redelivered", K::HookNoticeRedelivered, false),
53 ("deleted", K::HookNoticeDeleted, false),
54 ("confirm", K::HookNoticeConfirm, true),
55];
56
57/// The events a hook can subscribe to in the form, with their labels. `ping` is sent by the forge
58/// itself, so it is not offered. Each checkbox is named `ev_<event name>`.
59const EVENT_CHOICES: [(WebhookEvent, K); 8] = [
60 (WebhookEvent::Push, K::EvPush),
61 (WebhookEvent::PullRequest, K::EvPullRequest),
62 (WebhookEvent::Issues, K::EvIssues),
63 (WebhookEvent::IssueComment, K::EvIssueComment),
64 (WebhookEvent::PullRequestReview, K::EvPullRequestReview),
65 (WebhookEvent::Create, K::EvCreate),
66 (WebhookEvent::Delete, K::EvDelete),
67 (WebhookEvent::Release, K::EvRelease),
68];
69
70/// The hook form. Unticked boxes are absent from the body, so each event has an `Option`.
71#[derive(Deserialize)]
72struct HookForm {
73 #[serde(default)]
74 csrf: String,
75 #[serde(default)]
76 url: String,
77 /// Empty on edit keeps the stored secret.
78 #[serde(default)]
79 secret: String,
80 #[serde(default)]
81 active: Option<String>,
82 #[serde(default)]
83 ev_push: Option<String>,
84 #[serde(default)]
85 ev_pull_request: Option<String>,
86 #[serde(default)]
87 ev_issues: Option<String>,
88 #[serde(default)]
89 ev_issue_comment: Option<String>,
90 #[serde(default)]
91 ev_pull_request_review: Option<String>,
92 #[serde(default)]
93 ev_create: Option<String>,
94 #[serde(default)]
95 ev_delete: Option<String>,
96 #[serde(default)]
97 ev_release: Option<String>,
98}
99
100impl HookForm {
101 /// The ticked events, in the form's order.
102 fn events(&self) -> Vec<WebhookEvent> {
103 [
104 (WebhookEvent::Push, &self.ev_push),
105 (WebhookEvent::PullRequest, &self.ev_pull_request),
106 (WebhookEvent::Issues, &self.ev_issues),
107 (WebhookEvent::IssueComment, &self.ev_issue_comment),
108 (
109 WebhookEvent::PullRequestReview,
110 &self.ev_pull_request_review,
111 ),
112 (WebhookEvent::Create, &self.ev_create),
113 (WebhookEvent::Delete, &self.ev_delete),
114 (WebhookEvent::Release, &self.ev_release),
115 ]
116 .into_iter()
117 .filter(|(_, ticked)| ticked.is_some())
118 .map(|(event, _)| event)
119 .collect()
120 }
121}
122
123#[derive(Deserialize)]
124struct CsrfForm {
125 #[serde(default)]
126 csrf: String,
127}
128
129#[derive(Deserialize)]
130struct DeleteForm {
131 #[serde(default)]
132 csrf: String,
133 #[serde(default)]
134 confirm: Option<String>,
135}
136
137/// The values the hook form shows, plus the validation error to show above it.
138struct HookDraft {
139 url: String,
140 events: Vec<WebhookEvent>,
141 active: bool,
142 error: Option<String>,
143}
144
145impl HookDraft {
146 fn of_hook(hook: &Webhook) -> Self {
147 Self {
148 url: hook.url.clone(),
149 events: hook.events.clone(),
150 active: hook.active,
151 error: None,
152 }
153 }
154
155 /// The new-hook form starts with every event ticked and the hook active.
156 fn blank() -> Self {
157 Self {
158 url: String::new(),
159 events: EVENT_CHOICES.iter().map(|(event, _)| *event).collect(),
160 active: true,
161 error: None,
162 }
163 }
164
165 fn of_form(form: &HookForm, error: String) -> Self {
166 Self {
167 url: form.url.trim().to_owned(),
168 events: form.events(),
169 active: form.active.is_some(),
170 error: Some(error),
171 }
172 }
173}
174
175/// One event checkbox as the form shows it.
176struct EventBox {
177 name: String,
178 label: &'static str,
179 checked: bool,
180}
181
182fn event_boxes(lang: Lang, selected: &[WebhookEvent]) -> Vec<EventBox> {
183 EVENT_CHOICES
184 .iter()
185 .map(|(event, label)| EventBox {
186 name: format!("ev_{}", event.as_str()),
187 label: g::t(lang, *label),
188 checked: selected.contains(event),
189 })
190 .collect()
191}
192
193/// The hook URL as the audit log may keep it: scheme, host, port and path only. Userinfo, query and
194/// fragment often carry tokens, so they are dropped. An unparsable URL is not logged at all.
195fn audit_url(raw: &str) -> String {
196 match reqwest::Url::parse(raw) {
197 Ok(mut url) => {
198 let _ = url.set_username("");
199 let _ = url.set_password(None);
200 url.set_query(None);
201 url.set_fragment(None);
202 url.to_string()
203 }
204 Err(_) => String::new(),
205 }
206}
207
208/// The webhook policy from config. A bad `[webhooks]` section is a server error, not a user error.
209fn policy(state: &AppState) -> Result<WebhookPolicy> {
210 Ok(WebhookPolicy::from_config(&state.config.webhooks).map_err(forge_core::Error::Config)?)
211}
212
213/// Checks the form values. The message is shown to the user, so it says what to fix.
214fn check_form(
215 lang: Lang,
216 url: &str,
217 secret: &str,
218 events: &[WebhookEvent],
219 policy: &WebhookPolicy,
220) -> std::result::Result<(), String> {
221 if url.is_empty() {
222 return Err(g::t(lang, K::ErrUrlMissing).to_owned());
223 }
224 validate_url(url, policy).map_err(|e| e.to_string())?;
225 if events.is_empty() {
226 return Err(g::t(lang, K::ErrNoEvent).to_owned());
227 }
228 if secret.chars().count() > MAX_SECRET_CHARS {
229 return Err(g::secret_too_long(lang, MAX_SECRET_CHARS));
230 }
231 Ok(())
232}
233
234/// The viewer's language for a repo page.
235fn lang_of(cx: &Cx, ctx: &RepoCtx) -> Lang {
236 i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user))
237}
238
239/// The repo's `owner/name`, as the settings pages build it.
240fn title_of(ctx: &RepoCtx) -> String {
241 format!("{}/{}", ctx.repo.owner_name, ctx.repo.name)
242}
243
244fn hooks_href(title: &str) -> String {
245 format!("/{title}/settings/hooks")
246}
247
248fn hook_href(title: &str, hook_id: &str) -> String {
249 format!("{}/{}", hooks_href(title), enc(hook_id))
250}
251
252/// The notice from `?notice=<word>`. Anything else is ignored.
253fn notice_from_query(cx: &Cx, lang: Lang) -> Option<(&'static str, bool)> {
254 let query = uri(cx).query()?;
255 let word = form_urlencoded::parse(query.as_bytes())
256 .find(|(key, _)| key == "notice")
257 .map(|(_, value)| value.into_owned())?;
258 NOTICES
259 .iter()
260 .find(|(name, _, _)| *name == word)
261 .map(|(_, key, is_error)| (g::t(lang, *key), *is_error))
262}
263
264fn back_to_hook(title: &str, hook_id: &str, notice: &str) -> SeeOther {
265 see_other(format!("{}?notice={notice}", hook_href(title, hook_id)))
266}
267
268/// Reads an RFC 3339 stamp and shows it in the viewer's timezone. An unreadable stamp is shown as is.
269fn local_stamp(stamp: &str, tz: &str) -> String {
270 match OffsetDateTime::parse(stamp, &Rfc3339) {
271 Ok(at) => format_in_tz(at.unix_timestamp(), tz),
272 Err(_) => stamp.to_owned(),
273 }
274}
275
276/// Pretty-prints a JSON body. Anything that is not JSON is shown as it is.
277fn pretty_body(raw: &str) -> String {
278 match serde_json::from_str::<Value>(raw) {
279 Ok(value) => serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.to_owned()),
280 Err(_) => raw.to_owned(),
281 }
282}
283
284/// Header lines `name: value` from a JSON object of headers. Anything else is shown as it is.
285fn header_lines(raw: &str) -> String {
286 match serde_json::from_str::<serde_json::Map<String, Value>>(raw) {
287 Ok(map) => map
288 .iter()
289 .map(|(name, value)| match value.as_str() {
290 Some(text) => format!("{name}: {text}"),
291 None => format!("{name}: {value}"),
292 })
293 .collect::<Vec<_>>()
294 .join("\n"),
295 Err(_) => raw.to_owned(),
296 }
297}
298
299fn state_label(lang: Lang, state: DeliveryState) -> (&'static str, &'static str) {
300 match state {
301 DeliveryState::Pending => ("pending", g::t(lang, K::StatePending)),
302 DeliveryState::Ok => ("ok", g::t(lang, K::StateOk)),
303 DeliveryState::Failed => ("failed", g::t(lang, K::StateFailed)),
304 DeliveryState::GaveUp => ("gave_up", g::t(lang, K::StateGaveUp)),
305 }
306}
307
308/// One hook row on the list page.
309struct HookRow {
310 href: String,
311 url: String,
312 events: String,
313 active: bool,
314 last: Option<(&'static str, &'static str)>,
315}
316
317/// One delivery on the hook page, with every value already formatted for display.
318struct DeliveryRow {
319 state_class: &'static str,
320 state_text: &'static str,
321 event: String,
322 guid: String,
323 time: String,
324 duration: String,
325 status: String,
326 request_headers: String,
327 request_body: String,
328 response_headers: String,
329 response_body: String,
330 error: String,
331 redeliver_action: String,
332}
333
334fn delivery_row(
335 lang: Lang,
336 delivery: &Delivery,
337 tz: &str,
338 redeliver_action: String,
339) -> DeliveryRow {
340 let (state_class, state_text) = state_label(lang, delivery.state);
341 let event = if delivery.action.is_empty() {
342 delivery.event.as_str().to_owned()
343 } else {
344 format!("{}: {}", delivery.event.as_str(), delivery.action)
345 };
346 let status = match (delivery.response_status, &delivery.error) {
347 (Some(code), _) => code.to_string(),
348 (None, Some(_)) => g::t(lang, K::NoResponse).to_owned(),
349 (None, None) => g::t(lang, K::NoneYet).to_owned(),
350 };
351 DeliveryRow {
352 state_class,
353 state_text,
354 event,
355 guid: delivery.guid.clone(),
356 time: local_stamp(&delivery.created_at, tz),
357 duration: delivery
358 .duration_ms
359 .map_or_else(|| "-".to_owned(), |ms| format!("{ms} ms")),
360 status,
361 request_headers: header_lines(&delivery.request_headers),
362 request_body: pretty_body(&delivery.request_body),
363 response_headers: delivery
364 .response_headers
365 .as_deref()
366 .map(header_lines)
367 .unwrap_or_default(),
368 response_body: delivery
369 .response_body
370 .as_deref()
371 .map(pretty_body)
372 .unwrap_or_default(),
373 error: delivery.error.clone().unwrap_or_default(),
374 redeliver_action,
375 }
376}
377
378/// The repo fields the hook pages show, read once from the repo context.
379struct Frame {
380 page_title: String,
381 title: String,
382 archived: bool,
383 head: RepoHead,
384}
385
386impl Frame {
387 async fn new(ctx: &RepoCtx, state: &AppState, lang: Lang) -> Result<Self> {
388 let title = title_of(ctx);
389 Ok(Self {
390 page_title: format!("{title} \u{b7} {}", g::t(lang, K::Webhooks)),
391 title,
392 archived: ctx.repo.archived,
393 head: ctx.head(state, "settings").await?,
394 })
395 }
396}
397
398/// Header, sub-navigation, the one-line notice, and the archived note.
399#[component]
400async fn frame_top(
401 head: RepoHead,
402 title: String,
403 notice: Option<(&'static str, bool)>,
404 archived: bool,
405 lang: Lang,
406) -> Result<impl View> {
407 Ok(view! {
408 repo_head(head: head)
409 settings_nav_for(lang: lang, title: title, current: "webhooks")
410 if let Some((text, is_error)) = notice {
411 if is_error {
412 alert(message: text)
413 } else {
414 <section class="card notice" role="status">(text)</section>
415 }
416 }
417 if archived {
418 <section class="card notice" role="status">(g::t(lang, K::HookArchivedNotice))</section>
419 }
420 })
421}
422
423/// The add or edit form. `secret_hint` says what the secret field does for this form.
424#[component]
425async fn hook_form(
426 action: String,
427 csrf: String,
428 url: String,
429 events: Vec<EventBox>,
430 active: bool,
431 secret_hint: String,
432 error: Option<String>,
433 submit: &'static str,
434 lang: Lang,
435) -> Result<impl View> {
436 Ok(view! {
437 <form method="post" action=(action.as_str()) class="stack webhook-form">
438 if let Some(message) = error {
439 alert(message: message.as_str())
440 }
441 <input type="hidden" name="csrf" value=(csrf.as_str())>
442 <label>
443 <span>(g::t(lang, K::PayloadUrl))</span>
444 <input type="url" name="url" value=(url.as_str()) autocomplete="off" spellcheck="false" required=(true)>
445 </label>
446 <p class="muted small">(g::t(lang, K::UrlHintNew))</p>
447 <label>
448 <span>(g::t(lang, K::Secret))</span>
449 <input type="password" name="secret" autocomplete="new-password" maxlength="256">
450 </label>
451 <p class="muted small">(secret_hint.as_str()) (g::t(lang, K::SignedWith))</p>
452 <fieldset class="event-choices">
453 <legend>(g::t(lang, K::Events))</legend>
454 for event in events.iter() {
455 <label class="inline">
456 <input type="checkbox" name=(event.name.as_str()) value="on" checked=(event.checked)>
457 <span>(event.label)</span>
458 </label>
459 }
460 </fieldset>
461 <label class="inline">
462 <input type="checkbox" name="active" value="on" checked=(active)>
463 <span>(g::t(lang, K::ActiveField))</span>
464 </label>
465 <div>
466 <button type="submit" class="primary">(submit)</button>
467 </div>
468 </form>
469 })
470}
471
472#[component]
473async fn hooks_list_body(
474 head: RepoHead,
475 title: String,
476 notice: Option<(&'static str, bool)>,
477 archived: bool,
478 rows: Vec<HookRow>,
479 lang: Lang,
480) -> Result<impl View> {
481 let new_href = format!("{}/new", hooks_href(&title));
482 Ok(view! {
483 frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)
484 <section class="card">
485 <h2>(g::t(lang, K::Webhooks))</h2>
486 <p class="muted small">(g::t(lang, K::WebhooksIntro))</p>
487 if !archived {
488 <p><a href=(new_href.as_str()) class="button">(g::t(lang, K::AddWebhook))</a></p>
489 }
490 if rows.is_empty() {
491 <p class="muted">(g::t(lang, K::NoWebhooks))</p>
492 } else {
493 <div class="table-wrap">
494 <table class="table webhooks">
495 <thead>
496 <tr>
497 <th>"URL"</th>
498 <th>(g::t(lang, K::ColEvents))</th>
499 <th>(g::t(lang, K::ColActive))</th>
500 <th>(g::t(lang, K::ColLastDelivery))</th>
501 </tr>
502 </thead>
503 <tbody>
504 for row in rows.iter() {
505 <tr>
506 <td><a href=(row.href.as_str())><code>(row.url.as_str())</code></a></td>
507 <td>(row.events.as_str())</td>
508 <td>(if row.active { g::t(lang, K::Yes) } else { g::t(lang, K::No) })</td>
509 <td>
510 if let Some((class, text)) = row.last {
511 <span class=(format!("hook-state {class}"))>(text)</span>
512 } else {
513 <span class="muted">(g::t(lang, K::NoneYet))</span>
514 }
515 </td>
516 </tr>
517 }
518 </tbody>
519 </table>
520 </div>
521 }
522 </section>
523 })
524}
525
526#[component]
527async fn delivery_list(
528 deliveries: Vec<DeliveryRow>,
529 csrf: String,
530 archived: bool,
531 lang: Lang,
532) -> Result<impl View> {
533 Ok(view! {
534 if deliveries.is_empty() {
535 <p class="muted">(g::t(lang, K::NoDeliveries))</p>
536 }
537 for row in deliveries.iter() {
538 <details class="delivery">
539 <summary>
540 <span class=(format!("hook-state {}", row.state_class))>(row.state_text)</span>
541 " "
542 <code>(row.event.as_str())</code>
543 " "
544 <span class="muted small">(row.time.as_str())</span>
545 " "
546 <span class="muted small">(row.duration.as_str())</span>
547 " "
548 <span class="small">(g::t(lang, K::StatusLower))" " (row.status.as_str())</span>
549 </summary>
550 <div class="delivery-body">
551 <p class="muted small">"Guid " <code>(row.guid.as_str())</code></p>
552 <h4>(g::t(lang, K::RequestHeaders))</h4>
553 <pre class="code-block">(row.request_headers.as_str())</pre>
554 <h4>(g::t(lang, K::RequestBody))</h4>
555 <pre class="code-block">(row.request_body.as_str())</pre>
556 <h4>(g::t(lang, K::Response))</h4>
557 if !row.error.is_empty() {
558 <p class="alert" role="alert">(row.error.as_str())</p>
559 }
560 <p class="small">(g::t(lang, K::Status))" " (row.status.as_str())</p>
561 <h4>(g::t(lang, K::ResponseHeaders))</h4>
562 <pre class="code-block">(row.response_headers.as_str())</pre>
563 <h4>(g::t(lang, K::ResponseBody))</h4>
564 <pre class="code-block">(row.response_body.as_str())</pre>
565 if !archived {
566 <form method="post" action=(row.redeliver_action.as_str()) class="inline-form">
567 <input type="hidden" name="csrf" value=(csrf.as_str())>
568 <button type="submit">(g::t(lang, K::Redeliver))</button>
569 </form>
570 }
571 </div>
572 </details>
573 }
574 })
575}
576
577#[component]
578async fn hook_body(
579 head: RepoHead,
580 title: String,
581 notice: Option<(&'static str, bool)>,
582 archived: bool,
583 csrf: String,
584 hook_id: String,
585 draft: HookDraft,
586 secret_hint: String,
587 deliveries: Vec<DeliveryRow>,
588 lang: Lang,
589) -> Result<impl View> {
590 let edit_action = hook_href(&title, &hook_id);
591 let test_action = format!("{edit_action}/test");
592 let delete_action = format!("{edit_action}/delete");
593 let events = event_boxes(lang, &draft.events);
594 Ok(view! {
595 frame_top(head: head, title: title.clone(), notice: notice, archived: archived, lang: lang)
596 <section class="card">
597 <h2>(g::t(lang, K::EditWebhook))</h2>
598 if archived {
599 <p class="muted small">(g::t(lang, K::ArchivedHookNote))</p>
600 <p><code>(draft.url.as_str())</code></p>
601 } else {
602 hook_form(action: edit_action.clone(), csrf: csrf.clone(), url: draft.url,
603 events: events, active: draft.active, secret_hint: secret_hint,
604 error: draft.error, submit: g::t(lang, K::SaveWebhook), lang: lang)
605 }
606 </section>
607 if !archived {
608 <section class="card">
609 <h2>(g::t(lang, K::Test))</h2>
610 <p class="muted small">(g::t(lang, K::TestIntro))</p>
611 <form method="post" action=(test_action.as_str()) class="inline-form">
612 <input type="hidden" name="csrf" value=(csrf.as_str())>
613 <button type="submit">(g::t(lang, K::SendTestPing))</button>
614 </form>
615 </section>
616 }
617 <section class="card">
618 <h2>(g::t(lang, K::RecentDeliveries))</h2>
619 <p class="muted small">(g::recent_deliveries(lang, DELIVERIES_SHOWN))</p>
620 delivery_list(deliveries: deliveries, csrf: csrf.clone(), archived: archived, lang: lang)
621 </section>
622 if !archived {
623 <section class="card">
624 <h2>(g::t(lang, K::DeleteWebhook))</h2>
625 <p class="muted small">(g::t(lang, K::DeleteIntro))</p>
626 <form method="post" action=(delete_action.as_str()) class="stack">
627 <input type="hidden" name="csrf" value=(csrf.as_str())>
628 <label class="inline">
629 <input type="checkbox" name="confirm" value="on">
630 <span>(g::t(lang, K::DeleteConfirm))</span>
631 </label>
632 <div>
633 <button type="submit" class="danger">(g::t(lang, K::DeleteWebhook))</button>
634 </div>
635 </form>
636 </section>
637 }
638 })
639}
640
641/// Lists the repo's hooks. Admins only: a hidden repo is 404, any other viewer is 403.
642#[page("/{owner}/{repo}/settings/hooks")]
643async fn hooks_list_page(cx: &Cx) -> Result<impl View> {
644 let owner = path_param::<Owner>(cx);
645 let name = path_param::<Repo>(cx);
646 let ctx = load_repo(cx, owner, name).await?;
647 require_user(cx).await?;
648 if !ctx.can_admin() {
649 return Err(forbidden().into());
650 }
651 let state = app_context::<AppState>(cx);
652 let hooks = state.store.list_webhooks(&ctx.repo.id).await?;
653 let lang = lang_of(cx, &ctx);
654 let frame = Frame::new(&ctx, state, lang).await?;
655 let rows: Vec<HookRow> = hooks
656 .into_iter()
657 .map(|(hook, last)| HookRow {
658 href: hook_href(&frame.title, &hook.id),
659 url: hook.url,
660 events: hook
661 .events
662 .iter()
663 .map(|e| e.as_str())
664 .collect::<Vec<_>>()
665 .join(", "),
666 active: hook.active,
667 last: last.map(|last| state_label(lang, last)),
668 })
669 .collect();
670 let notice = notice_from_query(cx, lang);
671 Ok(view! {
672 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
673 hooks_list_body(head: frame.head, title: frame.title, notice: notice,
674 archived: frame.archived, rows: rows, lang: lang))
675 })
676}
677
678/// The add form.
679#[page("/{owner}/{repo}/settings/hooks/new")]
680async fn hook_new_page(cx: &Cx) -> Result<impl View> {
681 let owner = path_param::<Owner>(cx);
682 let name = path_param::<Repo>(cx);
683 let ctx = load_repo(cx, owner, name).await?;
684 let (_, session) = require_user(cx).await?;
685 if !ctx.can_admin() {
686 return Err(forbidden().into());
687 }
688 let state = app_context::<AppState>(cx);
689 let lang = lang_of(cx, &ctx);
690 let frame = Frame::new(&ctx, state, lang).await?;
691 let csrf = session.csrf_token.clone();
692 let draft = HookDraft::blank();
693 let action = format!("{}/new", hooks_href(&frame.title));
694 Ok(view! {
695 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
696 hook_new_body(head: frame.head, title: frame.title, archived: frame.archived,
697 csrf: csrf, action: action, draft: draft, lang: lang))
698 })
699}
700
701#[component]
702async fn hook_new_body(
703 head: RepoHead,
704 title: String,
705 archived: bool,
706 csrf: String,
707 action: String,
708 draft: HookDraft,
709 lang: Lang,
710) -> Result<impl View> {
711 let events = event_boxes(lang, &draft.events);
712 let back = hooks_href(&title);
713 Ok(view! {
714 frame_top(head: head, title: title.clone(), notice: None, archived: archived, lang: lang)
715 <section class="card">
716 <h2>(g::t(lang, K::AddWebhook))</h2>
717 <p class="muted small">(g::t(lang, K::AddWebhookIntro))</p>
718 hook_form(action: action, csrf: csrf, url: draft.url, events: events,
719 active: draft.active, secret_hint: g::t(lang, K::SecretHintNew).to_owned(),
720 error: draft.error, submit: g::t(lang, K::CreateWebhook), lang: lang)
721 <p><a href=(back.as_str())>(g::t(lang, K::BackToWebhooks))</a></p>
722 </section>
723 })
724}
725
726/// Creates a hook. The secret is sealed before it is stored. A ping is queued on success.
727#[page(POST "/{owner}/{repo}/settings/hooks/new")]
728async fn hook_create(cx: &Cx, Form(form): Form<HookForm>) -> Result<impl View> {
729 let owner = path_param::<Owner>(cx);
730 let name = path_param::<Repo>(cx);
731 let ctx = load_repo(cx, owner, name).await?;
732 let (user, session) = require_csrf(cx, &form.csrf).await?;
733 if !ctx.can_admin() || ctx.repo.archived {
734 return Err(forbidden().into());
735 }
736 let state = app_context::<AppState>(cx);
737 let url = form.url.trim().to_owned();
738 let events = form.events();
739 let lang = lang_of(cx, &ctx);
740 let checked = check_form(lang, &url, &form.secret, &events, &policy(state)?);
741 if let Err(message) = checked {
742 let frame = Frame::new(&ctx, state, lang).await?;
743 let csrf = session.csrf_token.clone();
744 let action = format!("{}/new", hooks_href(&frame.title));
745 let draft = HookDraft::of_form(&form, message);
746 return Ok(view! {
747 shell(title: frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
748 hook_new_body(head: frame.head, title: frame.title, archived: frame.archived,
749 csrf: csrf, action: action, draft: draft, lang: lang))
750 });
751 }
752 let sealed = secret::seal(&state.key, form.secret.as_bytes())?;
753 let hook = state
754 .store
755 .create_webhook(
756 &ctx.repo.id,
757 &user.id,
758 &url,
759 sealed.as_bytes(),
760 &events,
761 form.active.is_some(),
762 )
763 .await?;
764 audit::record(
765 &state.store,
766 Some(cx),
767 Actor::User(&user),
768 AuditAction::WebhookCreated,
769 Target {
770 kind: "repo",
771 id: &ctx.repo.id,
772 name: &title_of(&ctx),
773 },
774 serde_json::json!({"hook_id": hook.id, "url": audit_url(&hook.url)}),
775 )
776 .await;
777 if let Err(err) = emit_ping(state, &hook).await {
778 tracing::error!(hook_id = %hook.id, error = %err, "could not queue the ping for a new webhook");
779 }
780 tracing::info!(
781 actor_id = %user.id,
782 repo_id = %ctx.repo.id,
783 hook_id = %hook.id,
784 "webhook created"
785 );
786 Err(back_to_hook(&title_of(&ctx), &hook.id, "created").into())
787}
788
789/// The hook in the path, when it belongs to this repo. Any other hook answers 404, so another
790/// repo's hook id reveals nothing.
791async fn repo_hook(state: &AppState, ctx: &RepoCtx, id: &str) -> Result<Webhook> {
792 match state.store.webhook(id).await? {
793 Some(hook) if hook.repo_id == ctx.repo.id => Ok(hook),
794 _ => Err(not_found().into()),
795 }
796}
797
798/// The hint above the secret field: whether a secret is stored. The secret is opened only to see
799/// whether it is empty, and is not kept.
800async fn secret_hint(lang: Lang, state: &AppState, hook_id: &str) -> Result<String> {
801 let set = !state
802 .store
803 .webhook_secret(hook_id, &state.key)
804 .await?
805 .is_empty();
806 Ok(if set {
807 g::t(lang, K::SecretHintSet)
808 } else {
809 g::t(lang, K::SecretHintUnset)
810 }
811 .to_owned())
812}
813
814/// The last deliveries of a hook, newest first, each with its redeliver action.
815async fn delivery_rows(
816 lang: Lang,
817 state: &AppState,
818 ctx: &RepoCtx,
819 hook: &Webhook,
820) -> Result<Vec<DeliveryRow>> {
821 let title = title_of(ctx);
822 let tz = ctx.viewer_tz();
823 let deliveries = state
824 .store
825 .list_deliveries(&hook.id, DELIVERIES_SHOWN)
826 .await?;
827 Ok(deliveries
828 .iter()
829 .map(|delivery| {
830 let action = format!(
831 "{}/deliveries/{}/redeliver",
832 hook_href(&title, &hook.id),
833 enc(&delivery.id)
834 );
835 delivery_row(lang, delivery, tz, action)
836 })
837 .collect())
838}
839
840/// What the hook page shows besides the form: the frame, the deliveries and the secret hint.
841struct HookPage {
842 frame: Frame,
843 deliveries: Vec<DeliveryRow>,
844 hint: String,
845}
846
847async fn hook_page_data(
848 lang: Lang,
849 ctx: &RepoCtx,
850 state: &AppState,
851 hook: &Webhook,
852) -> Result<HookPage> {
853 Ok(HookPage {
854 frame: Frame::new(ctx, state, lang).await?,
855 deliveries: delivery_rows(lang, state, ctx, hook).await?,
856 hint: secret_hint(lang, state, &hook.id).await?,
857 })
858}
859
860/// The hook page: its form, a test button, the last deliveries and the delete form.
861#[page("/{owner}/{repo}/settings/hooks/{hook_id}")]
862async fn hook_page(cx: &Cx) -> Result<impl View> {
863 let owner = path_param::<Owner>(cx);
864 let name = path_param::<Repo>(cx);
865 let id = path_param::<HookId>(cx);
866 let ctx = load_repo(cx, owner, name).await?;
867 let (_, session) = require_user(cx).await?;
868 if !ctx.can_admin() {
869 return Err(forbidden().into());
870 }
871 let state = app_context::<AppState>(cx);
872 let hook = repo_hook(state, &ctx, id).await?;
873 let draft = HookDraft::of_hook(&hook);
874 let csrf = session.csrf_token.clone();
875 let lang = lang_of(cx, &ctx);
876 let notice = notice_from_query(cx, lang);
877 let page = hook_page_data(lang, &ctx, state, &hook).await?;
878 let hook_id = hook.id.clone();
879 Ok(view! {
880 shell(title: page.frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
881 hook_body(head: page.frame.head, title: page.frame.title, notice: notice,
882 archived: page.frame.archived, csrf: csrf, hook_id: hook_id, draft: draft,
883 secret_hint: page.hint, deliveries: page.deliveries, lang: lang))
884 })
885}
886
887/// Saves a hook. A blank secret keeps the stored one. A refused form shows the error on the hook
888/// page with the submitted values.
889#[page(POST "/{owner}/{repo}/settings/hooks/{hook_id}")]
890async fn hook_update(cx: &Cx, Form(form): Form<HookForm>) -> Result<impl View> {
891 let owner = path_param::<Owner>(cx);
892 let name = path_param::<Repo>(cx);
893 let id = path_param::<HookId>(cx);
894 let ctx = load_repo(cx, owner, name).await?;
895 let (user, session) = require_csrf(cx, &form.csrf).await?;
896 if !ctx.can_admin() || ctx.repo.archived {
897 return Err(forbidden().into());
898 }
899 let state = app_context::<AppState>(cx);
900 let hook = repo_hook(state, &ctx, id).await?;
901 let url = form.url.trim().to_owned();
902 let events = form.events();
903 let lang = lang_of(cx, &ctx);
904 if let Err(message) = check_form(lang, &url, &form.secret, &events, &policy(state)?) {
905 let draft = HookDraft::of_form(&form, message);
906 let csrf = session.csrf_token.clone();
907 let page = hook_page_data(lang, &ctx, state, &hook).await?;
908 let hook_id = hook.id.clone();
909 return Ok(view! {
910 shell(title: page.frame.page_title.as_str(), viewer: ctx.viewer.as_ref(),
911 hook_body(head: page.frame.head, title: page.frame.title, notice: None,
912 archived: page.frame.archived, csrf: csrf, hook_id: hook_id, draft: draft,
913 secret_hint: page.hint, deliveries: page.deliveries, lang: lang))
914 });
915 }
916 let sealed = if form.secret.is_empty() {
917 None
918 } else {
919 Some(secret::seal(&state.key, form.secret.as_bytes())?)
920 };
921 state
922 .store
923 .update_webhook(
924 &hook.id,
925 &url,
926 sealed.as_ref().map(String::as_bytes),
927 &events,
928 form.active.is_some(),
929 )
930 .await?;
931 audit::record(
932 &state.store,
933 Some(cx),
934 Actor::User(&user),
935 AuditAction::WebhookUpdated,
936 Target {
937 kind: "repo",
938 id: &ctx.repo.id,
939 name: &title_of(&ctx),
940 },
941 serde_json::json!({"hook_id": hook.id, "url": audit_url(&url)}),
942 )
943 .await;
944 tracing::info!(
945 actor_id = %user.id,
946 repo_id = %ctx.repo.id,
947 hook_id = %hook.id,
948 secret_changed = sealed.is_some(),
949 "webhook updated"
950 );
951 Err(back_to_hook(&title_of(&ctx), &hook.id, "saved").into())
952}
953
954/// Sends a ping to the hook now.
955#[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/test")]
956async fn hook_test(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
957 let owner = path_param::<Owner>(cx);
958 let name = path_param::<Repo>(cx);
959 let id = path_param::<HookId>(cx);
960 let ctx = load_repo(cx, owner, name).await?;
961 require_csrf(cx, &form.csrf).await?;
962 if !ctx.can_admin() || ctx.repo.archived {
963 return Err(forbidden().into());
964 }
965 let state = app_context::<AppState>(cx);
966 let hook = repo_hook(state, &ctx, id).await?;
967 emit_ping(state, &hook).await?;
968 Ok(back_to_hook(&title_of(&ctx), &hook.id, "tested"))
969}
970
971/// Queues a new delivery with the same payload as an earlier one, then wakes the worker.
972#[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/deliveries/{delivery_id}/redeliver")]
973async fn hook_redeliver(cx: &Cx, Form(form): Form<CsrfForm>) -> Result<SeeOther> {
974 let owner = path_param::<Owner>(cx);
975 let name = path_param::<Repo>(cx);
976 let id = path_param::<HookId>(cx);
977 let delivery = path_param::<DeliveryId>(cx);
978 let ctx = load_repo(cx, owner, name).await?;
979 require_csrf(cx, &form.csrf).await?;
980 if !ctx.can_admin() || ctx.repo.archived {
981 return Err(forbidden().into());
982 }
983 let state = app_context::<AppState>(cx);
984 let hook = repo_hook(state, &ctx, id).await?;
985 match state.store.delivery(delivery).await? {
986 Some(original) if original.hook_id == hook.id => {}
987 _ => return Err(not_found().into()),
988 }
989 state.store.redeliver(delivery).await?;
990 state.webhook_waker.notify_one();
991 Ok(back_to_hook(&title_of(&ctx), &hook.id, "redelivered"))
992}
993
994/// Deletes a hook and its deliveries. The box must be ticked.
995#[route(POST "/{owner}/{repo}/settings/hooks/{hook_id}/delete")]
996async fn hook_delete(cx: &Cx, Form(form): Form<DeleteForm>) -> Result<SeeOther> {
997 let owner = path_param::<Owner>(cx);
998 let name = path_param::<Repo>(cx);
999 let id = path_param::<HookId>(cx);
1000 let ctx = load_repo(cx, owner, name).await?;
1001 let (user, _) = require_csrf(cx, &form.csrf).await?;
1002 if !ctx.can_admin() || ctx.repo.archived {
1003 return Err(forbidden().into());
1004 }
1005 let state = app_context::<AppState>(cx);
1006 let hook = repo_hook(state, &ctx, id).await?;
1007 let title = title_of(&ctx);
1008 if form.confirm.is_none() {
1009 return Ok(back_to_hook(&title, &hook.id, "confirm"));
1010 }
1011 state.store.delete_webhook(&hook.id).await?;
1012 audit::record(
1013 &state.store,
1014 Some(cx),
1015 Actor::User(&user),
1016 AuditAction::WebhookDeleted,
1017 Target {
1018 kind: "repo",
1019 id: &ctx.repo.id,
1020 name: &title_of(&ctx),
1021 },
1022 serde_json::json!({"hook_id": hook.id, "url": audit_url(&hook.url)}),
1023 )
1024 .await;
1025 tracing::info!(
1026 actor_id = %user.id,
1027 repo_id = %ctx.repo.id,
1028 hook_id = %hook.id,
1029 "webhook deleted"
1030 );
1031 Ok(see_other(format!("{}?notice=deleted", hooks_href(&title))))
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036 use std::path::Path;
1037
1038 use super::audit_url;
1039
1040 use forge_core::AuditAction;
1041 use forge_core::secret;
1042 use forge_core::webhooks::{DeliveryState, WebhookEvent};
1043 use forge_core::{Role, Store, User, Visibility};
1044 use forge_git::repo::{init_bare, repo_path};
1045 use serde_json::Value;
1046 use topcoat::router::request::Request;
1047 use topcoat::router::{Body, Router, to_bytes};
1048
1049 use crate::auth::SESSION_COOKIE;
1050 use crate::test_support::{KEY, app_router, create_session, test_state};
1051
1052 const CSRF: &str = "hooks-test-csrf";
1053 const URL: &str = "https://hooks.example.com/in";
1054
1055 #[test]
1056 fn audit_url_strips_userinfo_query_and_fragment() {
1057 assert_eq!(
1058 audit_url("https://user:s3cret@hooks.example.com:8443/a/b?token=abc&x=1#frag"),
1059 "https://hooks.example.com:8443/a/b"
1060 );
1061 assert_eq!(
1062 audit_url("https://hooks.example.com/in?key=tok"),
1063 "https://hooks.example.com/in"
1064 );
1065 assert_eq!(audit_url(URL), URL);
1066 assert_eq!(audit_url("not a url"), "");
1067 }
1068
1069 /// Alice owns `proj` (private), bob is a Write collaborator, carol is outside the repo.
1070 struct Fx {
1071 store: Store,
1072 app: Router,
1073 repo: forge_core::Repo,
1074 alice: User,
1075 }
1076
1077 async fn fixture(dir: &Path) -> Fx {
1078 let state = test_state(dir).await;
1079 let store = state.store.clone();
1080 let alice = store
1081 .create_user("alice", None, "unused", false)
1082 .await
1083 .expect("alice");
1084 let bob = store
1085 .create_user("bob", None, "unused", false)
1086 .await
1087 .expect("bob");
1088 store
1089 .create_user("carol", None, "unused", false)
1090 .await
1091 .expect("carol");
1092 let repo = store
1093 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
1094 .await
1095 .expect("repo");
1096 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
1097 init_bare(&state.git, &bare, "main")
1098 .await
1099 .expect("init bare");
1100 store
1101 .set_collaborator(&repo.id, &bob.id, Role::Write)
1102 .await
1103 .expect("collaborator");
1104 create_session(&store, &alice.id, "alice-token", CSRF).await;
1105 create_session(&store, &bob.id, "bob-token", CSRF).await;
1106 create_session(&store, &user_id(&store, "carol").await, "carol-token", CSRF).await;
1107 let app = app_router(state.clone());
1108 Fx {
1109 store,
1110 app,
1111 repo,
1112 alice,
1113 }
1114 }
1115
1116 async fn user_id(store: &Store, name: &str) -> String {
1117 store
1118 .user_by_username(name)
1119 .await
1120 .expect("lookup")
1121 .expect("user")
1122 .id
1123 }
1124
1125 fn cookie_for(token: &str) -> String {
1126 format!("__Host-{SESSION_COOKIE}={token}")
1127 }
1128
1129 /// Sends a request. A POST gets the CSRF token and the given fields as a form body.
1130 /// Returns the status, the Location header (if any) and the body.
1131 async fn send(
1132 app: &Router,
1133 method: &str,
1134 uri: &str,
1135 cookie: &str,
1136 fields: &[(&str, &str)],
1137 ) -> (u16, Option<String>, String) {
1138 let mut form = form_urlencoded::Serializer::new(String::new());
1139 if method == "POST" {
1140 form.append_pair("csrf", CSRF);
1141 }
1142 for (name, value) in fields {
1143 form.append_pair(name, value);
1144 }
1145 let mut builder = Request::builder()
1146 .method(method)
1147 .uri(uri)
1148 .header("cookie", cookie);
1149 if method == "POST" {
1150 builder = builder.header("content-type", "application/x-www-form-urlencoded");
1151 }
1152 let req = builder.body(Body::from(form.finish())).expect("request");
1153 let res = app.handle(req).await;
1154 let status = res.status().as_u16();
1155 let location = res
1156 .headers()
1157 .get("location")
1158 .and_then(|v| v.to_str().ok())
1159 .map(str::to_owned);
1160 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
1161 (
1162 status,
1163 location,
1164 String::from_utf8_lossy(&bytes).into_owned(),
1165 )
1166 }
1167
1168 fn hook_fields(url: &str, events: &[&'static str]) -> Vec<(&'static str, String)> {
1169 let mut fields = vec![("url", url.to_owned()), ("active", "on".to_owned())];
1170 for event in events {
1171 fields.push((event, "on".to_owned()));
1172 }
1173 fields
1174 }
1175
1176 fn as_refs<'a>(fields: &'a [(&'static str, String)]) -> Vec<(&'a str, &'a str)> {
1177 fields.iter().map(|(k, v)| (*k, v.as_str())).collect()
1178 }
1179
1180 #[tokio::test]
1181 async fn admin_creates_hook_and_secret_is_sealed() {
1182 let tmp = tempfile::tempdir().expect("tempdir");
1183 let fx = fixture(tmp.path()).await;
1184 let cookie = cookie_for("alice-token");
1185 let secret_text = "plain-secret-7f3a9c";
1186 let mut fields = hook_fields(URL, &["ev_push", "ev_issues"]);
1187 fields.push(("secret", secret_text.to_owned()));
1188
1189 let (status, location, body) = send(
1190 &fx.app,
1191 "POST",
1192 "/alice/proj/settings/hooks/new",
1193 &cookie,
1194 &as_refs(&fields),
1195 )
1196 .await;
1197 assert_eq!(status, 303, "body: {body}");
1198 let location = location.expect("location");
1199 assert!(
1200 location.starts_with("/alice/proj/settings/hooks/"),
1201 "{location}"
1202 );
1203 assert!(location.ends_with("?notice=created"), "{location}");
1204
1205 let hooks = fx.store.list_webhooks(&fx.repo.id).await.expect("hooks");
1206 assert_eq!(hooks.len(), 1);
1207 let hook = &hooks[0].0;
1208 assert_eq!(hook.url, URL);
1209 assert!(hook.active);
1210 assert_eq!(hook.events, vec![WebhookEvent::Push, WebhookEvent::Issues]);
1211 assert_eq!(
1212 fx.store
1213 .webhook_secret(&hook.id, &KEY)
1214 .await
1215 .expect("secret"),
1216 secret_text
1217 );
1218
1219 // The audit entry names the hook and its URL, never the secret.
1220 let filter = forge_core::AuditFilter {
1221 action: Some(AuditAction::WebhookCreated),
1222 ..Default::default()
1223 };
1224 let entries = fx.store.list_audit(&filter, None, 10).await.expect("audit");
1225 assert_eq!(entries.len(), 1);
1226 assert_eq!(entries[0].target_kind, "repo");
1227 assert_eq!(entries[0].target_name, "alice/proj");
1228 assert!(!entries[0].data.contains(secret_text));
1229 let data: Value = serde_json::from_str(&entries[0].data).expect("json");
1230 assert_eq!(data["hook_id"], hook.id.as_str());
1231 assert_eq!(data["url"], URL);
1232
1233 // The plaintext never reaches the database file (or its write-ahead log).
1234 for name in ["forge.db", "forge.db-wal"] {
1235 let path = tmp.path().join(name);
1236 if let Ok(bytes) = std::fs::read(&path) {
1237 assert!(
1238 !bytes
1239 .windows(secret_text.len())
1240 .any(|w| w == secret_text.as_bytes()),
1241 "plaintext secret found in {name}"
1242 );
1243 }
1244 }
1245 }
1246
1247 #[tokio::test]
1248 async fn invalid_urls_and_empty_event_list_show_errors() {
1249 let tmp = tempfile::tempdir().expect("tempdir");
1250 let fx = fixture(tmp.path()).await;
1251 let cookie = cookie_for("alice-token");
1252 let cases = [
1253 (
1254 "http://hooks.example.com/in",
1255 vec!["ev_push"],
1256 "not allowed",
1257 ),
1258 (
1259 "https://127.0.0.1/in",
1260 vec!["ev_push"],
1261 "destination not allowed",
1262 ),
1263 (URL, vec![], "Choose at least one event."),
1264 ];
1265 for (url, events, expected) in cases {
1266 let fields = hook_fields(url, &events);
1267 let (status, _, body) = send(
1268 &fx.app,
1269 "POST",
1270 "/alice/proj/settings/hooks/new",
1271 &cookie,
1272 &as_refs(&fields),
1273 )
1274 .await;
1275 assert_eq!(status, 200, "{url}: {body}");
1276 assert!(body.contains(expected), "{url}: {body}");
1277 }
1278 assert!(
1279 fx.store
1280 .list_webhooks(&fx.repo.id)
1281 .await
1282 .expect("hooks")
1283 .is_empty()
1284 );
1285 }
1286
1287 #[tokio::test]
1288 async fn long_secret_is_refused() {
1289 let tmp = tempfile::tempdir().expect("tempdir");
1290 let fx = fixture(tmp.path()).await;
1291 let cookie = cookie_for("alice-token");
1292 let long = "x".repeat(257);
1293 let mut fields = hook_fields(URL, &["ev_push"]);
1294 fields.push(("secret", long));
1295 let (status, _, body) = send(
1296 &fx.app,
1297 "POST",
1298 "/alice/proj/settings/hooks/new",
1299 &cookie,
1300 &as_refs(&fields),
1301 )
1302 .await;
1303 assert_eq!(status, 200);
1304 assert!(body.contains("at most 256 characters"), "{body}");
1305 assert!(
1306 fx.store
1307 .list_webhooks(&fx.repo.id)
1308 .await
1309 .expect("hooks")
1310 .is_empty()
1311 );
1312 }
1313
1314 #[tokio::test]
1315 async fn collaborator_is_forbidden_and_outsider_gets_404() {
1316 let tmp = tempfile::tempdir().expect("tempdir");
1317 let fx = fixture(tmp.path()).await;
1318 let bob = cookie_for("bob-token");
1319 let carol = cookie_for("carol-token");
1320
1321 let (status, _, _) = send(&fx.app, "GET", "/alice/proj/settings/hooks", &bob, &[]).await;
1322 assert_eq!(status, 403);
1323 let fields = hook_fields(URL, &["ev_push"]);
1324 let (status, _, _) = send(
1325 &fx.app,
1326 "POST",
1327 "/alice/proj/settings/hooks/new",
1328 &bob,
1329 &as_refs(&fields),
1330 )
1331 .await;
1332 assert_eq!(status, 403);
1333 assert!(
1334 fx.store
1335 .list_webhooks(&fx.repo.id)
1336 .await
1337 .expect("hooks")
1338 .is_empty()
1339 );
1340
1341 // A private repo is invisible to someone outside it.
1342 let (status, _, _) = send(&fx.app, "GET", "/alice/proj/settings/hooks", &carol, &[]).await;
1343 assert_eq!(status, 404);
1344 }
1345
1346 #[tokio::test]
1347 async fn hook_of_another_repo_is_404() {
1348 let tmp = tempfile::tempdir().expect("tempdir");
1349 let fx = fixture(tmp.path()).await;
1350 let other = fx
1351 .store
1352 .create_repo(&fx.alice.id, "other", "", Visibility::Private, "main")
1353 .await
1354 .expect("other repo");
1355 let sealed = secret::seal(&KEY, b"s").expect("seal");
1356 let hook = fx
1357 .store
1358 .create_webhook(
1359 &other.id,
1360 &fx.alice.id,
1361 URL,
1362 sealed.as_bytes(),
1363 &[WebhookEvent::Push],
1364 true,
1365 )
1366 .await
1367 .expect("hook");
1368 let cookie = cookie_for("alice-token");
1369 let uri = format!("/alice/proj/settings/hooks/{}", hook.id);
1370 let (status, _, _) = send(&fx.app, "GET", &uri, &cookie, &[]).await;
1371 assert_eq!(status, 404);
1372 let (status, _, _) = send(
1373 &fx.app,
1374 "POST",
1375 &format!("{uri}/delete"),
1376 &cookie,
1377 &[("confirm", "on")],
1378 )
1379 .await;
1380 assert_eq!(status, 404);
1381 assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_some());
1382 }
1383
1384 #[tokio::test]
1385 async fn edit_with_blank_secret_keeps_the_secret() {
1386 let tmp = tempfile::tempdir().expect("tempdir");
1387 let fx = fixture(tmp.path()).await;
1388 let sealed = secret::seal(&KEY, b"old-secret").expect("seal");
1389 let hook = fx
1390 .store
1391 .create_webhook(
1392 &fx.repo.id,
1393 &fx.alice.id,
1394 URL,
1395 sealed.as_bytes(),
1396 &[WebhookEvent::Push],
1397 true,
1398 )
1399 .await
1400 .expect("hook");
1401 let cookie = cookie_for("alice-token");
1402 let uri = format!("/alice/proj/settings/hooks/{}", hook.id);
1403
1404 let new_url = "https://hooks.example.com/moved";
1405 let fields = hook_fields(new_url, &["ev_create"]);
1406 let (status, location, body) =
1407 send(&fx.app, "POST", &uri, &cookie, &as_refs(&fields)).await;
1408 assert_eq!(status, 303, "{body}");
1409 assert!(location.unwrap_or_default().ends_with("?notice=saved"));
1410 let saved = fx
1411 .store
1412 .webhook(&hook.id)
1413 .await
1414 .expect("lookup")
1415 .expect("hook");
1416 assert_eq!(saved.url, new_url);
1417 assert_eq!(saved.events, vec![WebhookEvent::Create]);
1418 assert!(saved.active);
1419 assert_eq!(
1420 fx.store
1421 .webhook_secret(&hook.id, &KEY)
1422 .await
1423 .expect("secret"),
1424 "old-secret"
1425 );
1426
1427 // A typed secret replaces it.
1428 let mut fields = hook_fields(new_url, &["ev_create"]);
1429 fields.push(("secret", "new-secret".to_owned()));
1430 let (status, _, _) = send(&fx.app, "POST", &uri, &cookie, &as_refs(&fields)).await;
1431 assert_eq!(status, 303);
1432 assert_eq!(
1433 fx.store
1434 .webhook_secret(&hook.id, &KEY)
1435 .await
1436 .expect("secret"),
1437 "new-secret"
1438 );
1439 }
1440
1441 #[tokio::test]
1442 async fn delete_needs_confirmation_then_removes_the_hook() {
1443 let tmp = tempfile::tempdir().expect("tempdir");
1444 let fx = fixture(tmp.path()).await;
1445 let sealed = secret::seal(&KEY, b"s").expect("seal");
1446 let hook = fx
1447 .store
1448 .create_webhook(
1449 &fx.repo.id,
1450 &fx.alice.id,
1451 URL,
1452 sealed.as_bytes(),
1453 &[WebhookEvent::Push],
1454 true,
1455 )
1456 .await
1457 .expect("hook");
1458 let cookie = cookie_for("alice-token");
1459 let uri = format!("/alice/proj/settings/hooks/{}/delete", hook.id);
1460
1461 let (status, location, _) = send(&fx.app, "POST", &uri, &cookie, &[]).await;
1462 assert_eq!(status, 303);
1463 assert!(location.unwrap_or_default().ends_with("?notice=confirm"));
1464 assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_some());
1465
1466 let (status, location, _) =
1467 send(&fx.app, "POST", &uri, &cookie, &[("confirm", "on")]).await;
1468 assert_eq!(status, 303);
1469 assert!(location.unwrap_or_default().ends_with("?notice=deleted"));
1470 assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_none());
1471 }
1472
1473 #[tokio::test]
1474 async fn delivery_body_is_escaped_on_the_hook_page() {
1475 let tmp = tempfile::tempdir().expect("tempdir");
1476 let fx = fixture(tmp.path()).await;
1477 let sealed = secret::seal(&KEY, b"s").expect("seal");
1478 let hook = fx
1479 .store
1480 .create_webhook(
1481 &fx.repo.id,
1482 &fx.alice.id,
1483 URL,
1484 sealed.as_bytes(),
1485 &[WebhookEvent::Issues],
1486 true,
1487 )
1488 .await
1489 .expect("hook");
1490 fx.store
1491 .enqueue_delivery(
1492 &hook.id,
1493 WebhookEvent::Issues,
1494 "opened",
1495 "{\"X-Forge-Event\":\"issues\"}",
1496 "{\"title\":\"<script>alert(1)</script>\"}",
1497 )
1498 .await
1499 .expect("delivery");
1500 let cookie = cookie_for("alice-token");
1501 let uri = format!("/alice/proj/settings/hooks/{}", hook.id);
1502 let (status, _, body) = send(&fx.app, "GET", &uri, &cookie, &[]).await;
1503 assert_eq!(status, 200);
1504 assert!(body.contains("&lt;script&gt;alert(1)"), "{body}");
1505 assert!(!body.contains("<script>alert(1)"), "{body}");
1506 assert!(body.contains("issues: opened"), "{body}");
1507 }
1508
1509 #[tokio::test]
1510 async fn redeliver_creates_a_new_delivery() {
1511 let tmp = tempfile::tempdir().expect("tempdir");
1512 let fx = fixture(tmp.path()).await;
1513 let sealed = secret::seal(&KEY, b"s").expect("seal");
1514 let hook = fx
1515 .store
1516 .create_webhook(
1517 &fx.repo.id,
1518 &fx.alice.id,
1519 URL,
1520 sealed.as_bytes(),
1521 &[WebhookEvent::Push],
1522 true,
1523 )
1524 .await
1525 .expect("hook");
1526 let original = fx
1527 .store
1528 .enqueue_delivery(&hook.id, WebhookEvent::Push, "", "{}", "{}")
1529 .await
1530 .expect("delivery");
1531 let cookie = cookie_for("alice-token");
1532 let uri = format!(
1533 "/alice/proj/settings/hooks/{}/deliveries/{}/redeliver",
1534 hook.id, original.id
1535 );
1536 let (status, location, body) = send(&fx.app, "POST", &uri, &cookie, &[]).await;
1537 assert_eq!(status, 303, "{body}");
1538 assert!(
1539 location
1540 .unwrap_or_default()
1541 .ends_with("?notice=redelivered")
1542 );
1543 let deliveries = fx
1544 .store
1545 .list_deliveries(&hook.id, 50)
1546 .await
1547 .expect("deliveries");
1548 assert_eq!(deliveries.len(), 2);
1549 assert!(deliveries.iter().all(|d| d.state == DeliveryState::Pending));
1550 }
1551
1552 #[tokio::test]
1553 async fn archived_repo_shows_the_page_but_refuses_changes() {
1554 let tmp = tempfile::tempdir().expect("tempdir");
1555 let fx = fixture(tmp.path()).await;
1556 let sealed = secret::seal(&KEY, b"s").expect("seal");
1557 let hook = fx
1558 .store
1559 .create_webhook(
1560 &fx.repo.id,
1561 &fx.alice.id,
1562 URL,
1563 sealed.as_bytes(),
1564 &[WebhookEvent::Push],
1565 true,
1566 )
1567 .await
1568 .expect("hook");
1569 fx.store
1570 .set_repo_archived(&fx.repo.id, true)
1571 .await
1572 .expect("archive");
1573 let cookie = cookie_for("alice-token");
1574 let (status, _, body) = send(
1575 &fx.app,
1576 "GET",
1577 &format!("/alice/proj/settings/hooks/{}", hook.id),
1578 &cookie,
1579 &[],
1580 )
1581 .await;
1582 assert_eq!(status, 200, "{body}");
1583 assert!(body.contains("archived"), "{body}");
1584 let fields = hook_fields("https://hooks.example.com/other", &["ev_push"]);
1585 let (status, _, _) = send(
1586 &fx.app,
1587 "POST",
1588 &format!("/alice/proj/settings/hooks/{}", hook.id),
1589 &cookie,
1590 &as_refs(&fields),
1591 )
1592 .await;
1593 assert_eq!(status, 403);
1594 let (status, _, _) = send(
1595 &fx.app,
1596 "POST",
1597 &format!("/alice/proj/settings/hooks/{}/delete", hook.id),
1598 &cookie,
1599 &[("confirm", "on")],
1600 )
1601 .await;
1602 assert_eq!(status, 403);
1603 assert!(fx.store.webhook(&hook.id).await.expect("lookup").is_some());
1604 }
1605
1606 #[tokio::test]
1607 async fn list_and_new_pages_render_for_admins() {
1608 let tmp = tempfile::tempdir().expect("tempdir");
1609 let fx = fixture(tmp.path()).await;
1610 let sealed = secret::seal(&KEY, b"s").expect("seal");
1611 fx.store
1612 .create_webhook(
1613 &fx.repo.id,
1614 &fx.alice.id,
1615 URL,
1616 sealed.as_bytes(),
1617 &[WebhookEvent::Push],
1618 true,
1619 )
1620 .await
1621 .expect("hook");
1622 let cookie = cookie_for("alice-token");
1623 let (status, _, body) =
1624 send(&fx.app, "GET", "/alice/proj/settings/hooks", &cookie, &[]).await;
1625 assert_eq!(status, 200, "{body}");
1626 assert!(body.contains(URL), "{body}");
1627 assert!(body.contains("Add webhook"), "{body}");
1628 assert!(body.contains("none yet"), "{body}");
1629 assert!(body.contains("settings-nav"), "{body}");
1630 let (status, _, body) = send(
1631 &fx.app,
1632 "GET",
1633 "/alice/proj/settings/hooks/new",
1634 &cookie,
1635 &[],
1636 )
1637 .await;
1638 assert_eq!(status, 200, "{body}");
1639 assert!(body.contains("name=\"ev_push\""), "{body}");
1640 assert!(!body.contains("name=\"ev_ping\""), "{body}");
1641 }
1642}