-
Notifications
You must be signed in to change notification settings - Fork 2
/
webhook.go
240 lines (208 loc) · 6.84 KB
/
webhook.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
package webhooks
import (
"context"
"errors"
"net/http"
"os"
"strconv"
"strings"
"github.com/google/go-github/v60/github"
"go.uber.org/zap"
"go.autokitteh.dev/autokitteh/integrations/github/internal/vars"
"go.autokitteh.dev/autokitteh/internal/kittehs"
eventsv1 "go.autokitteh.dev/autokitteh/proto/gen/go/autokitteh/events/v1"
valuesv1 "go.autokitteh.dev/autokitteh/proto/gen/go/autokitteh/values/v1"
"go.autokitteh.dev/autokitteh/sdk/sdkservices"
"go.autokitteh.dev/autokitteh/sdk/sdktypes"
"go.autokitteh.dev/autokitteh/integrations/internal/extrazap"
)
const (
// WebhookPath is the URL path for our webhook to handle inbound events.
WebhookPath = "/github/webhook"
// webhookSecretEnvVar is the name of an environment variable that contains a
// GitHub app SECRET which is required to verify inbound request signatures.
webhookSecretEnvVar = "GITHUB_WEBHOOK_SECRET"
// githubAppIDHeader is the HTTP header that contains the GitHub app ID of an incoming event.
githubAppIDHeader = "X-GitHub-Hook-Installation-Target-ID"
)
// handler is an autokitteh webhook which implements [http.Handler] to
// receive, dispatch, and acknowledge asynchronous event notifications.
type handler struct {
logger *zap.Logger
vars sdkservices.Vars
dispatcher sdkservices.Dispatcher
integrationID sdktypes.IntegrationID
}
func NewHandler(l *zap.Logger, vars sdkservices.Vars, d sdkservices.Dispatcher, id sdktypes.IntegrationID) handler {
return handler{
logger: l,
vars: vars,
dispatcher: d,
integrationID: id,
}
}
// ServeHTTP dispatches to autokitteh an asynchronous event notification that our GitHub app subscribed
// to. See https://github.com/organizations/autokitteh/settings/apps/autokitteh/permissions.
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
l := h.logger.With(zap.String("urlPath", r.URL.Path))
var (
payload []byte
err error
userCID sdktypes.ConnectionID
)
if strings.HasSuffix(r.URL.Path, "/github/webhook") {
// Validate that the inbound HTTP request has a valid content type
// and a valid signature header, and if so parse the received event.
payload, err = github.ValidatePayload(r, []byte(os.Getenv(webhookSecretEnvVar)))
if err != nil {
l.Warn("Received invalid app event payload",
zap.Error(err),
)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
} else {
// This event is for a user-defined webhook, not an app.
path := strings.Split(r.URL.Path, "/")
suffix := path[len(path)-1]
cids, err := h.vars.FindConnectionIDs(r.Context(), h.integrationID, vars.PATKey, suffix)
if err != nil {
l.Warn("Unrecognized user event payload",
zap.Error(err),
)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
if len(cids) != 1 {
l.Warn("Unexpected number of connection tokens for user event",
zap.String("suffix", suffix),
zap.Int("n", len(cids)),
)
http.Error(w, "Internal Server error", http.StatusInternalServerError)
return
}
userCID = cids[0]
data, err := h.vars.Reveal(r.Context(), sdktypes.NewVarScopeID(userCID), vars.PATSecret)
if err != nil {
l.Warn("Unrecognized connection for user event payload",
zap.String("suffix", suffix),
zap.String("cid", userCID.String()),
zap.Error(err),
)
http.Error(w, "Internal Server error", http.StatusInternalServerError)
return
}
payload, err = github.ValidatePayload(r, []byte(data.GetValue(vars.PATSecret)))
if err != nil {
l.Warn("Received invalid user event payload",
zap.Error(err),
)
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
}
eventType := github.WebHookType(r)
ghEvent, err := github.ParseWebHook(eventType, payload)
if err != nil {
l.Warn("Received unrecognized event type",
zap.Error(err),
zap.ByteString("payload", payload),
)
http.Error(w, "Not Implemented", http.StatusNotImplemented)
return
}
appID := r.Header.Get(githubAppIDHeader)
var installID string
if !userCID.IsValid() {
if installID, err = extractInstallationID(ghEvent); err != nil {
l.Error("Failed to extract installation ID and user", zap.Error(err))
http.Error(w, "no installation or user specified", http.StatusBadRequest)
return
}
}
// Transform the received GitHub event into an autokitteh event.
data, err := transformEvent(l, w, ghEvent)
if err != nil {
return
}
akEvent := &sdktypes.EventPB{
EventType: eventType,
Data: data,
}
// Retrieve all the relevant connections for this event.
var cids []sdktypes.ConnectionID
if userCID.IsValid() {
cids = append(cids, userCID) // User-defined webhook.
}
if installID != "" {
// App webhook.
icids, err := h.vars.FindConnectionIDs(
r.Context(),
h.integrationID,
vars.InstallKey(appID, installID),
"",
)
if err != nil {
l.Error("Failed to retrieve connection tokens", zap.Error(err))
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
cids = append(cids, icids...)
}
// Dispatch the event to all of them, for asynchronous handling.
dispatchAsyncEventsToConnections(l, cids, akEvent, h.dispatcher)
// Returning immediately without an error = acknowledgement of receipt.
}
func extractInstallationID(event any) (inst string, err error) {
type itf interface {
GetInstallation() *github.Installation
}
obj, ok := event.(itf)
if !ok {
err = errors.New("event does not have installation")
return
}
pinst := obj.GetInstallation()
if pinst == nil {
err = errors.New("event does not have installation")
return
}
inst = strconv.FormatInt(*(pinst).ID, 10)
return
}
// transformEvent transforms a received GitHub event into an autokitteh event.
func transformEvent(l *zap.Logger, w http.ResponseWriter, event any) (map[string]*valuesv1.Value, error) {
wrapped, err := sdktypes.DefaultValueWrapper.Wrap(event)
if err != nil {
l.Error("Failed to wrap GitHub event",
zap.Error(err),
zap.Any("event", event),
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return nil, err
}
data, err := wrapped.ToStringValuesMap()
if err != nil {
l.Error("Failed to convert wrapped GitHub event",
zap.Error(err),
zap.Any("event", event),
)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return nil, err
}
return kittehs.TransformMapValues(data, sdktypes.ToProto), nil
}
func dispatchAsyncEventsToConnections(l *zap.Logger, cids []sdktypes.ConnectionID, event *eventsv1.Event, d sdkservices.Dispatcher) {
ctx := extrazap.AttachLoggerToContext(l, context.Background())
for _, cid := range cids {
l := l.With(zap.String("cid", cid.String()))
event.ConnectionId = cid.String()
e := kittehs.Must1(sdktypes.EventFromProto(event))
eventID, err := d.Dispatch(ctx, e, nil)
if err != nil {
l.Error("Dispatch failed", zap.Error(err))
return
}
l.Debug("Dispatched", zap.String("eventID", eventID.String()))
}
}