internal/control/webhook.go

288 lines · 9222 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"net/url"
  8	"strconv"
  9	"strings"
 10
 11	"gitbay.org/gitbay/internal/policy"
 12	"gitbay.org/gitbay/internal/protocol"
 13	"gitbay.org/gitbay/internal/store"
 14	"gitbay.org/gitbay/internal/webhook"
 15)
 16
 17func init() {
 18	register(Command{Path: []string{"webhook", "add"},
 19		NeedsRecentSignIn: true,
 20		Summary:           "add a webhook",
 21		Usage:             "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]",
 22		Flags: []Flag{
 23			{"--secret", "-", "read the secret that signs deliveries from stdin", ""},
 24			{"--events", "push,issue.created|*", "which events to send", "*"},
 25		},
 26		Examples: []string{
 27			"webhook add krz/gitbay https://ci.example.org/hook --events push",
 28			"webhook add krz/gitbay https://ci.example.org/hook --secret - < secret.txt",
 29		},
 30		ReadsStdin: true,
 31		Run:        runWebhookAdd})
 32	register(Command{Path: []string{"webhook", "list"},
 33		Summary:  "list webhooks",
 34		Usage:    "webhook list <owner/name>",
 35		Examples: []string{"webhook list krz/gitbay"}, ReadOnly: true, Run: runWebhookList})
 36	register(Command{Path: []string{"webhook", "remove"},
 37		Summary:  "remove a webhook",
 38		Usage:    "webhook remove <owner/name> <id>",
 39		Examples: []string{"webhook remove krz/gitbay 3"}, Run: runWebhookRemove})
 40	register(Command{Path: []string{"webhook", "deliveries"},
 41		Summary: "recent deliveries",
 42		Usage:   "webhook deliveries <owner/name> [--limit n]",
 43		Flags: []Flag{
 44			{"--limit", "n", "rows to show", "20"},
 45		},
 46		Examples: []string{"webhook deliveries krz/gitbay --limit 50"},
 47		ReadOnly: true, Run: runWebhookDeliveries})
 48	register(Command{Path: []string{"webhook", "redeliver"},
 49		Summary:  "queue a delivery again",
 50		Usage:    "webhook redeliver <owner/name> <delivery-id>",
 51		Examples: []string{"webhook redeliver krz/gitbay 42"}, Run: runWebhookRedeliver})
 52}
 53
 54func runWebhookAdd(c *Ctx, args []string) int {
 55	f, err := c.parseArgs(args, flagSpec{Values: []string{"--secret", "--events"}, MaxPos: 2, Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]"})
 56	if err != nil {
 57		return c.fail(protocol.ExitUsage, "%v", err)
 58	}
 59	path, url, events := f.pos(0), f.pos(1), "*"
 60	if f.Has("--events") {
 61		events = f.Value("--events")
 62	}
 63	if path == "" || url == "" {
 64		return c.usage()
 65	}
 66	// Secrets travel on stdin: argv shows in /proc and in shell history.
 67	if f.Has("--secret") && f.Value("--secret") != "-" {
 68		return c.fail(protocol.ExitUsage, "the secret is read from stdin, never argv: pipe it and pass --secret - (printf %%s SECRET | ... --secret -)")
 69	}
 70	repo, code := resolveRepo(c, path, policy.CanAdmin)
 71	if code >= 0 {
 72		return code
 73	}
 74	// A name that is not an event is a subscription that never fires, and
 75	// nothing would ever say so. Checked before the URL, which resolves
 76	// DNS: a typo here should not need a reachable host to report.
 77	if code := checkEventNames(c, events); code >= 0 {
 78		return code
 79	}
 80	if err := webhook.ValidateURL(url, c.Cfg.Webhooks.AllowLocal); err != nil {
 81		// The command line parsed; the value is what the server refuses.
 82		// Exit 1 carries the reason to every client verbatim (#187).
 83		return c.fail(protocol.ExitFailure, "%v", err)
 84	}
 85	secret := ""
 86	if f.Has("--secret") {
 87		raw, err := io.ReadAll(io.LimitReader(c.Stdin, 64<<10))
 88		if err != nil {
 89			return c.fail(protocol.ExitFailure, "reading secret: %v", err)
 90		}
 91		secret = strings.TrimRight(string(raw), "\r\n")
 92		if secret == "" {
 93			return c.fail(protocol.ExitUsage, "no secret on stdin (pipe it: printf %%s SECRET | ... --secret -)")
 94		}
 95	}
 96	id, err := c.Store.AddWebhook(repo.ID, url, secret, events)
 97	if err != nil {
 98		return c.fail(protocol.ExitFailure, "%v", err)
 99	}
100	return c.emit(map[string]any{"id": id, "url": url, "events": events}, func(w io.Writer) {
101		fmt.Fprintf(w, "webhook %d added for %s (%s)\n", id, repo.Path(), events)
102	})
103}
104
105// shortURL is a webhook URL as a screen shows it: scheme and host only,
106// since the path of a hook URL is often a token.
107func shortURL(raw string) string {
108	u, err := url.Parse(raw)
109	if err != nil || u.Host == "" {
110		return "…"
111	}
112	if u.Path == "" || u.Path == "/" {
113		return u.Scheme + "://" + u.Host
114	}
115	return u.Scheme + "://" + u.Host + "/…"
116}
117
118func runWebhookList(c *Ctx, args []string) int {
119	if len(args) != 1 {
120		return c.usage()
121	}
122	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
123	if code >= 0 {
124		return code
125	}
126	hooks, err := c.Store.ListWebhooks(repo.ID)
127	if err != nil {
128		return c.fail(protocol.ExitFailure, "%v", err)
129	}
130	type out struct {
131		ID     int64  `json:"id"`
132		URL    string `json:"url"`
133		Events string `json:"events"`
134		Active bool   `json:"active"`
135		Secret bool   `json:"has_secret"`
136	}
137	var ds []out
138	for _, h := range hooks {
139		ds = append(ds, out{h.ID, h.URL, h.Events, h.Active, h.Secret != ""})
140	}
141	return c.emitView(ds, func(w io.Writer) {
142		tb := c.table(w, "ID", "URL", "EVENTS")
143		for _, d := range ds {
144			tb.row(cRef(fmt.Sprintf("%d", d.ID)), cText(d.URL), cText(d.Events))
145		}
146		tb.flush()
147	}, func() screen {
148		rows := make([]row, len(ds))
149		for i, d := range ds {
150			lead := cGlyph("closed")
151			if d.Active {
152				lead = cGlyph("ok")
153			}
154			signed := ""
155			if d.Secret {
156				signed = "signed"
157			}
158			rows[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), lead, cFlex(shortURL(d.URL)), cMeta(d.Events, signed))
159		}
160		return listScreen("Webhooks", rows,
161			action{"Hooks", []string{"webhook", "deliveries", repo.Path()}},
162		)
163	})
164}
165
166func runWebhookRemove(c *Ctx, args []string) int {
167	if len(args) != 2 {
168		return c.usage()
169	}
170	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
171	if code >= 0 {
172		return code
173	}
174	id, err := strconv.ParseInt(args[1], 10, 64)
175	if err != nil {
176		return c.fail(protocol.ExitUsage, "bad webhook id %q", args[1])
177	}
178	if err := c.Store.RemoveWebhook(repo.ID, id); err != nil {
179		if errors.Is(err, store.ErrNotFound) {
180			return c.fail(protocol.ExitNotFound, "no webhook %d on %s", id, repo.Path())
181		}
182		return c.fail(protocol.ExitFailure, "%v", err)
183	}
184	return c.emit(map[string]any{"removed": id}, func(w io.Writer) {
185		fmt.Fprintf(w, "removed webhook %d\n", id)
186	})
187}
188
189func runWebhookDeliveries(c *Ctx, args []string) int {
190	f, err := c.parseArgs(args, flagSpec{Values: []string{"--limit"}, MaxPos: 1, Usage: "webhook deliveries <owner/name> [--limit n]"})
191	if err != nil {
192		return c.fail(protocol.ExitUsage, "%v", err)
193	}
194	limit, path := 20, f.pos(0)
195	if f.Has("--limit") {
196		n, err := strconv.Atoi(f.Value("--limit"))
197		if err != nil || n < 1 || n > 200 {
198			return c.fail(protocol.ExitUsage, "--limit must be 1..200")
199		}
200		limit = n
201	}
202	if path == "" {
203		return c.usage()
204	}
205	repo, code := resolveRepo(c, path, policy.CanAdmin)
206	if code >= 0 {
207		return code
208	}
209	ds, err := c.Store.ListDeliveries(repo.ID, limit)
210	if err != nil {
211		return c.fail(protocol.ExitFailure, "%v", err)
212	}
213	type out struct {
214		ID         int64  `json:"id"`
215		URL        string `json:"url"`
216		Event      string `json:"event"`
217		Status     string `json:"status"`
218		Attempts   int    `json:"attempts"`
219		LastStatus int    `json:"last_status,omitempty"`
220		LastError  string `json:"last_error,omitempty"`
221	}
222	var rows []out
223	for _, d := range ds {
224		rows = append(rows, out{d.ID, d.URL, d.EventKind, d.Status, d.Attempts, d.LastStatus, d.LastError})
225	}
226	return c.emitView(rows, func(w io.Writer) {
227		tb := c.table(w, "ID", "EVENT", "URL", "STATUS")
228		for _, d := range rows {
229			cells := []cell{cRef(fmt.Sprintf("%d", d.ID)), cText(d.Event), cText(d.URL),
230				cState(fmt.Sprintf("%s (%d attempts)", d.Status, d.Attempts))}
231			if d.LastError != "" {
232				cells = append(cells, cText(d.LastError))
233			}
234			tb.row(cells...)
235		}
236		tb.flush()
237	}, func() screen {
238		rs := make([]row, len(rows))
239		var failed int64
240		for i, d := range rows {
241			state := d.Status
242			if state == "delivered" {
243				state = "ok"
244			}
245			if state == "failed" && failed == 0 {
246				failed = d.ID
247			}
248			attempts := fmt.Sprintf("%d attempts", d.Attempts)
249			if d.Attempts == 1 {
250				attempts = "1 attempt"
251			}
252			status := ""
253			if d.LastStatus != 0 {
254				status = fmt.Sprintf("HTTP %d", d.LastStatus)
255			}
256			rs[i] = rowOf(cRef(strconv.FormatInt(d.ID, 10)), cGlyph(state), cFlex(d.Event), cMeta(shortURL(d.URL), attempts, status), cMark(d.LastError, sgrRed))
257		}
258		s := listScreen("Deliveries", rs)
259		if failed != 0 {
260			s.actions = []action{{"Retry", []string{"webhook", "redeliver", repo.Path(), strconv.FormatInt(failed, 10)}}}
261		}
262		s.actions = append(s.actions, action{"Hooks", []string{"webhook", "list", repo.Path()}})
263		return s
264	})
265}
266
267func runWebhookRedeliver(c *Ctx, args []string) int {
268	if len(args) != 2 {
269		return c.usage()
270	}
271	repo, code := resolveRepo(c, args[0], policy.CanAdmin)
272	if code >= 0 {
273		return code
274	}
275	id, err := strconv.ParseInt(args[1], 10, 64)
276	if err != nil {
277		return c.fail(protocol.ExitUsage, "bad delivery id %q", args[1])
278	}
279	if err := c.Store.Redeliver(repo.ID, id); err != nil {
280		if errors.Is(err, store.ErrNotFound) {
281			return c.fail(protocol.ExitNotFound, "no delivery %d on %s", id, repo.Path())
282		}
283		return c.fail(protocol.ExitFailure, "%v", err)
284	}
285	return c.emit(map[string]any{"requeued": id}, func(w io.Writer) {
286		fmt.Fprintf(w, "delivery %d requeued\n", id)
287	})
288}