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}