package webhook import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "strings" "time" "ymhut-box/server/feedback-mailer/internal/config" "ymhut-box/server/feedback-mailer/internal/db" ) type Dispatcher struct { cfg *config.Config store *db.Store } type Event struct { Event string `json:"event"` Delivery string `json:"delivery"` OccurredAt string `json:"occurredAt"` Data any `json:"data"` } func NewDispatcher(cfg *config.Config, store *db.Store) *Dispatcher { return &Dispatcher{cfg: cfg, store: store} } func (d *Dispatcher) Dispatch(event string, data any) { if d == nil || len(d.cfg.Webhooks) == 0 { return } for _, hook := range d.cfg.Webhooks { if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) { continue } hookCopy := hook go d.Deliver(hookCopy, event, data) } } func (d *Dispatcher) DispatchSync(event string, data any) { if d == nil || len(d.cfg.Webhooks) == 0 { return } for _, hook := range d.cfg.Webhooks { if !hook.Enabled || hook.URL == "" || !matchesEvent(hook.Events, event) { continue } d.Deliver(hook, event, data) } } func (d *Dispatcher) DispatchTest(data any) int { if d == nil || len(d.cfg.Webhooks) == 0 { return 0 } count := 0 for _, hook := range d.cfg.Webhooks { if !hook.Enabled || hook.URL == "" { continue } d.Deliver(hook, "feedback.test", data) count++ } return count } func (d *Dispatcher) Deliver(hook config.WebhookConfig, event string, data any) { if d == nil || hook.URL == "" { return } payload, deliveryKey := buildPayload(event, data) sum := sha256.Sum256(payload) id, err := d.store.InsertWebhookDelivery(db.WebhookDelivery{ WebhookName: hook.Name, Event: event, Status: "pending", PayloadSHA256: hex.EncodeToString(sum[:]), CreatedAt: db.Now(), }) if err != nil { return } maxRetries := hook.MaxRetries if maxRetries < 0 { maxRetries = 0 } attempts := 0 status := "failed" responseCode := 0 errorMessage := "" for attempts <= maxRetries { attempts++ code, err := postJSON(hook, event, deliveryKey, payload) responseCode = code if err == nil && code >= 200 && code < 300 { status = "sent" errorMessage = "" break } if err != nil { errorMessage = err.Error() } else { errorMessage = "webhook returned HTTP " + http.StatusText(code) if errorMessage == "webhook returned HTTP " { errorMessage = "webhook returned HTTP status" } } if attempts <= maxRetries { time.Sleep(time.Duration(attempts) * 350 * time.Millisecond) } } _ = d.store.FinishWebhookDelivery(id, status, attempts, responseCode, errorMessage) } func buildPayload(event string, data any) ([]byte, string) { now := db.Now() rawDelivery := sha256.Sum256([]byte(event + "\n" + now + "\n" + db.ToJSON(data))) delivery := hex.EncodeToString(rawDelivery[:16]) payload := Event{Event: event, Delivery: delivery, OccurredAt: now, Data: data} encoded, err := json.Marshal(payload) if err != nil { return []byte(`{"event":"` + event + `"}`), delivery } return encoded, delivery } func postJSON(hook config.WebhookConfig, event, delivery string, body []byte) (int, error) { timeout := hook.TimeoutSeconds if timeout <= 0 { timeout = 5 } request, err := http.NewRequest(http.MethodPost, hook.URL, bytes.NewReader(body)) if err != nil { return 0, err } request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "YMhut-Feedback-Webhook/1.0") request.Header.Set("X-YMhut-Event", event) request.Header.Set("X-YMhut-Delivery", delivery) request.Header.Set("X-YMhut-Signature", sign(hook.Secret, body)) client := &http.Client{Timeout: time.Duration(timeout) * time.Second} response, err := client.Do(request) if err != nil { return 0, err } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096)) return response.StatusCode, nil } func sign(secret string, body []byte) string { mac := hmac.New(sha256.New, []byte(secret)) _, _ = mac.Write(body) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } func matchesEvent(events []string, event string) bool { if len(events) == 0 { return true } for _, candidate := range events { candidate = strings.TrimSpace(candidate) if candidate == "*" || candidate == event { return true } if strings.HasSuffix(candidate, ".*") && strings.HasPrefix(event, strings.TrimSuffix(candidate, "*")) { return true } } return false }