internal/control/webhook.go

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