From 79e860340a8ecf504e4f1c67944d5510695ee309 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Thu, 6 Aug 2026 15:56:02 +0300 Subject: [PATCH] appview/repo: serve webhook settings from spindle The settings handlers now proxy to the repo's spindle via SpindleServiceClient and adapt responses to the existing view models. Add rpc:org.tangled.temp.webhook.*?aud=* oauth scopes. Signed-off-by: dawn --- appview/oauth/scopes.go | 7 + .../pages/templates/repo/settings/hooks.html | 2 +- appview/repo/webhooks.go | 336 ++++++++++-------- 3 files changed, 194 insertions(+), 151 deletions(-) diff --git a/appview/oauth/scopes.go b/appview/oauth/scopes.go index 05eb68e8f..981b8ee9f 100644 --- a/appview/oauth/scopes.go +++ b/appview/oauth/scopes.go @@ -33,6 +33,13 @@ var TangledScopes = []string{ "rpc:sh.tangled.knot.removeMember?aud=*", "rpc:sh.tangled.ci.triggerPipeline?aud=*", "rpc:sh.tangled.ci.cancelPipeline?aud=*", + "rpc:org.tangled.temp.webhook.createWebhook?aud=*", + "rpc:org.tangled.temp.webhook.updateWebhook?aud=*", + "rpc:org.tangled.temp.webhook.deleteWebhook?aud=*", + "rpc:org.tangled.temp.webhook.toggleWebhook?aud=*", + "rpc:org.tangled.temp.webhook.listWebhooks?aud=*", + "rpc:org.tangled.temp.webhook.listDeliveries?aud=*", + "rpc:org.tangled.temp.webhook.retryDelivery?aud=*", "rpc:sh.tangled.repo.addCollaborator?aud=*", "rpc:sh.tangled.repo.addSecret?aud=*", "rpc:sh.tangled.repo.create?aud=*", diff --git a/appview/pages/templates/repo/settings/hooks.html b/appview/pages/templates/repo/settings/hooks.html index d1437b705..a68234dde 100644 --- a/appview/pages/templates/repo/settings/hooks.html +++ b/appview/pages/templates/repo/settings/hooks.html @@ -310,7 +310,7 @@ class="w-full font-mono text-sm" />

- Leave blank to keep the existing secret. Remove value to disable signing. + Leave blank to keep the existing secret.

diff --git a/appview/repo/webhooks.go b/appview/repo/webhooks.go index c11d22b10..677224a1b 100644 --- a/appview/repo/webhooks.go +++ b/appview/repo/webhooks.go @@ -4,10 +4,10 @@ import ( "net/http" "strconv" "strings" + "time" - "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" - "tangled.org/core/appview/db" + "tangled.org/core/api/tangled" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" ) @@ -36,7 +36,51 @@ func webhookEventsFromForm(r *http.Request) []string { return events } -// Webhooks displays the webhooks settings page +// webhookFromXrpc adapts a spindle listWebhooks item into the view model the +// settings templates render. +func webhookFromXrpc(wh *tangled.TempWebhookListWebhooks_Webhook) models.Webhook { + m := models.Webhook{ + Id: wh.Id, + Url: wh.Url, + Active: wh.Active, + Events: wh.Events, + } + if t, err := time.Parse(time.RFC3339, wh.CreatedAt); err == nil { + m.CreatedAt = t + } + if wh.UpdatedAt != nil { + if t, err := time.Parse(time.RFC3339, *wh.UpdatedAt); err == nil { + m.UpdatedAt = t + } + } + return m +} + +func deliveryFromXrpc(d *tangled.TempWebhookGetDeliveriesForWebhooks_Delivery) models.WebhookDelivery { + m := models.WebhookDelivery{ + Id: d.Id, + DeliveryId: d.DeliveryId, + Event: d.Event, + Url: d.Url, + Success: d.Success, + } + if d.RequestBody != nil { + m.RequestBody = *d.RequestBody + } + if d.ResponseBody != nil { + m.ResponseBody = *d.ResponseBody + } + if d.ResponseCode != nil { + m.ResponseCode = int(*d.ResponseCode) + } + if t, err := time.Parse(time.RFC3339, d.CreatedAt); err == nil { + m.CreatedAt = t + } + return m +} + +// Webhooks displays the webhooks settings page. Webhooks now live on the repo's +// spindle; this handler proxies to the spindle's XRPC API. func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "Webhooks") @@ -49,23 +93,51 @@ func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) { user := rp.oauth.GetMultiAccountUser(r) - webhooks, err := db.GetWebhooksForRepo(rp.db, f.RepoDid) + if f.Spindle == "" { + rp.pages.RepoWebhooksSettings(w, pages.RepoWebhooksSettingsParams{ + BaseParams: pages.BaseParamsFromContext(r.Context()), + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), + }) + rp.pages.Notice(w, "webhooks-error", "This repository has no spindle configured; webhooks are unavailable.") + return + } + + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookListWebhooksNSID) if err != nil { - l.Error("failed to get webhooks", "err", err) + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") + return + } + + out, err := tangled.TempWebhookListWebhooks(r.Context(), client, f.RepoDid) + if err != nil { + l.Error("failed to list webhooks", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to load webhooks") return } - // fetch recent deliveries for each webhook - deliveriesMap := make(map[int64][]models.WebhookDelivery) + webhooks := make([]models.Webhook, 0, len(out.Webhooks)) + for _, wh := range out.Webhooks { + webhooks = append(webhooks, webhookFromXrpc(wh)) + } + + const deliveriesPerWebhook = 4 + deliveriesMap := make(map[int64][]models.WebhookDelivery, len(webhooks)) + ids := make([]int64, 0, len(webhooks)) for _, webhook := range webhooks { - deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 4) - if err != nil { - l.Error("failed to get webhook deliveries", "webhook_id", webhook.Id, "err", err) - // continue even if we can't get deliveries for one webhook - continue + ids = append(ids, webhook.Id) + } + if len(ids) > 0 { + if dclient, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookGetDeliveriesForWebhooksNSID); err == nil { + dout, err := tangled.TempWebhookGetDeliveriesForWebhooks(r.Context(), dclient, ids, deliveriesPerWebhook, f.RepoDid) + if err != nil { + l.Error("failed to get webhook deliveries", "err", err) + } else { + for _, d := range dout.Deliveries { + deliveriesMap[d.WebhookId] = append(deliveriesMap[d.WebhookId], deliveryFromXrpc(d)) + } + } } - deliveriesMap[webhook.Id] = deliveries } rp.pages.RepoWebhooksSettings(w, pages.RepoWebhooksSettingsParams{ @@ -76,7 +148,7 @@ func (rp *Repo) Webhooks(w http.ResponseWriter, r *http.Request) { }) } -// AddWebhook creates a new webhook +// AddWebhook creates a new webhook on the repo's spindle. func (rp *Repo) AddWebhook(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "AddWebhook") @@ -86,61 +158,54 @@ func (rp *Repo) AddWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } + if f.Spindle == "" { + rp.pages.Notice(w, "webhooks-error", "This repository has no spindle configured; webhooks are unavailable.") + return + } url := strings.TrimSpace(r.FormValue("url")) if url == "" { rp.pages.Notice(w, "webhooks-error", "Webhook URL is required") return } - if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://") return } - secret := strings.TrimSpace(r.FormValue("secret")) - // if secret is empty, we don't sign - - active := r.FormValue("active") == "on" - events := webhookEventsFromForm(r) if len(events) == 0 { rp.pages.Notice(w, "webhooks-error", "At least one event must be enabled") return } - webhook := &models.Webhook{ - RepoDid: syntax.DID(f.RepoDid), + active := r.FormValue("active") == "on" + input := &tangled.TempWebhookCreateWebhook_Input{ + RepoDid: f.RepoDid, Url: url, - Secret: secret, - Active: active, Events: events, + Active: &active, + } + if secret := strings.TrimSpace(r.FormValue("secret")); secret != "" { + input.Secret = &secret } - tx, err := rp.db.Begin() + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookCreateWebhookNSID) if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") return } - defer tx.Rollback() - - if err := db.AddWebhook(tx, webhook); err != nil { + if _, err := tangled.TempWebhookCreateWebhook(r.Context(), client, input); err != nil { l.Error("failed to add webhook", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") return } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to create webhook") - return - } - rp.pages.HxRefresh(w) } -// UpdateWebhook updates an existing webhook +// UpdateWebhook updates an existing webhook on the repo's spindle. func (rp *Repo) UpdateWebhook(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "UpdateWebhook") @@ -150,74 +215,55 @@ func (rp *Repo) UpdateWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - l.Error("invalid webhook id", "err", err) - w.WriteHeader(http.StatusBadRequest) + if f.Spindle == "" { + rp.pages.Notice(w, "webhooks-error", "This repository has no spindle configured; webhooks are unavailable.") return } - webhook, err := db.GetWebhook(rp.db, id) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { - l.Error("failed to get webhook", "err", err) - rp.pages.Notice(w, "webhooks-error", "Webhook not found") + l.Error("invalid webhook id", "err", err) + w.WriteHeader(http.StatusBadRequest) return } - // Verify webhook belongs to this repo - if string(webhook.RepoDid) != f.RepoDid { - l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid) - w.WriteHeader(http.StatusForbidden) - return + active := r.FormValue("active") == "on" + input := &tangled.TempWebhookUpdateWebhook_Input{ + Id: id, + RepoDid: f.RepoDid, + Active: &active, } - url := strings.TrimSpace(r.FormValue("url")) if url != "" { if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { rp.pages.Notice(w, "webhooks-error", "Webhook URL must start with http:// or https://") return } - webhook.Url = url + input.Url = &url } - - secret := strings.TrimSpace(r.FormValue("secret")) - if secret != "" { - webhook.Secret = secret + if secret := strings.TrimSpace(r.FormValue("secret")); secret != "" { + input.Secret = &secret } - - webhook.Active = r.FormValue("active") == "on" - - events := webhookEventsFromForm(r) - if len(events) > 0 { - webhook.Events = events + if events := webhookEventsFromForm(r); len(events) > 0 { + input.Events = events } - tx, err := rp.db.Begin() + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookUpdateWebhookNSID) if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") return } - defer tx.Rollback() - - if err := db.UpdateWebhook(tx, webhook); err != nil { + if err := tangled.TempWebhookUpdateWebhook(r.Context(), client, input); err != nil { l.Error("failed to update webhook", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") return } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to update webhook") - return - } - rp.pages.HxRefresh(w) } -// DeleteWebhook deletes a webhook +// DeleteWebhook deletes a webhook on the repo's spindle. func (rp *Repo) DeleteWebhook(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "DeleteWebhook") @@ -227,53 +273,37 @@ func (rp *Repo) DeleteWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - l.Error("invalid webhook id", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - webhook, err := db.GetWebhook(rp.db, id) - if err != nil { - l.Error("failed to get webhook", "err", err) + if f.Spindle == "" { w.WriteHeader(http.StatusNotFound) return } - // Verify webhook belongs to this repo - if string(webhook.RepoDid) != f.RepoDid { - l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid) - w.WriteHeader(http.StatusForbidden) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil { + l.Error("invalid webhook id", "err", err) + w.WriteHeader(http.StatusBadRequest) return } - tx, err := rp.db.Begin() + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookDeleteWebhookNSID) if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") return } - defer tx.Rollback() - - if err := db.DeleteWebhook(tx, id); err != nil { + if err := tangled.TempWebhookDeleteWebhook(r.Context(), client, &tangled.TempWebhookDeleteWebhook_Input{ + Id: id, + RepoDid: f.RepoDid, + }); err != nil { l.Error("failed to delete webhook", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") return } - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to delete webhook") - return - } - rp.pages.HxRefresh(w) } -// ToggleWebhook toggles the active state of a webhook +// ToggleWebhook toggles the active state of a webhook on the repo's spindle. func (rp *Repo) ToggleWebhook(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "ToggleWebhook") @@ -283,48 +313,29 @@ func (rp *Repo) ToggleWebhook(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseInt(idStr, 10, 64) - if err != nil { - l.Error("invalid webhook id", "err", err) - w.WriteHeader(http.StatusBadRequest) - return - } - - webhook, err := db.GetWebhook(rp.db, id) - if err != nil { - l.Error("failed to get webhook", "err", err) + if f.Spindle == "" { w.WriteHeader(http.StatusNotFound) return } - // Verify webhook belongs to this repo - if string(webhook.RepoDid) != f.RepoDid { - l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid) - w.WriteHeader(http.StatusForbidden) - return - } - - // Toggle the active state - webhook.Active = !webhook.Active - - tx, err := rp.db.Begin() + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { - l.Error("failed to start transaction", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") + l.Error("invalid webhook id", "err", err) + w.WriteHeader(http.StatusBadRequest) return } - defer tx.Rollback() - if err := db.UpdateWebhook(tx, webhook); err != nil { - l.Error("failed to update webhook", "err", err) - rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookToggleWebhookNSID) + if err != nil { + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") return } - - if err := tx.Commit(); err != nil { - l.Error("failed to commit transaction", "err", err) + if _, err := tangled.TempWebhookToggleWebhook(r.Context(), client, &tangled.TempWebhookToggleWebhook_Input{ + Id: id, + RepoDid: f.RepoDid, + }); err != nil { + l.Error("failed to toggle webhook", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to toggle webhook") return } @@ -332,7 +343,7 @@ func (rp *Repo) ToggleWebhook(w http.ResponseWriter, r *http.Request) { rp.pages.HxRefresh(w) } -// WebhookDeliveries returns all deliveries for a webhook (for modal display) +// WebhookDeliveries returns all deliveries for a webhook (for modal display). func (rp *Repo) WebhookDeliveries(w http.ResponseWriter, r *http.Request) { l := rp.logger.With("handler", "WebhookDeliveries") @@ -342,37 +353,62 @@ func (rp *Repo) WebhookDeliveries(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } + if f.Spindle == "" { + w.WriteHeader(http.StatusNotFound) + return + } - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseInt(idStr, 10, 64) + id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { l.Error("invalid webhook id", "err", err) w.WriteHeader(http.StatusBadRequest) return } - webhook, err := db.GetWebhook(rp.db, id) + user := rp.oauth.GetMultiAccountUser(r) + + // look up the webhook (for its URL header) and its deliveries + listClient, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookListWebhooksNSID) if err != nil { - l.Error("failed to get webhook", "err", err) + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") + return + } + listOut, err := tangled.TempWebhookListWebhooks(r.Context(), listClient, f.RepoDid) + if err != nil { + l.Error("failed to list webhooks", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to load deliveries") + return + } + var webhook *models.Webhook + for _, wh := range listOut.Webhooks { + if wh.Id == id { + m := webhookFromXrpc(wh) + webhook = &m + break + } + } + if webhook == nil { w.WriteHeader(http.StatusNotFound) return } - // Verify webhook belongs to this repo - if string(webhook.RepoDid) != f.RepoDid { - l.Error("webhook does not belong to repo", "webhook_repo", webhook.RepoDid, "current_repo", f.RepoDid) - w.WriteHeader(http.StatusForbidden) + client, err := rp.oauth.SpindleServiceClient(r, f.Spindle, tangled.TempWebhookGetDeliveriesForWebhooksNSID) + if err != nil { + l.Error("failed to build spindle client", "err", err) + rp.pages.Notice(w, "webhooks-error", "Failed to authorize with spindle") return } - - deliveries, err := db.GetWebhookDeliveries(rp.db, webhook.Id, 100) + dout, err := tangled.TempWebhookGetDeliveriesForWebhooks(r.Context(), client, []int64{id}, 100, f.RepoDid) if err != nil { l.Error("failed to get webhook deliveries", "err", err) rp.pages.Notice(w, "webhooks-error", "Failed to load deliveries") return } - - user := rp.oauth.GetMultiAccountUser(r) + deliveries := make([]models.WebhookDelivery, 0, len(dout.Deliveries)) + for _, d := range dout.Deliveries { + deliveries = append(deliveries, deliveryFromXrpc(d)) + } rp.pages.WebhookDeliveriesList(w, pages.WebhookDeliveriesListParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), -- 2.51.2