Skip to content
quarry Artifact [ff64353054]
Login

Artifact [ff64353054]

Artifact ff64353054bc6f3bb74c226a5e7463904789fbf5d4f6acae2d8734e7b03c155e:


// webhook.go stores webhook subscriptions — Principle 6's standard
// outbound surface. A subscription is forge configuration, not DAG
// state, so it belongs in the OLTP database beside accounts (0001 §8).
package oltp

import (
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"net/url"
	"strings"
	"time"
)

const webhookSchema = `
CREATE TABLE IF NOT EXISTS webhook (
	id      INTEGER PRIMARY KEY AUTOINCREMENT,
	repo    TEXT NOT NULL,
	url     TEXT NOT NULL,
	secret  TEXT NOT NULL,           -- HMAC-SHA256 key; shown once, at creation
	active  INTEGER NOT NULL DEFAULT 1,
	created TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS webhook_by_repo ON webhook (repo, active);`

// webhookMigrations are additive columns for stores written by an
// earlier schema.
//
// events: which of RFC 0005's vocabulary this endpoint wants, as
// space-separated patterns. `*` is every event, which is what every
// subscription written before this column got — the default preserves
// exactly the behaviour those owners configured, which was "send me
// everything" by not having been asked.
var webhookMigrations = []string{
	`ALTER TABLE webhook ADD COLUMN events TEXT NOT NULL DEFAULT '*'`,
}

// DefaultEvents is what a subscription selects when its owner does not
// say. Everything, because a webhook exists to be told things.
const DefaultEvents = "*"

// Webhook is one subscription. Secret is only populated when the
// subscription is created — listing never returns it, because a
// signing key that can be read back is a signing key that leaks.
type Webhook struct {
	ID     int64
	Repo   string
	URL    string
	Secret string
	Active bool
	// Events is the space-separated vocabulary patterns this endpoint
	// selects (RFC 0005). `*` is everything; `ticket` is every action on
	// tickets; `landing.accepted` is exactly that.
	Events  string
	Created string
}

// AddWebhook registers an endpoint and mints its signing secret. The
// secret is returned once and never retrievable afterwards.
func (d *DB) AddWebhook(repo, endpoint, events string) (Webhook, error) {
	if repo == "" {
		return Webhook{}, errors.New("oltp: webhook needs a repository")
	}
	if strings.TrimSpace(events) == "" {
		events = DefaultEvents
	}
	// Only http(s): a webhook URL is fetched by the server, so any other
	// scheme is a request to make quarry speak a protocol on someone
	// else's behalf.
	u, err := url.Parse(endpoint)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
		return Webhook{}, fmt.Errorf("oltp: webhook url must be http or https, got %q", endpoint)
	}
	var buf [32]byte
	if _, err := rand.Read(buf[:]); err != nil {
		return Webhook{}, err
	}
	secret := hex.EncodeToString(buf[:])
	created := time.Now().UTC().Format(time.RFC3339)
	res, err := d.db.Exec(`INSERT INTO webhook (repo, url, secret, active, events, created)
		VALUES (?, ?, ?, 1, ?, ?)`, repo, endpoint, secret, events, created)
	if err != nil {
		return Webhook{}, err
	}
	id, _ := res.LastInsertId()
	return Webhook{ID: id, Repo: repo, URL: endpoint, Secret: secret, Active: true,
		Events: events, Created: created}, nil
}

// Webhooks lists a repository's subscriptions. Secret is deliberately
// left empty; ActiveWebhooks is the dispatcher's accessor.
func (d *DB) Webhooks(repo string) ([]Webhook, error) {
	rows, err := d.db.Query(`SELECT id, repo, url, active, events, created FROM webhook
		WHERE repo = ? ORDER BY id`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Webhook
	for rows.Next() {
		var w Webhook
		var active int
		if err := rows.Scan(&w.ID, &w.Repo, &w.URL, &active, &w.Events, &w.Created); err != nil {
			return nil, err
		}
		w.Active = active != 0
		out = append(out, w)
	}
	return out, rows.Err()
}

// ActiveWebhooks returns the subscriptions to deliver to, secrets
// included — the dispatcher needs them to sign.
func (d *DB) ActiveWebhooks(repo string) ([]Webhook, error) {
	rows, err := d.db.Query(`SELECT id, repo, url, secret, events, created FROM webhook
		WHERE repo = ? AND active = 1 ORDER BY id`, repo)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Webhook
	for rows.Next() {
		var w Webhook
		if err := rows.Scan(&w.ID, &w.Repo, &w.URL, &w.Secret, &w.Events, &w.Created); err != nil {
			return nil, err
		}
		w.Active = true
		out = append(out, w)
	}
	return out, rows.Err()
}

// WebhookByID returns one subscription with its secret, for redelivery.
func (d *DB) WebhookByID(id int64) (Webhook, bool) {
	var w Webhook
	var active int
	err := d.db.QueryRow(`SELECT id, repo, url, secret, active, events, created FROM webhook WHERE id = ?`,
		id).Scan(&w.ID, &w.Repo, &w.URL, &w.Secret, &active, &w.Events, &w.Created)
	if err != nil {
		return Webhook{}, false
	}
	w.Active = active != 0
	return w, true
}

// SetWebhookActive pauses or resumes a subscription. Pausing is kept
// distinct from deleting so an endpoint that is down can be silenced
// without losing its identity and secret.
func (d *DB) SetWebhookActive(id int64, active bool) error {
	res, err := d.db.Exec(`UPDATE webhook SET active = ? WHERE id = ?`, boolInt(active), id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("oltp: no webhook %d", id)
	}
	return nil
}

// SetWebhookEvents changes which events an endpoint selects. Kept
// separate from pausing: narrowing a subscription is a routine edit,
// where pausing is a statement about the endpoint's health.
func (d *DB) SetWebhookEvents(id int64, events string) error {
	if strings.TrimSpace(events) == "" {
		events = DefaultEvents
	}
	res, err := d.db.Exec(`UPDATE webhook SET events = ? WHERE id = ?`, events, id)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return fmt.Errorf("oltp: no webhook %d", id)
	}
	return nil
}

// DeleteWebhook removes a subscription.
func (d *DB) DeleteWebhook(id int64) error {
	_, err := d.db.Exec(`DELETE FROM webhook WHERE id = ?`, id)
	return err
}

// redacted keeps a secret out of logs and errors if one is ever printed.
func (w Webhook) String() string {
	return fmt.Sprintf("webhook %d %s -> %s (secret %s)", w.ID, w.Repo, w.URL,
		strings.Repeat("*", 8))
}