internal/control/webhook.go
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}