Tahinli/forge
public0 watching
★ 0
Webhooks (contract between workers)
The global rules from the earlier interface docs apply:
- No JS and no inline styles; no unwrap in non-test code.
- Tests use
tempfile::TempDir. - Never put cargo target dirs or big files in /tmp.
- Every new crate is added at its latest version with
cargo addand recorded in the root[workspace.dependencies].
Model
- A repo webhook has:
- a URL (https, or http only when the config allows it);
- a secret, encrypted at rest with the instance key the same way TOTP secrets are;
- a content type (always
application/json); - an event list:
push,pull_request,issues,issue_comment,pull_request_review,create,delete,ping; - an
activeflag.
- Managing hooks needs Admin on the repo.
- Deliveries are rows in the DB:
- the request: headers and body. The headers are queued without
X-Forge-Deliveryor the signature; after each attempt the stored headers are replaced with the ones actually sent; - the response: status, the first 64 KiB of the body, and timing;
attempt, a state (pending|ok|failed|gave_up),next_attempt_at, and a GUID.
- the request: headers and body. The headers are queued without
- Retries use backoff 1m, 5m, 30m, 2h, 6h, then
gave_up. "Redeliver" in the UI creates a new delivery with the same payload. - Headers:
X-Forge-Event: <event>,X-Forge-Delivery: <guid>;X-Forge-Signature-256: sha256=<hex HMAC-SHA256(secret, raw body)>;User-Agent: forge-hookshot/<version>.
- Payloads are our own JSON. We deliberately don't copy the GitHub or Gitea schemas, but a similar shape is fine:
repository {id, owner, name, full_name, private, html_url, clone_url},sender {id, username};- push:
ref, before, after, commits[{id, message, author{name,email}, timestamp, url}] (max 20), total_commits, compare_url; - 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 addlabel {name, color},assignee {username}ormilestone {title}at the top level, as the issues event does; - issues:
action (opened|closed|reopened|edited|labeled|unlabeled|assigned|unassigned|milestoned|demilestoned), issue {number, title, body, state, html_url}, pluslabel {name, color},assignee {username}ormilestone {title}for those actions; - issue_comment:
action (created|edited|deleted), issue {…}, comment {id, body, html_url}; - pull_request_review:
action submitted, review {id, state, body}, pull_request {…}.
SSRF guard (mandatory)
- Resolve the host once, check every resolved address, then connect to exactly that checked address.
- In reqwest, use a custom
dns::Resolvethat filters addresses, so a DNS rebind can't bypass the check.
- In reqwest, use a custom
- 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.
- Config
[webhooks] allowed_hosts = [](exact hostnames or CIDRs) whitelists internal targets; the default is empty. - No redirects. Timeouts: 10s connect, 20s total. Response bodies are read up to 64 KiB.
- The URL must have no userinfo, and its port must be 1–65535.
allow_http = falseby default.
Delivery worker
- 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. - A 2xx response means
ok. - It is woken by a
Notifywhen a new delivery is queued, so the first attempt is immediate; otherwise it polls every 30s. - Deliveries older than 14 days are purged daily by the same task.
Integration points
- 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 genericemit(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.Sendersays who made the change.lookup_sender(state, actor_id)builds one; an empty id means a system change and is sent as"sender": null.emit_ref_update(…):push, pluscreate/deletefor branch and tag creation and deletion. Onlyrefs/heads/*andrefs/tags/*produce events.emit_pull(…):pull_request(for example, on PR create inpages/compare.rs).emit_item_action(…):pull_requestorissuesaction events, reloaded from the store. Used for close, reopen, merge andsynchronize.emit_issue(…):issuescreate (pages/issues_list.rs).emit_label_changes(…)andemit_assignee_changes(…): onelabeled/unlabeledorassigned/unassignedevent per changed label (matched by name) or username.pullpicks thepull_requestevent for a pull request andissuesfor an issue.emit_milestone_change(…):demilestonedfor the milestone before andmilestonedfor the one after, given their titles; nothing when they match. Samepullchoice.emit_comment(…):issue_commentcreate, edit and delete.emit_review(…):pull_request_reviewsubmit.
- 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 inpages/issue_meta.rsrun for issues and pull requests alike. - 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.
UI (repo settings, a "Webhooks" section)
- List hooks with their last delivery status.
- Create and edit form: URL, secret (write-only; leave it blank to keep the current one), event checkboxes, active.
- Delete hooks.
- 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.