88756a72fa
- Swagger UI restricted to localhost only - Message content length limit (4000 chars max) on create + update - Bot/server/webhook ownership checks return 404 instead of 403 (prevents resource ID enumeration) - Fix .gitignore: /server instead of server to stop ignoring internal/server/ directory
428 lines
12 KiB
Go
428 lines
12 KiB
Go
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.StatusNotFound, "webhook not found")
|
|
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
|
|
}
|