Monorepo for Tangled
tangled.org
1package repo
2
3import (
4 "net/http"
5 "strconv"
6 "strings"
7
8 "github.com/bluesky-social/indigo/atproto/syntax"
9 "github.com/go-chi/chi/v5"
10 "tangled.org/core/appview/db"
11 "tangled.org/core/appview/models"
12 "tangled.org/core/appview/pages"
13)
14
15// Webhooks displays the webhooks settings page
16func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) {
17 l := rp.logger.With("handler", "Webhooks")
18
19 f, err := rp.repoResolver.Resolve(r)
20 if err != nil {
21 l.Error("failed to get repo and knot", "err", err)
22 w.WriteHeader(http.StatusBadRequest)
23 return
24 }
25
26 user := rp.oauth.GetMultiAccountUser(r)
27
28 webhooks, err := db.GetWebhooksForRepo(rp.db, f.RepoDid)
29 if err != nil {
30 l.Error("failed to get webhooks", "err", err)
31 rp.pages.Notice(w, "webhooks-error", "Failed to load webhooks")
32 return
33 }
34
35 // fetch recent deliveries for each webhook
36 deliveriesMap := make(map[int64][]models.WebhookDelivery)
37 for _, webhook := range webhooks {
38 deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 4)
39 if err != nil {
40 l.Error("failed to get webhook deliveries", "webhook_id", webhook.Id, "err", err)
41 // continue even if we can't get deliveries for one webhook
42 continue
43 }
44 deliveriesMap[webhook.Id] = deliveries
45 }
46
47 rp.pages.RepoWebhooksSettings(w, pages.RepoWebhooksSettingsParams{
48 BaseParams: pages.BaseParamsFromContext(r.Context()),
49 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
50 Webhooks: webhooks,
51 WebhookDeliveries: deliveriesMap,
52 })
53}
54
55// AddWebhook creates a new webhook
56func (rp *Repo) AddWebhook(w http.ResponseWriter, r *http.Request) {
57 l := rp.logger.With("handler", "AddWebhook")
58
59 f, err := rp.repoResolver.Resolve(r)
60 if err != nil {
61 l.Error("failed to get repo and knot", "err", err)
62 w.WriteHeader(http.StatusBadRequest)
63 return
64 }
65
66 url := strings.TrimSpace(r.FormValue("url"))
67 if url == "" {
68 rp.pages.Notice(w, "webhooks-error", "Webhook URL is required")
69 return
70 }
71
72 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
73 rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://")
74 return
75 }
76
77 secret := strings.TrimSpace(r.FormValue("secret"))
78 // if secret is empty, we don't sign
79
80 active := r.FormValue("active") == "on"
81
82 events := []string{}
83 if r.FormValue("event_push") == "on" {
84 events = append(events, string(models.WebhookEventPush))
85 }
86 if r.FormValue("event_repo_renamed") == "on" {
87 events = append(events, string(models.WebhookEventRepoRenamed))
88 }
89
90 if len(events) == 0 {
91 rp.pages.Notice(w, "webhooks-error", "At least one event must be enabled")
92 return
93 }
94
95 webhook := &models.Webhook{
96 RepoDid: syntax.DID(f.RepoDid),
97 Url: url,
98 Secret: secret,
99 Active: active,
100 Events: events,
101 }
102
103 tx, err := rp.db.Begin()
104 if err != nil {
105 l.Error("failed to start transaction", "err", err)
106 rp.pages.Notice(w, "webhooks-error", "Failed to create webhook")
107 return
108 }
109 defer tx.Rollback()
110
111 if err := db.AddWebhook(tx, webhook); err != nil {
112 l.Error("failed to add webhook", "err", err)
113 rp.pages.Notice(w, "webhooks-error", "Failed to create webhook")
114 return
115 }
116
117 if err := tx.Commit(); err != nil {
118 l.Error("failed to commit transaction", "err", err)
119 rp.pages.Notice(w, "webhooks-error", "Failed to create webhook")
120 return
121 }
122
123 rp.pages.HxRefresh(w)
124}
125
126// UpdateWebhook updates an existing webhook
127func (rp *Repo) UpdateWebhook(w http.ResponseWriter, r *http.Request) {
128 l := rp.logger.With("handler", "UpdateWebhook")
129
130 f, err := rp.repoResolver.Resolve(r)
131 if err != nil {
132 l.Error("failed to get repo and knot", "err", err)
133 w.WriteHeader(http.StatusBadRequest)
134 return
135 }
136
137 idStr := chi.URLParam(r, "id")
138 id, err := strconv.ParseInt(idStr, 10, 64)
139 if err != nil {
140 l.Error("invalid webhook id", "err", err)
141 w.WriteHeader(http.StatusBadRequest)
142 return
143 }
144
145 webhook, err := db.GetWebhook(rp.db, id)
146 if err != nil {
147 l.Error("failed to get webhook", "err", err)
148 rp.pages.Notice(w, "webhooks-error", "Webhook not found")
149 return
150 }
151
152 // Verify webhook belongs to this repo
153 if string(webhook.RepoDid) != f.RepoDid {
154 l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid)
155 w.WriteHeader(http.StatusForbidden)
156 return
157 }
158
159 url := strings.TrimSpace(r.FormValue("url"))
160 if url != "" {
161 if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
162 rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://")
163 return
164 }
165 webhook.Url = url
166 }
167
168 secret := strings.TrimSpace(r.FormValue("secret"))
169 if secret != "" {
170 webhook.Secret = secret
171 }
172
173 webhook.Active = r.FormValue("active") == "on"
174
175 events := []string{}
176 if r.FormValue("event_push") == "on" {
177 events = append(events, string(models.WebhookEventPush))
178 }
179 if r.FormValue("event_repo_renamed") == "on" {
180 events = append(events, string(models.WebhookEventRepoRenamed))
181 }
182
183 if len(events) > 0 {
184 webhook.Events = events
185 }
186
187 tx, err := rp.db.Begin()
188 if err != nil {
189 l.Error("failed to start transaction", "err", err)
190 rp.pages.Notice(w, "webhooks-error", "Failed to update webhook")
191 return
192 }
193 defer tx.Rollback()
194
195 if err := db.UpdateWebhook(tx, webhook); err != nil {
196 l.Error("failed to update webhook", "err", err)
197 rp.pages.Notice(w, "webhooks-error", "Failed to update webhook")
198 return
199 }
200
201 if err := tx.Commit(); err != nil {
202 l.Error("failed to commit transaction", "err", err)
203 rp.pages.Notice(w, "webhooks-error", "Failed to update webhook")
204 return
205 }
206
207 rp.pages.HxRefresh(w)
208}
209
210// DeleteWebhook deletes a webhook
211func (rp *Repo) DeleteWebhook(w http.ResponseWriter, r *http.Request) {
212 l := rp.logger.With("handler", "DeleteWebhook")
213
214 f, err := rp.repoResolver.Resolve(r)
215 if err != nil {
216 l.Error("failed to get repo and knot", "err", err)
217 w.WriteHeader(http.StatusBadRequest)
218 return
219 }
220
221 idStr := chi.URLParam(r, "id")
222 id, err := strconv.ParseInt(idStr, 10, 64)
223 if err != nil {
224 l.Error("invalid webhook id", "err", err)
225 w.WriteHeader(http.StatusBadRequest)
226 return
227 }
228
229 webhook, err := db.GetWebhook(rp.db, id)
230 if err != nil {
231 l.Error("failed to get webhook", "err", err)
232 w.WriteHeader(http.StatusNotFound)
233 return
234 }
235
236 // Verify webhook belongs to this repo
237 if string(webhook.RepoDid) != f.RepoDid {
238 l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid)
239 w.WriteHeader(http.StatusForbidden)
240 return
241 }
242
243 tx, err := rp.db.Begin()
244 if err != nil {
245 l.Error("failed to start transaction", "err", err)
246 rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook")
247 return
248 }
249 defer tx.Rollback()
250
251 if err := db.DeleteWebhook(tx, id); err != nil {
252 l.Error("failed to delete webhook", "err", err)
253 rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook")
254 return
255 }
256
257 if err := tx.Commit(); err != nil {
258 l.Error("failed to commit transaction", "err", err)
259 rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook")
260 return
261 }
262
263 rp.pages.HxRefresh(w)
264}
265
266// ToggleWebhook toggles the active state of a webhook
267func (rp *Repo) ToggleWebhook(w http.ResponseWriter, r *http.Request) {
268 l := rp.logger.With("handler", "ToggleWebhook")
269
270 f, err := rp.repoResolver.Resolve(r)
271 if err != nil {
272 l.Error("failed to get repo and knot", "err", err)
273 w.WriteHeader(http.StatusBadRequest)
274 return
275 }
276
277 idStr := chi.URLParam(r, "id")
278 id, err := strconv.ParseInt(idStr, 10, 64)
279 if err != nil {
280 l.Error("invalid webhook id", "err", err)
281 w.WriteHeader(http.StatusBadRequest)
282 return
283 }
284
285 webhook, err := db.GetWebhook(rp.db, id)
286 if err != nil {
287 l.Error("failed to get webhook", "err", err)
288 w.WriteHeader(http.StatusNotFound)
289 return
290 }
291
292 // Verify webhook belongs to this repo
293 if string(webhook.RepoDid) != f.RepoDid {
294 l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid)
295 w.WriteHeader(http.StatusForbidden)
296 return
297 }
298
299 // Toggle the active state
300 webhook.Active = !webhook.Active
301
302 tx, err := rp.db.Begin()
303 if err != nil {
304 l.Error("failed to start transaction", "err", err)
305 rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook")
306 return
307 }
308 defer tx.Rollback()
309
310 if err := db.UpdateWebhook(tx, webhook); err != nil {
311 l.Error("failed to update webhook", "err", err)
312 rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook")
313 return
314 }
315
316 if err := tx.Commit(); err != nil {
317 l.Error("failed to commit transaction", "err", err)
318 rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook")
319 return
320 }
321
322 rp.pages.HxRefresh(w)
323}
324
325// WebhookDeliveries returns all deliveries for a webhook (for modal display)
326func (rp *Repo) WebhookDeliveries(w http.ResponseWriter, r *http.Request) {
327 l := rp.logger.With("handler", "WebhookDeliveries")
328
329 f, err := rp.repoResolver.Resolve(r)
330 if err != nil {
331 l.Error("failed to get repo and knot", "err", err)
332 w.WriteHeader(http.StatusBadRequest)
333 return
334 }
335
336 idStr := chi.URLParam(r, "id")
337 id, err := strconv.ParseInt(idStr, 10, 64)
338 if err != nil {
339 l.Error("invalid webhook id", "err", err)
340 w.WriteHeader(http.StatusBadRequest)
341 return
342 }
343
344 webhook, err := db.GetWebhook(rp.db, id)
345 if err != nil {
346 l.Error("failed to get webhook", "err", err)
347 w.WriteHeader(http.StatusNotFound)
348 return
349 }
350
351 // Verify webhook belongs to this repo
352 if string(webhook.RepoDid) != f.RepoDid {
353 l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid)
354 w.WriteHeader(http.StatusForbidden)
355 return
356 }
357
358 deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 100)
359 if err != nil {
360 l.Error("failed to get webhook deliveries", "err", err)
361 rp.pages.Notice(w, "webhooks-error", "Failed to load deliveries")
362 return
363 }
364
365 user := rp.oauth.GetMultiAccountUser(r)
366
367 rp.pages.WebhookDeliveriesList(w, pages.WebhookDeliveriesListParams{
368 BaseParams: pages.BaseParamsFromContext(r.Context()),
369 RepoInfo: rp.repoResolver.GetRepoInfo(r, user),
370 Webhook: webhook,
371 Deliveries: deliveries,
372 })
373}