309 lines
8.2 KiB
Go
309 lines
8.2 KiB
Go
|
|
package tenant
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"database/sql"
|
||
|
|
"encoding/hex"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Webhook struct {
|
||
|
|
ID string `json:"id"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
URL string `json:"url"`
|
||
|
|
Events []string `json:"events"`
|
||
|
|
Secret string `json:"secret,omitempty"`
|
||
|
|
Enabled bool `json:"enabled"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||
|
|
LastStatus *string `json:"last_status"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type WebhookDelivery struct {
|
||
|
|
ID int64 `json:"id"`
|
||
|
|
WebhookID string `json:"webhook_id"`
|
||
|
|
Event string `json:"event"`
|
||
|
|
Payload string `json:"payload"`
|
||
|
|
Status string `json:"status"`
|
||
|
|
ResponseCode *int `json:"response_code"`
|
||
|
|
ResponseBody *string `json:"response_body"`
|
||
|
|
Attempts int `json:"attempts"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type WebhookPayload struct {
|
||
|
|
Event string `json:"event"`
|
||
|
|
Timestamp time.Time `json:"timestamp"`
|
||
|
|
Data any `json:"data"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) CountWebhooks(ctx context.Context) (int, error) {
|
||
|
|
var count int
|
||
|
|
err := q.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM webhooks`).Scan(&count)
|
||
|
|
return count, err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) CreateWebhook(ctx context.Context, name, url string, events []string, secret string) (*Webhook, error) {
|
||
|
|
id := uuid.New().String()
|
||
|
|
eventsJSON, _ := json.Marshal(events)
|
||
|
|
|
||
|
|
_, err := q.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO webhooks (id, name, url, events, secret, enabled, created_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
|
||
|
|
`, id, name, url, string(eventsJSON), secret)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
return q.GetWebhook(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) GetWebhook(ctx context.Context, id string) (*Webhook, error) {
|
||
|
|
var w Webhook
|
||
|
|
var eventsJSON string
|
||
|
|
var lastTriggeredAt, lastStatus sql.NullString
|
||
|
|
var createdAtStr string
|
||
|
|
|
||
|
|
err := q.db.QueryRowContext(ctx, `
|
||
|
|
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
|
||
|
|
FROM webhooks WHERE id = ?
|
||
|
|
`, id).Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus)
|
||
|
|
if err == sql.ErrNoRows {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
json.Unmarshal([]byte(eventsJSON), &w.Events)
|
||
|
|
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||
|
|
if lastTriggeredAt.Valid {
|
||
|
|
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
|
||
|
|
w.LastTriggeredAt = &t
|
||
|
|
}
|
||
|
|
if lastStatus.Valid {
|
||
|
|
w.LastStatus = &lastStatus.String
|
||
|
|
}
|
||
|
|
|
||
|
|
return &w, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) ListWebhooks(ctx context.Context) ([]Webhook, error) {
|
||
|
|
rows, err := q.db.QueryContext(ctx, `
|
||
|
|
SELECT id, name, url, events, secret, enabled, created_at, last_triggered_at, last_status
|
||
|
|
FROM webhooks ORDER BY created_at DESC
|
||
|
|
`)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var webhooks []Webhook
|
||
|
|
for rows.Next() {
|
||
|
|
var w Webhook
|
||
|
|
var eventsJSON string
|
||
|
|
var lastTriggeredAt, lastStatus sql.NullString
|
||
|
|
var createdAtStr string
|
||
|
|
|
||
|
|
if err := rows.Scan(&w.ID, &w.Name, &w.URL, &eventsJSON, &w.Secret, &w.Enabled, &createdAtStr, &lastTriggeredAt, &lastStatus); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
json.Unmarshal([]byte(eventsJSON), &w.Events)
|
||
|
|
w.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||
|
|
if lastTriggeredAt.Valid {
|
||
|
|
t, _ := time.Parse(time.RFC3339, lastTriggeredAt.String)
|
||
|
|
w.LastTriggeredAt = &t
|
||
|
|
}
|
||
|
|
if lastStatus.Valid {
|
||
|
|
w.LastStatus = &lastStatus.String
|
||
|
|
}
|
||
|
|
|
||
|
|
webhooks = append(webhooks, w)
|
||
|
|
}
|
||
|
|
|
||
|
|
return webhooks, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) UpdateWebhook(ctx context.Context, id, name, url string, events []string, secret string, enabled bool) error {
|
||
|
|
eventsJSON, _ := json.Marshal(events)
|
||
|
|
|
||
|
|
_, err := q.db.ExecContext(ctx, `
|
||
|
|
UPDATE webhooks SET name = ?, url = ?, events = ?, secret = ?, enabled = ?
|
||
|
|
WHERE id = ?
|
||
|
|
`, name, url, string(eventsJSON), secret, enabled, id)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) DeleteWebhook(ctx context.Context, id string) error {
|
||
|
|
_, err := q.db.ExecContext(ctx, `DELETE FROM webhooks WHERE id = ?`, id)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) ListWebhooksByEvent(ctx context.Context, event string) ([]Webhook, error) {
|
||
|
|
webhooks, err := q.ListWebhooks(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
var result []Webhook
|
||
|
|
for _, w := range webhooks {
|
||
|
|
if !w.Enabled {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, e := range w.Events {
|
||
|
|
if e == event {
|
||
|
|
result = append(result, w)
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) TriggerWebhooks(ctx context.Context, event string, data any, baseURL string) {
|
||
|
|
webhooks, err := q.ListWebhooksByEvent(ctx, event)
|
||
|
|
if err != nil || len(webhooks) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
payload := WebhookPayload{
|
||
|
|
Event: event,
|
||
|
|
Timestamp: time.Now().UTC(),
|
||
|
|
Data: data,
|
||
|
|
}
|
||
|
|
payloadJSON, _ := json.Marshal(payload)
|
||
|
|
|
||
|
|
for _, w := range webhooks {
|
||
|
|
go q.deliverWebhook(ctx, w, event, payloadJSON)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) deliverWebhook(ctx context.Context, w Webhook, event string, payloadJSON []byte) {
|
||
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
||
|
|
|
||
|
|
req, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewReader(payloadJSON))
|
||
|
|
if err != nil {
|
||
|
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
req.Header.Set("Content-Type", "application/json")
|
||
|
|
req.Header.Set("User-Agent", "WriteKit-Webhook/1.0")
|
||
|
|
|
||
|
|
if w.Secret != "" {
|
||
|
|
mac := hmac.New(sha256.New, []byte(w.Secret))
|
||
|
|
mac.Write(payloadJSON)
|
||
|
|
signature := hex.EncodeToString(mac.Sum(nil))
|
||
|
|
req.Header.Set("X-WriteKit-Signature", signature)
|
||
|
|
}
|
||
|
|
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), "failed", nil, stringPtr(err.Error()))
|
||
|
|
q.updateWebhookStatus(ctx, w.ID, "failed")
|
||
|
|
return
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
var respBody string
|
||
|
|
buf := make([]byte, 1024)
|
||
|
|
n, _ := resp.Body.Read(buf)
|
||
|
|
respBody = string(buf[:n])
|
||
|
|
|
||
|
|
status := "success"
|
||
|
|
if resp.StatusCode >= 400 {
|
||
|
|
status = "failed"
|
||
|
|
}
|
||
|
|
|
||
|
|
q.logDelivery(ctx, w.ID, event, string(payloadJSON), status, &resp.StatusCode, &respBody)
|
||
|
|
q.updateWebhookStatus(ctx, w.ID, status)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) logDelivery(ctx context.Context, webhookID, event, payload, status string, responseCode *int, responseBody *string) {
|
||
|
|
q.db.ExecContext(ctx, `
|
||
|
|
INSERT INTO webhook_deliveries (webhook_id, event, payload, status, response_code, response_body, created_at)
|
||
|
|
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||
|
|
`, webhookID, event, truncate(payload, 1024), status, responseCode, truncate(ptrToString(responseBody), 1024))
|
||
|
|
|
||
|
|
// Cleanup old deliveries - keep last 50 per webhook
|
||
|
|
q.db.ExecContext(ctx, `
|
||
|
|
DELETE FROM webhook_deliveries
|
||
|
|
WHERE webhook_id = ? AND id NOT IN (
|
||
|
|
SELECT id FROM webhook_deliveries WHERE webhook_id = ?
|
||
|
|
ORDER BY created_at DESC LIMIT 50
|
||
|
|
)
|
||
|
|
`, webhookID, webhookID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) updateWebhookStatus(ctx context.Context, webhookID, status string) {
|
||
|
|
q.db.ExecContext(ctx, `
|
||
|
|
UPDATE webhooks SET last_triggered_at = CURRENT_TIMESTAMP, last_status = ?
|
||
|
|
WHERE id = ?
|
||
|
|
`, status, webhookID)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (q *Queries) ListWebhookDeliveries(ctx context.Context, webhookID string) ([]WebhookDelivery, error) {
|
||
|
|
rows, err := q.db.QueryContext(ctx, `
|
||
|
|
SELECT id, webhook_id, event, payload, status, response_code, response_body, attempts, created_at
|
||
|
|
FROM webhook_deliveries
|
||
|
|
WHERE webhook_id = ?
|
||
|
|
ORDER BY created_at DESC
|
||
|
|
LIMIT 50
|
||
|
|
`, webhookID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
var deliveries []WebhookDelivery
|
||
|
|
for rows.Next() {
|
||
|
|
var d WebhookDelivery
|
||
|
|
var createdAtStr string
|
||
|
|
var respCode sql.NullInt64
|
||
|
|
var respBody sql.NullString
|
||
|
|
|
||
|
|
if err := rows.Scan(&d.ID, &d.WebhookID, &d.Event, &d.Payload, &d.Status, &respCode, &respBody, &d.Attempts, &createdAtStr); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
d.CreatedAt, _ = time.Parse(time.RFC3339, createdAtStr)
|
||
|
|
if respCode.Valid {
|
||
|
|
code := int(respCode.Int64)
|
||
|
|
d.ResponseCode = &code
|
||
|
|
}
|
||
|
|
if respBody.Valid {
|
||
|
|
d.ResponseBody = &respBody.String
|
||
|
|
}
|
||
|
|
|
||
|
|
deliveries = append(deliveries, d)
|
||
|
|
}
|
||
|
|
|
||
|
|
return deliveries, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func truncate(s string, max int) string {
|
||
|
|
if len(s) <= max {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
return s[:max]
|
||
|
|
}
|
||
|
|
|
||
|
|
func stringPtr(s string) *string {
|
||
|
|
return &s
|
||
|
|
}
|
||
|
|
|
||
|
|
func ptrToString(p *string) string {
|
||
|
|
if p == nil {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return *p
|
||
|
|
}
|