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}