181 lines
4.4 KiB
Go
181 lines
4.4 KiB
Go
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
|
|
}
|