internal/control/webhook.go

210 lines · 6767 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8
  9	"gitbay.org/gitbay/internal/policy"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12	"gitbay.org/gitbay/internal/webhook"
 13)
 14
 15func init() {
 16	register(Command{Path: []string{"webhook", "add"},
 17		Summary: "add a webhook",
 18		Usage:   "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]",
 19		Flags: []Flag{
 20			{"--secret", "<s>", "signs deliveries so the receiver can verify them", ""},
 21			{"--events", "push,issue.created|*", "which events to send", "*"},
 22		},
 23		Examples: []string{"webhook add krz/gitbay https://ci.example.org/hook --events push"},
 24		Run:      runWebhookAdd})
 25	register(Command{Path: []string{"webhook", "list"},
 26		Summary:  "list webhooks",
 27		Usage:    "webhook list <owner/name>",
 28		Examples: []string{"webhook list krz/gitbay"}, ReadOnly: true, Run: runWebhookList})
 29	register(Command{Path: []string{"webhook", "remove"},
 30		Summary:  "remove a webhook",
 31		Usage:    "webhook remove <owner/name> <id>",
 32		Examples: []string{"webhook remove krz/gitbay 3"}, Run: runWebhookRemove})
 33	register(Command{Path: []string{"webhook", "deliveries"},
 34		Summary: "recent deliveries",
 35		Usage:   "webhook deliveries <owner/name> [--limit n]",
 36		Flags: []Flag{
 37			{"--limit", "n", "rows to show", "20"},
 38		},
 39		Examples: []string{"webhook deliveries krz/gitbay --limit 50"},
 40		ReadOnly: true, Run: runWebhookDeliveries})
 41	register(Command{Path: []string{"webhook", "redeliver"},
 42		Summary:  "queue a delivery again",
 43		Usage:    "webhook redeliver <owner/name> <delivery-id>",
 44		Examples: []string{"webhook redeliver krz/gitbay 42"}, Run: runWebhookRedeliver})
 45}
 46
 47func runWebhookAdd(c *Ctx, args []string) int {
 48	f, err := parseFlags(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret <s>] [--events push,issue.created|*]"})
 49	if err != nil {
 50		return c.fail(protocol.ExitUsage, "%v", err)
 51	}
 52	path, url, secret, events := f.pos(0), f.pos(1), f.Value("--secret"), "*"
 53	if f.Has("--events") {
 54		events = f.Value("--events")
 55	}
 56	if path == "" || url == "" {
 57		return c.usage()
 58	}
 59	repo, code := resolveRepo(c, path, policy.CanAdmin)
 60	if code >= 0 {
 61		return code
 62	}
 63	// A name that is not an event is a subscription that never fires, and
 64	// nothing would ever say so. Checked before the URL, which resolves
 65	// DNS: a typo here should not need a reachable host to report.
 66	if code := checkEventNames(c, events); code >= 0 {
 67		return code
 68	}
 69	if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil {
 70		// The command line parsed; the value is what the server refuses.
 71		// Exit 1 carries the reason to every client verbatim (#187).
 72		return c.fail(protocol.ExitFailure, "%v", err)
 73	}
 74	id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
 75	if err != nil {
 76		return c.fail(protocol.ExitFailure, "%v", err)
 77	}
 78	return c.emit(map[string]any{"id": id, "url": url, "events": events}, func(w io.Writer) {
 79		fmt.Fprintf(w, "webhook %d added for %s (%s)\n", id, repo.Path(), events)
 80	})
 81}
 82
 83func runWebhookList(c *Ctx, args []string) int {
 84	if len(args) != 1 {
 85		return c.usage()
 86	}
 87	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
 88	if code >= 0 {
 89		return code
 90	}
 91	hooks, err := c.Store.ListWebhooks(repo.ID)
 92	if err != nil {
 93		return c.fail(protocol.ExitFailure, "%v", err)
 94	}
 95	type out struct {
 96		ID     int64  `json:"id"`
 97		URL    string `json:"url"`
 98		Events string `json:"events"`
 99		Active bool   `json:"active"`
100		Secret bool   `json:"has_secret"`
101	}
102	var ds []out
103	for _, h := range hooks {
104		ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""})
105	}
106	return c.emit(ds, func(w io.Writer) {
107		tb := c.table(w, "ID", "URL", "EVENTS")
108		for _, d := range ds {
109			tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events))
110		}
111		tb.flush()
112	})
113}
114
115func runWebhookRemove(c *Ctx, args []string) int {
116	if len(args) != 2 {
117		return c.usage()
118	}
119	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
120	if code >= 0 {
121		return code
122	}
123	id, err := strconv.ParseInt(args[1], 10, 64)
124	if err != nil {
125		return c.fail(protocol.ExitUsage, "bad webhook id %q", args[1])
126	}
127	if err := c.Store.RemoveWebhook(repo.ID, id); err != nil {
128		if errors.Is(err, store.ErrNotFound) {
129			return c.fail(protocol.ExitNotFound, "no webhook %d on %s", id, repo.Path())
130		}
131		return c.fail(protocol.ExitFailure, "%v", err)
132	}
133	return c.emit(map[string]any{"removed": id}, func(w io.Writer) {
134		fmt.Fprintf(w, "removed webhook %d\n", id)
135	})
136}
137
138func runWebhookDeliveries(c *Ctx, args []string) int {
139	f, err := parseFlags(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"})
140	if err != nil {
141		return c.fail(protocol.ExitUsage, "%v", err)
142	}
143	limit, path := 20, f.pos(0)
144	if f.Has("--limit") {
145		n, err := strconv.Atoi(f.Value("--limit"))
146		if err != nil || n < 1 || n > 200 {
147			return c.fail(protocol.ExitUsage, "--limit must be 1..200")
148		}
149		limit = n
150	}
151	if path == "" {
152		return c.usage()
153	}
154	repo, code := resolveRepo(c, path, policy.CanAdmin)
155	if code >= 0 {
156		return code
157	}
158	ds, err := c.Store.ListDeliveries(repo.ID, limit)
159	if err != nil {
160		return c.fail(protocol.ExitFailure, "%v", err)
161	}
162	type out struct {
163		ID         int64  `json:"id"`
164		URL        string `json:"url"`
165		Event      string `json:"event"`
166		Status     string `json:"status"`
167		Attempts   int    `json:"attempts"`
168		LastStatus int    `json:"last_status,omitempty"`
169		LastError  string `json:"last_error,omitempty"`
170	}
171	var rows []out
172	for _, d := range ds {
173		rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError})
174	}
175	return c.emit(rows, func(w io.Writer) {
176		tb := c.table(w, "ID", "EVENT", "URL", "STATUS")
177		for _, d := range rows {
178			cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL),
179				cState(fmt.Sprintf("%s (%d attempts)", d.Status, d.Attempts))}
180			if d.LastError != "" {
181				cells = append(cells, cText(d.LastError))
182			}
183			tb.row(cells...)
184		}
185		tb.flush()
186	})
187}
188
189func runWebhookRedeliver(c *Ctx, args []string) int {
190	if len(args) != 2 {
191		return c.usage()
192	}
193	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
194	if code >= 0 {
195		return code
196	}
197	id, err := strconv.ParseInt(args[1], 10, 64)
198	if err != nil {
199		return c.fail(protocol.ExitUsage, "bad delivery id %q", args[1])
200	}
201	if err := c.Store.Redeliver(repo.ID, id); err != nil {
202		if errors.Is(err, store.ErrNotFound) {
203			return c.fail(protocol.ExitNotFound, "no delivery %d on %s", id, repo.Path())
204		}
205		return c.fail(protocol.ExitFailure, "%v", err)
206	}
207	return c.emit(map[string]any{"requeued": id}, func(w io.Writer) {
208		fmt.Fprintf(w, "delivery %d requeued\n", id)
209	})
210}