Tahinli/forge
public0 watching
★ 0
| 1 | # Webhooks (contract between workers) |
| 2 | |
| 3 | The global rules from the earlier interface docs apply: |
| 4 | - No JS and no inline styles; no unwrap in non-test code. |
| 5 | - Tests use `tempfile::TempDir`. |
| 6 | - Never put cargo target dirs or big files in /tmp. |
| 7 | - Every new crate is added at its **latest** version with `cargo add` and recorded in the root `[workspace.dependencies]`. |
| 8 | |
| 9 | ## Model |
| 10 | - A repo webhook has: |
| 11 | - a URL (https, or http only when the config allows it); |
| 12 | - a secret, encrypted at rest with the instance key the same way TOTP secrets are; |
| 13 | - a content type (always `application/json`); |
| 14 | - an event list: `push`, `pull_request`, `issues`, `issue_comment`, `pull_request_review`, `create`, `delete`, `ping`; |
| 15 | - an `active` flag. |
| 16 | - Managing hooks needs Admin on the repo. |
| 17 | - **Deliveries** are rows in the DB: |
| 18 | - the request: headers and body. The headers are queued without `X-Forge-Delivery` or the signature; after each attempt the stored headers are replaced with the ones actually sent; |
| 19 | - the response: status, the first 64 KiB of the body, and timing; |
| 20 | - `attempt`, a state (`pending` | `ok` | `failed` | `gave_up`), `next_attempt_at`, and a GUID. |
| 21 | - **Retries** use backoff 1m, 5m, 30m, 2h, 6h, then `gave_up`. "Redeliver" in the UI creates a new delivery with the same payload. |
| 22 | - **Headers:** |
| 23 | - `X-Forge-Event: <event>`, `X-Forge-Delivery: <guid>`; |
| 24 | - `X-Forge-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)>`; |
| 25 | - `User-Agent: forge-hookshot/<version>`. |
| 26 | - **Payloads** are our own JSON. We deliberately don't copy the GitHub or Gitea schemas, but a similar shape is fine: |
| 27 | - `repository {id, owner, name, full_name, private, html_url, clone_url}`, `sender {id, username}`; |
| 28 | - push: `ref, before, after, commits[{id, message, author{name,email}, timestamp, url}] (max 20), total_commits, compare_url`; |
| 29 | - pull_request: `action (opened|closed|reopened|merged|synchronize|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), number, pull_request {title, body, state, merged, base, head, html_url}`; the label, assignee and milestone actions add `label {name, color}`, `assignee {username}` or `milestone {title}` at the top level, as the issues event does; |
| 30 | - issues: `action (opened|closed|reopened|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), issue {number, title, body, state, html_url}`, plus `label {name, color}`, `assignee {username}` or `milestone {title}` for those actions; |
| 31 | - issue_comment: `action (created|edited|deleted), issue {…}, comment {id, body, html_url}`; |
| 32 | - pull_request_review: `action submitted, review {id, state, body}, pull_request {…}`. |
| 33 | |
| 34 | ## SSRF guard (mandatory) |
| 35 | - Resolve the host once, check every resolved address, then connect to exactly that checked address. |
| 36 | - In reqwest, use a custom `dns::Resolve` that filters addresses, so a DNS rebind can't bypass the check. |
| 37 | - Reject: loopback, private (RFC1918, fc00::/7), link-local (169.254/16, fe80::/10), CGNAT 100.64/10, unspecified, multicast, broadcast, documentation ranges, IPv4-mapped IPv6 of any of these, and 0.0.0.0/8. |
| 38 | - Config `[webhooks] allowed_hosts = []` (exact hostnames or CIDRs) whitelists internal targets; the default is empty. |
| 39 | - **No redirects.** Timeouts: 10s connect, 20s total. Response bodies are read up to 64 KiB. |
| 40 | - The URL must have no userinfo, and its port must be 1–65535. |
| 41 | - `allow_http = false` by default. |
| 42 | |
| 43 | ## Delivery worker |
| 44 | - A tokio task started at boot. Each loop it claims due deliveries (`state = 'pending' AND next_attempt_at <= now`), sends them with bounded concurrency (4), and records the result. |
| 45 | - A 2xx response means `ok`. |
| 46 | - It is woken by a `Notify` when a new delivery is queued, so the first attempt is immediate; otherwise it polls every 30s. |
| 47 | - Deliveries older than 14 days are purged daily by the same task. |
| 48 | |
| 49 | ## Integration points |
| 50 | - Enqueue through the sender-aware helpers in `crates/forge-web/src/webhooks/emit.rs`. Each one builds its event's payload, looks up the sender, and hands off to the generic `emit(state, repo, sender, event, action, payload)`. That function finds the active hooks subscribed to the event and inserts a delivery for each. Nothing here fails the caller; errors are logged. |
| 51 | - `Sender` says who made the change. `lookup_sender(state, actor_id)` builds one; an empty id means a system change and is sent as `"sender": null`. |
| 52 | - `emit_ref_update(…)`: `push`, plus `create`/`delete` for branch and tag creation and deletion. Only `refs/heads/*` and `refs/tags/*` produce events. |
| 53 | - `emit_pull(…)`: `pull_request` (for example, on PR create in `pages/compare.rs`). |
| 54 | - `emit_item_action(…)`: `pull_request` or `issues` action events, reloaded from the store. Used for close, reopen, merge and `synchronize`. |
| 55 | - `emit_issue(…)`: `issues` create (`pages/issues_list.rs`). |
| 56 | - `emit_label_changes(…)` and `emit_assignee_changes(…)`: one `labeled`/`unlabeled` or `assigned`/`unassigned` event per changed label (matched by name) or username. `pull` picks the `pull_request` event for a pull request and `issues` for an issue. |
| 57 | - `emit_milestone_change(…)`: `demilestoned` for the milestone before and `milestoned` for the one after, given their titles; nothing when they match. Same `pull` choice. |
| 58 | - `emit_comment(…)`: `issue_comment` create, edit and delete. |
| 59 | - `emit_review(…)`: `pull_request_review` submit. |
| 60 | - Call sites: `post_update.rs`, `pulls_sync.rs`, `pages/{compare,issue_view,issue_parts,issue_meta,issues_list,pulls_merge,pull_reviews}.rs`. The label, assignee and milestone saves in `pages/issue_meta.rs` run for issues and pull requests alike. |
| 61 | - A "ping" event is sent by `emit_ping(state, hook)` when a hook is created and from the "Test" button. It ignores the hook's subscriptions. |
| 62 | |
| 63 | ## UI (repo settings, a "Webhooks" section) |
| 64 | - List hooks with their last delivery status. |
| 65 | - Create and edit form: URL, secret (write-only; leave it blank to keep the current one), event checkboxes, active. |
| 66 | - Delete hooks. |
| 67 | - Hook detail page: the last 50 deliveries, each expandable with `<details>` to show the request headers and body and the response; a Redeliver button and a Test (ping) button. |