75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
278 lines
6.6 KiB
Go
278 lines
6.6 KiB
Go
package webhook_notifier
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
|
|
"databasus-backend/internal/util/encryption"
|
|
)
|
|
|
|
type WebhookHeader struct {
|
|
Key string `json:"key"`
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// Before both WebhookURL, BodyTemplate and HeadersJSON were considered
|
|
// as sensetive data and it was causing issues. Now only headers values
|
|
// considered as sensetive data, but we try to decrypt webhook URL and
|
|
// body template for backward combability
|
|
type WebhookNotifier struct {
|
|
NotifierID uuid.UUID `json:"notifierId" gorm:"primaryKey;column:notifier_id"`
|
|
WebhookURL string `json:"webhookUrl" gorm:"not null;column:webhook_url"`
|
|
WebhookMethod WebhookMethod `json:"webhookMethod" gorm:"not null;column:webhook_method"`
|
|
BodyTemplate *string `json:"bodyTemplate" gorm:"column:body_template;type:text"`
|
|
HeadersJSON string `json:"-" gorm:"column:headers;type:text"`
|
|
|
|
Headers []WebhookHeader `json:"headers" gorm:"-"`
|
|
}
|
|
|
|
func (t *WebhookNotifier) TableName() string {
|
|
return "webhook_notifiers"
|
|
}
|
|
|
|
func (t *WebhookNotifier) BeforeSave(_ *gorm.DB) error {
|
|
if len(t.Headers) > 0 {
|
|
data, err := json.Marshal(t.Headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
t.HeadersJSON = string(data)
|
|
} else {
|
|
t.HeadersJSON = "[]"
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) AfterFind(_ *gorm.DB) error {
|
|
if t.HeadersJSON != "" {
|
|
if err := json.Unmarshal([]byte(t.HeadersJSON), &t.Headers); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
encryptor := encryption.GetFieldEncryptor()
|
|
|
|
if t.WebhookURL != "" {
|
|
if decrypted, err := encryptor.Decrypt(t.WebhookURL); err == nil {
|
|
t.WebhookURL = decrypted
|
|
}
|
|
}
|
|
|
|
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
|
|
if decrypted, err := encryptor.Decrypt(*t.BodyTemplate); err == nil {
|
|
t.BodyTemplate = &decrypted
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error {
|
|
if t.WebhookURL == "" {
|
|
return errors.New("webhook URL is required")
|
|
}
|
|
|
|
if t.WebhookMethod == "" {
|
|
return errors.New("webhook method is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) Send(
|
|
encryptor encryption.FieldEncryptor,
|
|
logger *slog.Logger,
|
|
heading string,
|
|
message string,
|
|
) error {
|
|
if err := t.decryptHeadersForSending(encryptor); err != nil {
|
|
return err
|
|
}
|
|
|
|
switch t.WebhookMethod {
|
|
case WebhookMethodGET:
|
|
return t.sendGET(t.WebhookURL, heading, message, logger)
|
|
case WebhookMethodPOST:
|
|
return t.sendPOST(t.WebhookURL, heading, message, logger)
|
|
default:
|
|
return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod)
|
|
}
|
|
}
|
|
|
|
func (t *WebhookNotifier) HideSensitiveData() {
|
|
for i := range t.Headers {
|
|
t.Headers[i].Value = ""
|
|
}
|
|
}
|
|
|
|
func (t *WebhookNotifier) Update(incoming *WebhookNotifier) {
|
|
t.WebhookURL = incoming.WebhookURL
|
|
t.WebhookMethod = incoming.WebhookMethod
|
|
t.BodyTemplate = incoming.BodyTemplate
|
|
t.Headers = incoming.Headers
|
|
}
|
|
|
|
func (t *WebhookNotifier) EncryptSensitiveData(encryptor encryption.FieldEncryptor) error {
|
|
for i := range t.Headers {
|
|
if t.Headers[i].Value != "" {
|
|
encrypted, err := encryptor.Encrypt(t.Headers[i].Value)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to encrypt header value: %w", err)
|
|
}
|
|
|
|
t.Headers[i].Value = encrypted
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) sendGET(webhookURL, heading, message string, logger *slog.Logger) error {
|
|
reqURL := fmt.Sprintf("%s?heading=%s&message=%s",
|
|
webhookURL,
|
|
url.QueryEscape(heading),
|
|
url.QueryEscape(message),
|
|
)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create GET request: %w", err)
|
|
}
|
|
|
|
t.applyHeaders(req)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send GET webhook: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if cerr := resp.Body.Close(); cerr != nil {
|
|
logger.Error("failed to close response body", "error", cerr)
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf(
|
|
"webhook GET returned status: %s, body: %s",
|
|
resp.Status,
|
|
string(body),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) sendPOST(webhookURL, heading, message string, logger *slog.Logger) error {
|
|
body := t.buildRequestBody(heading, message)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create POST request: %w", err)
|
|
}
|
|
|
|
hasContentType := false
|
|
|
|
for _, h := range t.Headers {
|
|
if strings.EqualFold(h.Key, "Content-Type") {
|
|
hasContentType = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasContentType {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
t.applyHeaders(req)
|
|
|
|
client := &http.Client{}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to send POST webhook: %w", err)
|
|
}
|
|
|
|
defer func() {
|
|
if cerr := resp.Body.Close(); cerr != nil {
|
|
logger.Error("failed to close response body", "error", cerr)
|
|
}
|
|
}()
|
|
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf(
|
|
"webhook POST returned status: %s, body: %s",
|
|
resp.Status,
|
|
string(respBody),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (t *WebhookNotifier) buildRequestBody(heading, message string) []byte {
|
|
if t.BodyTemplate != nil && *t.BodyTemplate != "" {
|
|
result := *t.BodyTemplate
|
|
result = strings.ReplaceAll(result, "{{heading}}", escapeJSONString(heading))
|
|
result = strings.ReplaceAll(result, "{{message}}", escapeJSONString(message))
|
|
return []byte(result)
|
|
}
|
|
|
|
payload := map[string]string{
|
|
"heading": heading,
|
|
"message": message,
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
return body
|
|
}
|
|
|
|
func (t *WebhookNotifier) applyHeaders(req *http.Request) {
|
|
for _, h := range t.Headers {
|
|
if h.Key != "" {
|
|
req.Header.Set(h.Key, h.Value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func escapeJSONString(s string) string {
|
|
b, err := json.Marshal(s)
|
|
if err != nil || len(b) < 2 {
|
|
escaped := strings.ReplaceAll(s, `\`, `\\`)
|
|
escaped = strings.ReplaceAll(escaped, `"`, `\"`)
|
|
escaped = strings.ReplaceAll(escaped, "\n", `\n`)
|
|
escaped = strings.ReplaceAll(escaped, "\r", `\r`)
|
|
escaped = strings.ReplaceAll(escaped, "\t", `\t`)
|
|
return escaped
|
|
}
|
|
|
|
return string(b[1 : len(b)-1])
|
|
}
|
|
|
|
func (t *WebhookNotifier) decryptHeadersForSending(encryptor encryption.FieldEncryptor) error {
|
|
for i := range t.Headers {
|
|
if t.Headers[i].Value != "" {
|
|
if decrypted, err := encryptor.Decrypt(t.Headers[i].Value); err == nil {
|
|
t.Headers[i].Value = decrypted
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|