mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-18 16:01:18 +00:00
293 lines
8.8 KiB
Go
293 lines
8.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/webhook"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/utils/paging"
|
|
)
|
|
|
|
// webhookEndpointPayload is the wire shape for create/update requests.
|
|
// The secret is server-generated and only returned at create / rotate
|
|
// time — clients cannot set it directly.
|
|
type webhookEndpointPayload struct {
|
|
URL string `json:"url"`
|
|
Description string `json:"description"`
|
|
EventTypes []string `json:"event_types"`
|
|
Enabled *bool `json:"enabled"`
|
|
}
|
|
|
|
// ListWebhookEventCatalog serves the full outbound event vocabulary so
|
|
// integrators can discover every event (and which are high-volume/opt-in)
|
|
// without scraping the docs. Public within the org gate.
|
|
//
|
|
// GET /webhooks/event-types
|
|
func (h *Handler) ListWebhookEventCatalog(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"event_types": models.WebhookEventCatalog})
|
|
}
|
|
|
|
// ListWebhookEndpoints returns every endpoint configured for the caller's
|
|
// organization. Secrets are not included.
|
|
func (h *Handler) ListWebhookEndpoints(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
endpoints, err := h.WebhookService.ListEndpoints(c.Request.Context(), orgID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to list endpoints"))
|
|
return
|
|
}
|
|
if endpoints == nil {
|
|
endpoints = []models.WebhookEndpoint{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"endpoints": endpoints,
|
|
"event_types": models.WebhookEventCatalog,
|
|
})
|
|
}
|
|
|
|
// CreateWebhookEndpoint creates a new subscription. The response includes
|
|
// the signing secret — this is the only time it is returned, so clients
|
|
// must capture it. Subsequent reads return the endpoint without the secret.
|
|
//
|
|
// When the caller authenticated with an OAuth access token, the endpoint is
|
|
// bound to that app and its URL host must fall within the app's allowed
|
|
// webhook domains.
|
|
func (h *Handler) CreateWebhookEndpoint(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var p webhookEndpointPayload
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid payload"))
|
|
return
|
|
}
|
|
enabled := true
|
|
if p.Enabled != nil {
|
|
enabled = *p.Enabled
|
|
}
|
|
in := webhook.EndpointInput{
|
|
URL: p.URL,
|
|
Description: p.Description,
|
|
EventTypes: p.EventTypes,
|
|
Enabled: enabled,
|
|
OAuthApplicationID: middleware.GetOAuthApplicationID(c),
|
|
}
|
|
if uid, err := middleware.GetUserUUID(c); err == nil {
|
|
in.CreatedBy = &uid
|
|
}
|
|
endpoint, err := h.WebhookService.CreateEndpoint(c.Request.Context(), orgID, in)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
|
|
return
|
|
}
|
|
endpointID := endpoint.ID
|
|
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityWebhook, &endpointID, nil, map[string]string{"url": endpoint.URL})
|
|
c.JSON(http.StatusCreated, endpoint)
|
|
}
|
|
|
|
// UpdateWebhookEndpoint updates a subscription. The secret is not changed
|
|
// here — use POST /webhooks/:id/rotate-secret to rotate it. Changing the URL
|
|
// host re-arms ownership verification.
|
|
func (h *Handler) UpdateWebhookEndpoint(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
endpointID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
var p webhookEndpointPayload
|
|
if err := c.ShouldBindJSON(&p); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid payload"))
|
|
return
|
|
}
|
|
enabled := true
|
|
if p.Enabled != nil {
|
|
enabled = *p.Enabled
|
|
}
|
|
endpoint, err := h.WebhookService.UpdateEndpoint(c.Request.Context(), orgID, endpointID, p.URL, p.Description, p.EventTypes, enabled)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityWebhook, &endpointID, nil, nil)
|
|
c.JSON(http.StatusOK, endpoint)
|
|
}
|
|
|
|
// DeleteWebhookEndpoint deletes a subscription and cascades to its
|
|
// delivery history.
|
|
func (h *Handler) DeleteWebhookEndpoint(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
endpointID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
if err := h.WebhookService.DeleteEndpoint(c.Request.Context(), orgID, endpointID); err != nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, err.Error()))
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityWebhook, &endpointID, nil, nil)
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// RotateWebhookSecret issues a new signing secret and returns it once.
|
|
// Existing in-flight deliveries continue to use the old secret until they
|
|
// settle; new deliveries use the new one.
|
|
func (h *Handler) RotateWebhookSecret(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
endpointID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
secret, err := h.WebhookService.RotateSecret(c.Request.Context(), orgID, endpointID)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, err.Error()))
|
|
return
|
|
}
|
|
h.auditOrg(c, models.AuditActionRotate, models.AuditEntityWebhook, &endpointID, nil, map[string]string{"rotated": "true"})
|
|
c.JSON(http.StatusOK, gin.H{"secret": secret})
|
|
}
|
|
|
|
// VerifyWebhookEndpoint (re)sends the signed challenge / test request to the
|
|
// endpoint so it can prove it accepts our deliveries. Doubles as "send a test
|
|
// event": a 2xx verifies the endpoint and starts the event stream.
|
|
//
|
|
// POST /webhooks/:id/verify
|
|
func (h *Handler) VerifyWebhookEndpoint(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
endpointID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
if err := h.WebhookService.VerifyEndpoint(c.Request.Context(), orgID, endpointID); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"status": "challenge_sent"})
|
|
}
|
|
|
|
// ListWebhookDeliveries returns recent delivery attempts (org-wide, or for one
|
|
// endpoint via ?endpoint_id=), filterable by status/event_type, with opaque
|
|
// cursor pagination.
|
|
//
|
|
// GET /webhooks/deliveries and GET /webhooks/:id/deliveries
|
|
func (h *Handler) ListWebhookDeliveries(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
filter := models.WebhookDeliveryFilter{
|
|
Status: c.Query("status"),
|
|
EventType: c.Query("event_type"),
|
|
}
|
|
|
|
// Endpoint id can come from the path (/:id/deliveries) or the query.
|
|
if raw := c.Param("id"); raw != "" {
|
|
id, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
filter.EndpointID = &id
|
|
} else if raw := c.Query("endpoint_id"); raw != "" {
|
|
id, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid endpoint id"))
|
|
return
|
|
}
|
|
filter.EndpointID = &id
|
|
}
|
|
|
|
filter.Limit = 50
|
|
if raw := c.Query("limit"); raw != "" {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n <= 0 || n > 200 {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "limit must be between 1 and 200"))
|
|
return
|
|
}
|
|
filter.Limit = n
|
|
}
|
|
|
|
offset, xerr := paging.DecodeOffsetCursor(c.Query("cursor"))
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
filter.Offset = offset
|
|
|
|
result, err := h.WebhookService.ListDeliveries(c.Request.Context(), orgID, filter)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to list deliveries"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// RedeliverWebhookDelivery re-queues an existing delivery (same event id) for a
|
|
// fresh attempt cycle.
|
|
//
|
|
// POST /webhooks/deliveries/:deliveryId/redeliver
|
|
func (h *Handler) RedeliverWebhookDelivery(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
deliveryID, err := uuid.Parse(c.Param("deliveryId"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid delivery id"))
|
|
return
|
|
}
|
|
if err := h.WebhookService.Redeliver(c.Request.Context(), orgID, deliveryID); err != nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, err.Error()))
|
|
return
|
|
}
|
|
c.JSON(http.StatusAccepted, gin.H{"status": "queued"})
|
|
}
|
|
|
|
// ListWebhookDrops surfaces events the dispatch throttle dropped (rate-limiting
|
|
// visibility), rolled up by day for the last 30 days.
|
|
//
|
|
// GET /webhooks/throttle-drops
|
|
func (h *Handler) ListWebhookDrops(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
since := time.Now().UTC().AddDate(0, 0, -30)
|
|
drops, err := h.WebhookService.ListEventDrops(c.Request.Context(), orgID, since)
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.Internal, "failed to list throttle drops"))
|
|
return
|
|
}
|
|
if drops == nil {
|
|
drops = []models.WebhookEventDrop{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"drops": drops})
|
|
}
|