Compare commits

...

4 Commits

Author SHA1 Message Date
Rostislav Dugin
ce9fa18d58 FEATURE (webhook): Add webhook customization 2025-11-28 21:53:44 +03:00
Rostislav Dugin
281e185f21 FIX (dark): Add dark theme image 2025-11-27 23:17:43 +03:00
Rostislav Dugin
bb5b0064ea Merge branch 'main' of https://github.com/RostislavDugin/postgresus 2025-11-27 22:19:34 +03:00
Rostislav Dugin
da95bbb178 FIX (s3): Do not allow to change prefix after creation 2025-11-27 22:00:21 +03:00
11 changed files with 427 additions and 106 deletions

View File

@@ -25,6 +25,8 @@
<a href="https://postgresus.com" target="_blank"><strong>🌐 Postgresus website</strong></a>
</p>
<img src="assets/dashboard-dark.svg" alt="Postgresus Dark Dashboard" width="800" style="margin-bottom: 10px;"/>
<img src="assets/dashboard.svg" alt="Postgresus Dashboard" width="800"/>
@@ -72,6 +74,12 @@
- **Audit logs**: Track all system activities and changes made by users
- **User roles**: Assign viewer, member, admin or owner roles within workspaces
### 🎨 **UX-Friendly**
- **Designer-polished UI**: Clean, intuitive interface crafted with attention to detail
- **Dark & light themes**: Choose the look that suits your workflow
- **Mobile adaptive**: Check your backups from anywhere on any device
### 🐳 **Self-Hosted & Secure**
- **Docker-based**: Easy deployment and management

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 537 KiB

View File

@@ -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)
}
}
}

View File

@@ -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

View File

@@ -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';

View File

@@ -0,0 +1,4 @@
export interface WebhookHeader {
key: string;
value: string;
}

View File

@@ -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[];
}

View File

@@ -119,6 +119,7 @@ export function EditNotifierComponent({
notifier.webhookNotifier = {
webhookUrl: '',
webhookMethod: WebhookMethod.POST,
headers: [],
};
}

View File

@@ -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<typeof notifier.webhookNotifier>) => {
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 (
<>
<div className="mb-1 flex w-full flex-col items-start sm:flex-row sm:items-center">
@@ -18,14 +76,7 @@ export function EditWebhookNotifierComponent({ notifier, setNotifier, setUnsaved
<Input
value={notifier?.webhookNotifier?.webhookUrl || ''}
onChange={(e) => {
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
<Select
value={notifier?.webhookNotifier?.webhookMethod || WebhookMethod.POST}
onChange={(value) => {
setNotifier({
...notifier,
webhookNotifier: {
...(notifier.webhookNotifier || { webhookUrl: '' }),
webhookMethod: value,
},
});
setUnsaved();
updateWebhookNotifier({ webhookMethod: value });
}}
size="small"
className="w-full max-w-[250px]"
className="w-[100px] max-w-[250px]"
options={[
{ value: WebhookMethod.POST, label: 'POST' },
{ value: WebhookMethod.GET, label: 'GET' },
]}
/>
<Tooltip
className="cursor-pointer"
title="The HTTP method that will be used to call the webhook"
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>
<div className="mt-3 mb-1 flex w-full flex-col items-start">
<div className="mb-1 flex items-center">
<span className="min-w-[150px]">
Custom headers{' '}
<Tooltip title="Add custom HTTP headers to the webhook request (e.g., Authorization, X-API-Key)">
<InfoCircleOutlined className="ml-1" style={{ color: 'gray' }} />
</Tooltip>
</span>
</div>
<div className="w-full max-w-[500px]">
{headers.map((header: WebhookHeader, index: number) => (
<div key={index} className="mb-1 flex items-center gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(index, 'key', e.target.value)}
size="small"
style={{ width: 150, flexShrink: 0 }}
placeholder="Header name"
/>
<Input
value={header.value}
onChange={(e) => updateHeader(index, 'value', e.target.value)}
size="small"
style={{ flex: 1, minWidth: 0 }}
placeholder="Header value"
/>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeHeader(index)}
/>
</div>
))}
<Button
type="dashed"
size="small"
icon={<PlusOutlined />}
onClick={addHeader}
className="mt-1"
>
Add header
</Button>
</div>
</div>
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
<div className="mt-3 mb-1 flex w-full flex-col items-start">
<div className="mb-1 flex items-center">
<span className="min-w-[150px]">Body template </span>
</div>
<div className="mb-2 text-xs text-gray-500 dark:text-gray-400">
<span className="mr-4">
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
{'{{heading}}'}
</code>{' '}
notification title
</span>
<span>
<code className="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-700">
{'{{message}}'}
</code>{' '}
notification message
</span>
</div>
<Input.TextArea
value={bodyTemplate}
onChange={(e) => {
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 && <div className="mt-1 text-xs text-red-500">{jsonError}</div>}
</div>
)}
{notifier?.webhookNotifier?.webhookUrl && (
<div className="mt-3">
<div className="mb-1">Example request</div>
<div className="mt-4">
<div className="mb-1 font-medium">Example request</div>
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.GET && (
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all">
GET {notifier?.webhookNotifier?.webhookUrl}?heading= Backup completed for
database&message=Backup completed successfully in 2m 17s.\nCompressed backup size:
1.7GB
<div className="rounded bg-gray-100 p-2 px-3 text-sm break-all dark:bg-gray-800">
<div className="font-semibold text-blue-600 dark:text-blue-400">GET</div>
<div className="mt-1">
{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'
}
</div>
{headers.length > 0 && (
<div className="mt-2 border-t border-gray-200 pt-2 dark:border-gray-600">
<div className="text-xs font-semibold text-gray-500 dark:text-gray-400">
Headers:
</div>
{headers
.filter((h) => h.key)
.map((h, i) => (
<div key={i} className="text-xs">
{h.key}: {h.value || '(empty)'}
</div>
))}
</div>
)}
</div>
)}
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST && (
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-all whitespace-pre-line">
{`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"
}
`}
<div className="rounded bg-gray-100 p-2 px-3 font-mono text-sm break-all whitespace-pre-line dark:bg-gray-800">
<div className="font-semibold text-blue-600 dark:text-blue-400">
POST {notifier?.webhookNotifier?.webhookUrl}
</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
{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('')}
</div>
<div className="mt-2 whitespace-pre">
{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"
}`}
</div>
</div>
)}
</div>

View File

@@ -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 (
<>
<div className="flex items-center">
<div className="min-w-[110px]">Webhook URL</div>
<div className="w-[250px]">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
<div className="max-w-[350px] truncate">{notifier?.webhookNotifier?.webhookUrl || '-'}</div>
</div>
<div className="mt-1 mb-1 flex items-center">
<div className="min-w-[110px]">Method</div>
<div>{notifier?.webhookNotifier?.webhookMethod || '-'}</div>
</div>
{hasHeaders && (
<div className="mt-1 mb-1 flex items-start">
<div className="min-w-[110px]">Headers</div>
<div className="flex flex-col text-sm">
{headers
.filter((h: WebhookHeader) => h.key)
.map((h: WebhookHeader, i: number) => (
<div key={i} className="text-gray-600">
<span className="font-medium">{h.key}:</span> {h.value || '(empty)'}
</div>
))}
</div>
</div>
)}
{notifier?.webhookNotifier?.webhookMethod === WebhookMethod.POST &&
notifier?.webhookNotifier?.bodyTemplate && (
<div className="mt-1 mb-1 flex items-start">
<div className="min-w-[110px]">Body Template</div>
<div className="max-w-[350px] rounded bg-gray-50 p-2 font-mono text-xs whitespace-pre-wrap">
{notifier.webhookNotifier.bodyTemplate}
</div>
</div>
)}
</>
);
}

View File

@@ -183,13 +183,16 @@ export function EditS3StorageComponent({ storage, setStorage, setUnsaved }: Prop
size="small"
className="w-full max-w-[250px]"
placeholder="my-prefix/ (optional)"
// we do not allow to change the prefix after creation,
// otherwise we will have to migrate all the data to the new prefix
disabled={!!storage.id}
/>
<Tooltip
className="cursor-pointer"
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages."
title="Optional prefix for all object keys (e.g., 'backups/' or 'my_team/'). May not work with some S3-compatible storages. Cannot be changed after creation (otherwise backups will be lost)."
>
<InfoCircleOutlined className="ml-2" style={{ color: 'gray' }} />
<InfoCircleOutlined className="ml-4" style={{ color: 'gray' }} />
</Tooltip>
</div>
</div>