@@ -0,0 +1,180 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user