internal/control/webhook.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/control/webhook.go history · blame · raw

230 lines · 7495 bytes

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