diff --git a/backend/internal/features/notifiers/models/webhook/model.go b/backend/internal/features/notifiers/models/webhook/model.go index 0bac9c0..620c951 100644 --- a/backend/internal/features/notifiers/models/webhook/model.go +++ b/backend/internal/features/notifiers/models/webhook/model.go @@ -10,20 +10,57 @@ import ( "net/http" "net/url" "postgresus-backend/internal/util/encryption" + "strings" "github.com/google/uuid" + "gorm.io/gorm" ) +type WebhookHeader struct { + Key string `json:"key"` + Value string `json:"value"` +} + 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 + } + } + + return nil +} + func (t *WebhookNotifier) Validate(encryptor encryption.FieldEncryptor) error { if t.WebhookURL == "" { return errors.New("webhook URL is required") @@ -49,66 +86,9 @@ func (t *WebhookNotifier) Send( switch t.WebhookMethod { case WebhookMethodGET: - reqURL := fmt.Sprintf("%s?heading=%s&message=%s", - webhookURL, - url.QueryEscape(heading), - url.QueryEscape(message), - ) - - resp, err := http.Get(reqURL) - 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 - + return t.sendGET(webhookURL, heading, message, logger) case WebhookMethodPOST: - payload := map[string]string{ - "heading": heading, - "message": message, - } - - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal webhook payload: %w", err) - } - - resp, err := http.Post(webhookURL, "application/json", bytes.NewReader(body)) - 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 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf( - "webhook POST returned status: %s, body: %s", - resp.Status, - string(body), - ) - } - - return nil - + return t.sendPOST(webhookURL, heading, message, logger) default: return fmt.Errorf("unsupported webhook method: %s", t.WebhookMethod) } @@ -120,15 +100,130 @@ func (t *WebhookNotifier) HideSensitiveData() { 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 { if t.WebhookURL != "" { encrypted, err := encryptor.Encrypt(t.NotifierID, t.WebhookURL) + if err != nil { return fmt.Errorf("failed to encrypt webhook URL: %w", err) } + t.WebhookURL = 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.NewRequest(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.NewRequest(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}}", heading) + result = strings.ReplaceAll(result, "{{message}}", 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) + } + } +} diff --git a/backend/migrations/20251128120000_add_webhook_headers_and_body_template.sql b/backend/migrations/20251128120000_add_webhook_headers_and_body_template.sql new file mode 100644 index 0000000..51f768f --- /dev/null +++ b/backend/migrations/20251128120000_add_webhook_headers_and_body_template.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE webhook_notifiers + ADD COLUMN body_template TEXT, + ADD COLUMN headers TEXT DEFAULT '[]'; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE webhook_notifiers + DROP COLUMN body_template, + DROP COLUMN headers; + +-- +goose StatementEnd + diff --git a/frontend/src/entity/notifiers/index.ts b/frontend/src/entity/notifiers/index.ts index ba09989..65e7798 100644 --- a/frontend/src/entity/notifiers/index.ts +++ b/frontend/src/entity/notifiers/index.ts @@ -9,6 +9,7 @@ export type { TelegramNotifier } from './models/telegram/TelegramNotifier'; export { validateTelegramNotifier } from './models/telegram/validateTelegramNotifier'; export type { WebhookNotifier } from './models/webhook/WebhookNotifier'; +export type { WebhookHeader } from './models/webhook/WebhookHeader'; export { validateWebhookNotifier } from './models/webhook/validateWebhookNotifier'; export { WebhookMethod } from './models/webhook/WebhookMethod'; diff --git a/frontend/src/entity/notifiers/models/webhook/WebhookHeader.ts b/frontend/src/entity/notifiers/models/webhook/WebhookHeader.ts new file mode 100644 index 0000000..c27cede --- /dev/null +++ b/frontend/src/entity/notifiers/models/webhook/WebhookHeader.ts @@ -0,0 +1,4 @@ +export interface WebhookHeader { + key: string; + value: string; +} diff --git a/frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts b/frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts index a326208..ec6141a 100644 --- a/frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts +++ b/frontend/src/entity/notifiers/models/webhook/WebhookNotifier.ts @@ -1,6 +1,9 @@ +import type { WebhookHeader } from './WebhookHeader'; import type { WebhookMethod } from './WebhookMethod'; export interface WebhookNotifier { webhookUrl: string; webhookMethod: WebhookMethod; + bodyTemplate?: string; + headers?: WebhookHeader[]; } diff --git a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx index 8e055b8..def124c 100644 --- a/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/edit/EditNotifierComponent.tsx @@ -119,6 +119,7 @@ export function EditNotifierComponent({ notifier.webhookNotifier = { webhookUrl: '', webhookMethod: WebhookMethod.POST, + headers: [], }; } diff --git a/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx b/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx index 3600a75..ec7bbe7 100644 --- a/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/edit/notifiers/EditWebhookNotifierComponent.tsx @@ -1,7 +1,8 @@ -import { InfoCircleOutlined } from '@ant-design/icons'; -import { Input, Select, Tooltip } from 'antd'; +import { DeleteOutlined, InfoCircleOutlined, PlusOutlined } from '@ant-design/icons'; +import { Button, Input, Select, Tooltip } from 'antd'; +import { useMemo } from 'react'; -import type { Notifier } from '../../../../../entity/notifiers'; +import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers'; import { WebhookMethod } from '../../../../../entity/notifiers/models/webhook/WebhookMethod'; interface Props { @@ -10,7 +11,64 @@ interface Props { setUnsaved: () => void; } +const DEFAULT_BODY_TEMPLATE = `{ + "heading": "{{heading}}", + "message": "{{message}}" +}`; + +function validateJsonTemplate(template: string): string | null { + if (!template.trim()) { + return null; // Empty is valid (will use default) + } + + // Replace placeholders with valid JSON strings before parsing + const testJson = template.replace(/\{\{heading\}\}/g, 'test').replace(/\{\{message\}\}/g, 'test'); + + try { + JSON.parse(testJson); + return null; + } catch (e) { + if (e instanceof SyntaxError) { + return 'Invalid JSON format'; + } + return 'Invalid JSON'; + } +} + export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved }: Props) { + const headers = notifier?.webhookNotifier?.headers || []; + const bodyTemplate = notifier?.webhookNotifier?.bodyTemplate || ''; + + const jsonError = useMemo(() => validateJsonTemplate(bodyTemplate), [bodyTemplate]); + + const updateWebhookNotifier = (updates: Partial) => { + setNotifier({ + ...notifier, + webhookNotifier: { + ...(notifier.webhookNotifier || { webhookUrl: '', webhookMethod: WebhookMethod.POST }), + ...updates, + }, + }); + setUnsaved(); + }; + + const addHeader = () => { + updateWebhookNotifier({ + headers: [...headers, { key: '', value: '' }], + }); + }; + + const updateHeader = (index: number, field: 'key' | 'value', value: string) => { + const newHeaders = [...headers]; + newHeaders[index] = { ...newHeaders[index], [field]: value }; + updateWebhookNotifier({ headers: newHeaders }); + }; + + const removeHeader = (index: number) => { + const newHeaders = headers.filter((_, i) => i !== index); + updateWebhookNotifier({ headers: newHeaders }); + }; + return ( <>
@@ -18,14 +76,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved { - setNotifier({ - ...notifier, - webhookNotifier: { - ...(notifier.webhookNotifier || { webhookMethod: WebhookMethod.POST }), - webhookUrl: e.target.value.trim(), - }, - }); - setUnsaved(); + updateWebhookNotifier({ webhookUrl: e.target.value.trim() }); }} size="small" className="w-full max-w-[250px]" @@ -39,54 +90,162 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved updateHeader(index, 'key', e.target.value)} + size="small" + style={{ width: 150, flexShrink: 0 }} + placeholder="Header name" + /> + updateHeader(index, 'value', e.target.value)} + size="small" + style={{ flex: 1, minWidth: 0 }} + placeholder="Header value" + /> +
+ ))} + + + + + + {notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && ( +
+
+ Body template +
+ +
+ + + {'{{heading}}'} + {' '} + — notification title + + + + {'{{message}}'} + {' '} + — notification message + +
+ + { + updateWebhookNotifier({ bodyTemplate: e.target.value }); + }} + className="w-full max-w-[500px] font-mono text-xs" + rows={6} + placeholder={DEFAULT_BODY_TEMPLATE} + status={jsonError ? 'error' : undefined} + /> + {jsonError &&
{jsonError}
} +
+ )} + {notifier?.webhookNotifier?.webhookUrl && ( -
-
Example request
+
+
Example request
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.GET && ( -
- GET {notifier?.webhookNotifier?.webhookUrl}?heading=✅ Backup completed for - database&message=Backup completed successfully in 2m 17s.\nCompressed backup size: - 1.7GB +
+
GET
+
+ {notifier?.webhookNotifier?.webhookUrl} + { + '?heading=✅ Backup completed for database "my-database" (workspace "Production")&message=Backup completed successfully in 1m 23s.%0ACompressed backup size: 256.00 MB' + } +
+ {headers.length > 0 && ( +
+
+ Headers: +
+ {headers + .filter((h) => h.key) + .map((h, i) => ( +
+ {h.key}: {h.value || '(empty)'} +
+ ))} +
+ )}
)} {notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && ( -
- {`POST ${notifier?.webhookNotifier?.webhookUrl} -Content-Type: application/json - -{ - "heading": "✅ Backup completed for database", - "message": "Backup completed successfully in 2m 17s.\\nCompressed backup size: 1.7GB" -} -`} +
+
+ POST {notifier?.webhookNotifier?.webhookUrl} +
+
+ {headers.find((h) => h.key.toLowerCase() === 'content-type') + ? '' + : 'Content-Type: application/json'} + {headers + .filter((h) => h.key) + .map((h) => `\n${h.key}: ${h.value}`) + .join('')} +
+
+ {notifier?.webhookNotifier?.bodyTemplate + ? notifier.webhookNotifier.bodyTemplate + .replace( + '{{heading}}', + '✅ Backup completed for database "my-database" (workspace "Production")', + ) + .replace( + '{{message}}', + 'Backup completed successfully in 1m 23s.\\nCompressed backup size: 256.00 MB', + ) + : `{ + "heading": "✅ Backup completed for database \\"my-database\\" (workspace \\"My workspace\\")", + "message": "Backup completed successfully in 1m 23s.\\nCompressed backup size: 256.00 MB" +}`} +
)}
diff --git a/frontend/src/features/notifiers/ui/show/notifier/ShowWebhookNotifierComponent.tsx b/frontend/src/features/notifiers/ui/show/notifier/ShowWebhookNotifierComponent.tsx index cfdbe0b..cced3fa 100644 --- a/frontend/src/features/notifiers/ui/show/notifier/ShowWebhookNotifierComponent.tsx +++ b/frontend/src/features/notifiers/ui/show/notifier/ShowWebhookNotifierComponent.tsx @@ -1,22 +1,50 @@ -import type { Notifier } from '../../../../../entity/notifiers'; +import type { Notifier, WebhookHeader } from '../../../../../entity/notifiers'; +import { WebhookMethod } from '../../../../../entity/notifiers'; interface Props { notifier: Notifier; } export function ShowWebhookNotifierComponent({ notifier }: Props) { + const headers = notifier?.webhookNotifier?.headers || []; + const hasHeaders = headers.filter((h: WebhookHeader) => h.key).length > 0; + return ( <>
Webhook URL
- -
{notifier?.webhookNotifier?.webhookUrl || '-'}
+
{notifier?.webhookNotifier?.webhookUrl || '-'}
Method
{notifier?.webhookNotifier?.webhookMethod || '-'}
+ + {hasHeaders && ( +
+
Headers
+
+ {headers + .filter((h: WebhookHeader) => h.key) + .map((h: WebhookHeader, i: number) => ( +
+ {h.key}: {h.value || '(empty)'} +
+ ))} +
+
+ )} + + {notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && + notifier?.webhookNotifier?.bodyTemplate && ( +
+
Body Template
+
+ {notifier.webhookNotifier.bodyTemplate} +
+
+ )} ); }