ghost-listmonk-connector/handlers/webhook_handler.go
troneras ff5b39241a first commit
fixed cache

Refactoring from app router to page router

Refactoring from app router to page router

Add authentication with JWT

base ui done

protect the dashboard

added transitions

styling

type trick added

cleanup

Add enable-disable logic

add transactional emails

Improvements on delay and frontend

added description on select

implement auth with magic link

migrate to mariadb

webhook signature working

Adding async processing with queues

Implemented webhook reply functionality

Add son execution logs

update status logic

Add recent activity monitoring to sons

show son performance in dashboard

add readme

implement listmonk connector

listmonk-connector-v1

Create CODE_OF_CONDUCT.md

Create LICENSE
2024-08-21 02:09:24 +02:00

290 lines
9.5 KiB
Go

package handlers
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/troneras/ghost-listmonk-connector/models"
"github.com/troneras/ghost-listmonk-connector/services"
"github.com/troneras/ghost-listmonk-connector/utils"
)
type WebhookHandler struct {
sonStorage *services.SonStorage
executor *services.SonExecutor
webhookService *services.WebhookService
webhookLogger *services.WebhookLogger
}
func NewWebhookHandler(sonStorage *services.SonStorage, executor *services.SonExecutor, webhookService *services.WebhookService, webhookLogger *services.WebhookLogger) *WebhookHandler {
return &WebhookHandler{
sonStorage: sonStorage,
executor: executor,
webhookService: webhookService,
webhookLogger: webhookLogger,
}
}
func (h *WebhookHandler) HandleWebhook(c *gin.Context) {
startTime := time.Now()
endpoint := c.Param("endpoint")
webhook, err := h.webhookService.GetWebhookByEndpoint(endpoint)
if err != nil {
utils.ErrorLogger.Printf("Webhook not found: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"})
return
}
// Read the request body
body, err := io.ReadAll(c.Request.Body)
if err != nil {
utils.ErrorLogger.Printf("Failed to read request body: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"})
return
}
// Create initial webhook log
webhookLogID, err := h.webhookLogger.CreateWebhookLog(webhook.UserID, c.Request, body)
if err != nil {
utils.ErrorLogger.Printf("Failed to create webhook log: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to log webhook"})
return
}
// Verify the webhook signature
signature := c.GetHeader("x-ghost-signature")
if !verifySignature(signature, body, webhook.Secret) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusUnauthorized, gin.H{"error": "Invalid signature"}, time.Since(startTime))
return
}
var webhookData map[string]interface{}
if err := json.Unmarshal(body, &webhookData); err != nil {
utils.ErrorLogger.Errorf("Invalid webhook data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook data"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusBadRequest, gin.H{"error": "Invalid webhook data"}, time.Since(startTime))
return
}
utils.InfoLogger.Infof("Received webhook: %s", utils.PrettyPrint(webhookData))
triggerType, err := determineTriggerType(webhookData)
if err != nil {
utils.ErrorLogger.Errorf("Unable to determine trigger type: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusBadRequest, gin.H{"error": "Unable to determine trigger type"}, time.Since(startTime))
return
}
utils.InfoLogger.Infof("Determined trigger type: %s", triggerType)
// Find and execute relevant Sons
sons, err := h.sonStorage.List(webhook.UserID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to list Sons: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"})
h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusInternalServerError, gin.H{"error": "Failed to list Sons"}, time.Since(startTime))
return
}
executedCount := 0
for _, son := range sons {
if son.Trigger == triggerType && son.Enabled {
go func(s models.Son) {
utils.InfoLogger.Infof("Executing Son %s for trigger %s", s.ID, triggerType)
h.executor.ExecuteSon(s, webhookData, webhookLogID)
}(son)
executedCount++
}
}
utils.InfoLogger.Infof("Executed %d Sons for trigger %s", executedCount, triggerType)
// Prepare response
response := gin.H{"message": "Webhook processed successfully", "sons_executed": executedCount}
c.JSON(http.StatusOK, response)
// Update the webhook log
duration := time.Since(startTime)
err = h.webhookLogger.UpdateWebhookLog(webhookLogID, http.StatusOK, response, duration)
if err != nil {
utils.ErrorLogger.Errorf("Failed to update webhook log: %v", err)
}
}
func (h *WebhookHandler) ReplayWebhook(c *gin.Context) {
logID := c.Param("id")
// Get the original webhook log
log, err := h.webhookLogger.GetWebhookLogForReplay(logID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to get webhook log for replay: %v", err)
c.JSON(http.StatusNotFound, gin.H{"error": "Webhook log not found"})
return
}
// Construct the webhook URL
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
// Check for X-Forwarded-Proto header
if forwardedProto := c.GetHeader("X-Forwarded-Proto"); forwardedProto != "" {
scheme = forwardedProto
}
webhookURL := fmt.Sprintf("%s://%s%s", scheme, c.Request.Host, log.Path)
req, err := http.NewRequest(log.Method, webhookURL, bytes.NewBuffer([]byte(log.Body)))
if err != nil {
utils.ErrorLogger.Errorf("Failed to create replay request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create replay request"})
return
}
// Set the original headers
var headers map[string]string
if err := json.Unmarshal([]byte(log.Headers), &headers); err != nil {
utils.ErrorLogger.Errorf("Failed to unmarshal headers: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process headers"})
return
}
for k, v := range headers {
req.Header.Set(k, v)
}
// Add a custom header to indicate this is a replay
req.Header.Set("X-Webhook-Replay", "true")
// Send the request to our own webhook endpoint
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
utils.ErrorLogger.Errorf("Failed to send replay request: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to replay webhook"})
return
}
defer resp.Body.Close()
// Read the response
respBody, err := io.ReadAll(resp.Body)
if err != nil {
utils.ErrorLogger.Errorf("Failed to read replay response: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process replay response"})
return
}
// Return the response from the webhook endpoint
c.Data(resp.StatusCode, resp.Header.Get("Content-Type"), respBody)
}
func (h *WebhookHandler) GetWebhookInfo(c *gin.Context) {
user, exists := c.Get("user")
if !exists {
utils.ErrorLogger.Println("User not found in context")
c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"})
return
}
currentUser := user.(*models.User)
webhooks, err := h.webhookService.GetWebhooksByUserID(currentUser.ID)
if err != nil {
utils.ErrorLogger.Errorf("Failed to get webhooks: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get webhooks"})
return
}
if len(webhooks) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "No webhook found for user"})
return
}
webhooks[0].Endpoint = utils.GetConfig().FrontendURL + "/webhook/" + webhooks[0].Endpoint
// For now, we'll just return the first webhook
c.JSON(http.StatusOK, gin.H{"data": webhooks[0]})
}
func verifySignature(signature string, payload []byte, secret string) bool {
// Split the signature into its components
parts := strings.Split(signature, ", ")
if len(parts) != 2 {
utils.ErrorLogger.Println("Invalid signature format")
return false
}
receivedSignature := strings.TrimPrefix(parts[0], "sha256=")
timestamp := strings.TrimPrefix(parts[1], "t=")
// Append the timestamp to the payload
fullPayload := append(payload, []byte(timestamp)...)
// Compute the expected signature
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(fullPayload)
expectedSignature := hex.EncodeToString(mac.Sum(nil))
// Compare the signatures
if receivedSignature != expectedSignature {
utils.ErrorLogger.Printf("Signature mismatch. Received: %s, Expected: %s", receivedSignature, expectedSignature)
return false
}
return true
}
func determineTriggerType(webhookData map[string]interface{}) (models.TriggerType, error) {
if member, ok := webhookData["member"].(map[string]interface{}); ok {
if _, ok := member["current"].(map[string]interface{}); ok {
if previous, ok := member["previous"].(map[string]interface{}); ok && len(previous) > 0 {
utils.InfoLogger.Infof("Member updated. Previous data: %v", previous)
return models.TriggerMemberUpdated, nil
}
utils.InfoLogger.Infof("New member created")
return models.TriggerMemberCreated, nil
}
utils.InfoLogger.Infof("Member deleted. Previous data: %v", member["previous"])
return models.TriggerMemberDeleted, nil
}
if post, ok := webhookData["post"].(map[string]interface{}); ok {
if current, ok := post["current"].(map[string]interface{}); ok {
if status, ok := current["status"].(string); ok {
switch status {
case "published":
utils.InfoLogger.Infof("Post published")
return models.TriggerPostPublished, nil
case "scheduled":
utils.InfoLogger.Infof("Post scheduled")
return models.TriggerPostScheduled, nil
default:
utils.InfoLogger.Infof("Unhandled post status: %s", status)
}
}
}
}
if page, ok := webhookData["page"].(map[string]interface{}); ok {
if current, ok := page["current"].(map[string]interface{}); ok {
if status, ok := current["status"].(string); ok && status == "published" {
utils.InfoLogger.Infof("Page published")
return models.TriggerPagePublished, nil
}
}
}
// Log the entire webhook data for debugging
utils.ErrorLogger.Printf("Unable to determine trigger type. Webhook data: %+v", webhookData)
return "", utils.NewError("UnknownTriggerType", "Unable to determine trigger type from webhook data")
}