package webhook import ( "context" "database/sql" "encoding/json" "errors" "net/http" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway" "git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware" "github.com/go-chi/chi/v5" ) // Handler handles webhook CRUD and execution routes. type Handler struct { db *sql.DB hub *gateway.Hub } // NewHandler creates a new webhook Handler. func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler { return &Handler{db: db, hub: hub} } // RegisterRoutes registers authenticated webhook routes. // // POST /channels/{channelID}/webhooks - create webhook // GET /channels/{channelID}/webhooks - list webhooks for channel // DELETE /webhooks/{webhookID} - delete webhook func (h *Handler) RegisterRoutes(r chi.Router) { r.Post("/channels/{channelID}/webhooks", h.Create) r.Get("/channels/{channelID}/webhooks", h.List) r.Delete("/webhooks/{webhookID}", h.Delete) } // RegisterPublicRoutes registers the public webhook execution route (no auth). // // POST /webhooks/{webhookID}/{token} - execute webhook func (h *Handler) RegisterPublicRoutes(r chi.Router) { r.Post("/webhooks/{webhookID}/{token}", h.Execute) } // ---- types ---- type webhookResponse struct { ID string `json:"id"` ChannelID string `json:"channel_id"` Name string `json:"name"` Avatar *string `json:"avatar"` CreatedBy string `json:"created_by"` CreatedAt string `json:"created_at"` } type webhookWithToken struct { webhookResponse Token string `json:"token"` } type createWebhookRequest struct { Name string `json:"name"` Avatar *string `json:"avatar"` } type executeWebhookRequest struct { Content string `json:"content"` Username *string `json:"username"` Avatar *string `json:"avatar"` } // ---- helpers ---- func writeJSON(w http.ResponseWriter, status int, v interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, msg string) { http.Error(w, `{"error":"`+msg+`"}`, status) } // ---- handlers ---- // @Summary Create a webhook // @Description Create a new webhook for a channel // @Tags webhooks // @Accept json // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Param body body createWebhookRequest true "Webhook data" // @Success 201 {object} webhookWithToken // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /channels/{channelID}/webhooks [post] // Create creates a new webhook for a channel. func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") // Verify channel exists and user is a member of its server serverID, err := h.serverIDForChannel(r.Context(), channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "channel not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } if !member { writeErr(w, http.StatusForbidden, "not a member of this server") return } var req createWebhookRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } if req.Name == "" { writeErr(w, http.StatusBadRequest, "name is required") return } token := genToken() tokenHash := hashToken(token) var wh webhookWithToken var avatar sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` INSERT INTO webhooks (channel_id, name, token, token_hash, avatar, created_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, channel_id, name, avatar, created_by, created_at::text `, channelID, req.Name, token, tokenHash, req.Avatar, userID).Scan( &wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to create webhook") return } if avatar.Valid { wh.Avatar = &avatar.String } wh.CreatedAt = createdAt.String wh.Token = token writeJSON(w, http.StatusCreated, wh) } // @Summary List webhooks // @Description List webhooks for a channel // @Tags webhooks // @Produce json // @Security SessionAuth // @Param channelID path string true "Channel ID" // @Success 200 {array} webhookResponse // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /channels/{channelID}/webhooks [get] // List returns all webhooks for a channel. func (h *Handler) List(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } channelID := chi.URLParam(r, "channelID") serverID, err := h.serverIDForChannel(r.Context(), channelID) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "channel not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } member, err := h.isMember(r.Context(), userID, serverID) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } if !member { writeErr(w, http.StatusForbidden, "not a member of this server") return } rows, err := h.db.QueryContext(r.Context(), ` SELECT id, channel_id, name, avatar, created_by, created_at::text FROM webhooks WHERE channel_id = $1 ORDER BY created_at DESC `, channelID) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } defer rows.Close() webhooks := make([]webhookResponse, 0) for rows.Next() { var wh webhookResponse var avatar sql.NullString var createdAt sql.NullString if err := rows.Scan(&wh.ID, &wh.ChannelID, &wh.Name, &avatar, &wh.CreatedBy, &createdAt); err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } if avatar.Valid { wh.Avatar = &avatar.String } wh.CreatedAt = createdAt.String webhooks = append(webhooks, wh) } if err := rows.Err(); err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } writeJSON(w, http.StatusOK, webhooks) } // @Summary Delete a webhook // @Description Delete a webhook // @Tags webhooks // @Security SessionAuth // @Param webhookID path string true "Webhook ID" // @Success 204 // @Failure 401 {object} map[string]string // @Failure 403 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /webhooks/{webhookID} [delete] // Delete removes a webhook. func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) { userID, ok := middleware.UserIDFromContext(r.Context()) if !ok { writeErr(w, http.StatusUnauthorized, "unauthorized") return } webhookID := chi.URLParam(r, "webhookID") var createdBy string err := h.db.QueryRowContext(r.Context(), ` SELECT created_by FROM webhooks WHERE id = $1 `, webhookID).Scan(&createdBy) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "webhook not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if createdBy != userID { writeErr(w, http.StatusForbidden, "forbidden") return } _, err = h.db.ExecContext(r.Context(), `DELETE FROM webhooks WHERE id = $1`, webhookID) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to delete webhook") return } w.WriteHeader(http.StatusNoContent) } // @Summary Execute a webhook // @Description Execute a webhook to send a message (no auth required) // @Tags webhooks // @Accept json // @Produce json // @Param webhookID path string true "Webhook ID" // @Param token path string true "Webhook token" // @Param body body executeWebhookRequest true "Message data" // @Success 201 {object} map[string]interface{} // @Failure 400 {object} map[string]string // @Failure 401 {object} map[string]string // @Failure 404 {object} map[string]string // @Router /webhooks/{webhookID}/{token} [post] // Execute sends a message to a channel via webhook (no auth required). func (h *Handler) Execute(w http.ResponseWriter, r *http.Request) { webhookID := chi.URLParam(r, "webhookID") token := chi.URLParam(r, "token") // Look up webhook and verify token via hash comparison var storedHash, channelID, webhookName string var webhookAvatar sql.NullString err := h.db.QueryRowContext(r.Context(), ` SELECT token_hash, channel_id, name, avatar FROM webhooks WHERE id = $1 `, webhookID).Scan(&storedHash, &channelID, &webhookName, &webhookAvatar) if err != nil { if errors.Is(err, sql.ErrNoRows) { writeErr(w, http.StatusNotFound, "webhook not found") } else { writeErr(w, http.StatusInternalServerError, "server error") } return } if hashToken(token) != storedHash { writeErr(w, http.StatusUnauthorized, "invalid token") return } var req executeWebhookRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "invalid request") return } if req.Content == "" { writeErr(w, http.StatusBadRequest, "content is required") return } // Determine display name and avatar for the message author displayName := webhookName if req.Username != nil && *req.Username != "" { displayName = *req.Username } avatarURL := "" if req.Avatar != nil && *req.Avatar != "" { avatarURL = *req.Avatar } else if webhookAvatar.Valid { avatarURL = webhookAvatar.String } // Insert the message. We use the webhook_id as the author_id marker. // For bots/webhooks, author_id is set to a special value; alternatively // we can store it as the webhook id (which is a UUID but not a user). // The existing schema requires author_id FK to users, so we store the // webhook owner's created_by as the author to satisfy the FK constraint. var createdBy string err = h.db.QueryRowContext(r.Context(), ` SELECT created_by FROM webhooks WHERE id = $1 `, webhookID).Scan(&createdBy) if err != nil { writeErr(w, http.StatusInternalServerError, "server error") return } // Create the message type msgResp struct { ID string `json:"id"` ChannelID string `json:"channel_id"` AuthorID string `json:"author_id"` AuthorName string `json:"author_username"` DisplayName *string `json:"author_display_name"` Content string `json:"content"` EditedAt *string `json:"edited_at"` CreatedAt string `json:"created_at"` } var msg msgResp var editedAt sql.NullString var createdAt sql.NullString err = h.db.QueryRowContext(r.Context(), ` INSERT INTO messages (channel_id, author_id, content) VALUES ($1, $2, $3) RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text `, channelID, createdBy, req.Content).Scan( &msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt, ) if err != nil { writeErr(w, http.StatusInternalServerError, "failed to send message") return } if editedAt.Valid { msg.EditedAt = &editedAt.String } msg.CreatedAt = createdAt.String // Override author display info with webhook identity msg.AuthorName = displayName if avatarURL != "" { msg.DisplayName = &displayName } else { msg.DisplayName = &displayName } // Broadcast MESSAGE_CREATE via gateway (scoped to server) if h.hub != nil { serverID, _ := h.hub.ServerIDForChannel(r.Context(), channelID) if serverID != "" { h.hub.BroadcastToServer(serverID, gateway.Event{ Type: gateway.EventMessageCreate, Data: msg, }) } } writeJSON(w, http.StatusCreated, msg) } // ---- internal helpers ---- func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) { var serverID string err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID) return serverID, err } func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool, error) { var exists bool err := h.db.QueryRowContext(ctx, ` SELECT EXISTS(SELECT 1 FROM members WHERE user_id = $1 AND server_id = $2) `, userID, serverID).Scan(&exists) return exists, err }