Compare commits

...

10 Commits

Author SHA1 Message Date
Rostislav Dugin
a3b263bbac FIX (installation): Fix installation on Debian 2025-11-30 20:25:28 +03:00
Rostislav Dugin
a956dccf7c FIX (whitelist): Show hint about Postgresus whitelist in case of connection failure 2025-11-28 23:59:20 +03:00
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
Rostislav Dugin
cfe5993831 Merge pull request #110 from RostislavDugin/feature/pgpass_escape
Feature/pgpass escape
2025-11-27 17:03:06 +03:00
Rostislav Dugin
fa0e3d1ce2 REFACTOR (pgpass): Refactor escaping 2025-11-27 17:00:26 +03:00
Rostislav Dugin
d07085c462 Merge pull request #108 from kapawit/fix/pgpass-special-characters
FIX (postgresql): Escape special characters in .pgpass file for authentication
2025-11-27 16:54:38 +03:00
kapawit
c89c1f9654 FIX (postgresql): Escape special characters in .pgpass file for authentication 2025-11-26 21:35:38 +07:00
17 changed files with 547 additions and 131 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

@@ -719,11 +719,15 @@ func (uc *CreatePostgresqlBackupUsecase) createTempPgpassFile(
return "", nil
}
escapedHost := tools.EscapePgpassField(pgConfig.Host)
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
escapedPassword := tools.EscapePgpassField(password)
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
escapedHost,
pgConfig.Port,
pgConfig.Username,
password,
escapedUsername,
escapedPassword,
)
tempDir, err := os.MkdirTemp("", "pgpass")

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

@@ -564,11 +564,15 @@ func (uc *RestorePostgresqlBackupUsecase) createTempPgpassFile(
return "", nil
}
escapedHost := tools.EscapePgpassField(pgConfig.Host)
escapedUsername := tools.EscapePgpassField(pgConfig.Username)
escapedPassword := tools.EscapePgpassField(password)
pgpassContent := fmt.Sprintf("%s:%d:*:%s:%s",
pgConfig.Host,
escapedHost,
pgConfig.Port,
pgConfig.Username,
password,
escapedUsername,
escapedPassword,
)
tempDir, err := os.MkdirTemp("", "pgpass")

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
env_utils "postgresus-backend/internal/util/env"
)
@@ -151,6 +152,24 @@ func VerifyPostgresesInstallation(
logger.Info("All PostgreSQL version-specific client tools verification completed successfully!")
}
// EscapePgpassField escapes special characters in a field value for .pgpass file format.
// According to PostgreSQL documentation, the .pgpass file format requires:
// - Backslash (\) must be escaped as \\
// - Colon (:) must be escaped as \:
// Additionally, newlines and carriage returns are removed to prevent format corruption.
func EscapePgpassField(field string) string {
// Remove newlines and carriage returns that would break .pgpass format
field = strings.ReplaceAll(field, "\r", "")
field = strings.ReplaceAll(field, "\n", "")
// Escape backslashes first (order matters!)
// Then escape colons
field = strings.ReplaceAll(field, "\\", "\\\\")
field = strings.ReplaceAll(field, ":", "\\:")
return field
}
func getPostgresqlBasePath(
version PostgresqlVersion,
envMode env_utils.EnvMode,

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

@@ -48,10 +48,12 @@ export const EditDatabaseSpecificDataComponent = ({
const [isConnectionTested, setIsConnectionTested] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
const [isConnectionFailed, setIsConnectionFailed] = useState(false);
const testConnection = async () => {
if (!editingDatabase) return;
setIsTestingConnection(true);
setIsConnectionFailed(false);
try {
await databaseApi.testDatabaseConnectionDirect(editingDatabase);
@@ -61,6 +63,7 @@ export const EditDatabaseSpecificDataComponent = ({
description: 'You can continue with the next step',
});
} catch (e) {
setIsConnectionFailed(true);
alert((e as Error).message);
}
@@ -89,6 +92,7 @@ export const EditDatabaseSpecificDataComponent = ({
setIsSaving(false);
setIsConnectionTested(false);
setIsTestingConnection(false);
setIsConnectionFailed(false);
setEditingDatabase({ ...database });
}, [database]);
@@ -327,6 +331,13 @@ export const EditDatabaseSpecificDataComponent = ({
</Button>
)}
</div>
{isConnectionFailed && (
<div className="mt-3 text-sm text-gray-500 dark:text-gray-400">
If your database uses IP whitelist, make sure Postgresus server IP is added to the allowed
list.
</div>
)}
</div>
);
};

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-words whitespace-pre-wrap 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 break-words whitespace-pre-wrap">
{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. Compressed 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>

View File

@@ -16,10 +16,12 @@ function getSystemTheme(): ResolvedTheme {
function getStoredTheme(): ThemeMode {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (stored === 'light' || stored === 'dark' || stored === 'system') {
return stored;
}
}
return 'system';
}

View File

@@ -1,5 +1,7 @@
#!/bin/bash
set -e # Exit on any error
# Check if script is run as root
if [ "$(id -u)" -ne 0 ]; then
echo "Error: This script must be run as root (sudo ./install-postgresus.sh)" >&2
@@ -27,39 +29,88 @@ else
log "Directory already exists: $INSTALL_DIR"
fi
# Detect OS
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS=$ID
VERSION_CODENAME=${VERSION_CODENAME:-}
else
log "ERROR: Cannot detect OS. /etc/os-release not found."
exit 1
fi
}
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
log "Docker not found. Installing Docker..."
# Install Docker
detect_os
log "Detected OS: $OS, Codename: $VERSION_CODENAME"
# Install prerequisites
apt-get update
apt-get remove -y docker docker-engine docker.io containerd runc
apt-get install -y ca-certificates curl gnupg lsb-release
mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get install -y ca-certificates curl gnupg
# Set up Docker repository
install -m 0755 -d /etc/apt/keyrings
# Determine Docker repo URL based on OS
case "$OS" in
ubuntu)
DOCKER_URL="https://download.docker.com/linux/ubuntu"
# Fallback for unsupported versions
case "$VERSION_CODENAME" in
plucky|oracular) VERSION_CODENAME="noble" ;; # Ubuntu 25.x -> 24.04
esac
;;
debian)
DOCKER_URL="https://download.docker.com/linux/debian"
# Fallback for unsupported versions
case "$VERSION_CODENAME" in
trixie|forky) VERSION_CODENAME="bookworm" ;; # Debian 13/14 -> 12
esac
;;
*)
log "ERROR: Unsupported OS: $OS. Please install Docker manually."
exit 1
;;
esac
log "Using Docker repository: $DOCKER_URL with codename: $VERSION_CODENAME"
# Download and add Docker GPG key (no sudo needed - already root)
curl -fsSL "$DOCKER_URL/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
# Add Docker repository
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] $DOCKER_URL $VERSION_CODENAME stable" > /etc/apt/sources.list.d/docker.list
apt-get update
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# Verify Docker installation
if ! command -v docker &> /dev/null; then
log "ERROR: Docker installation failed!"
exit 1
fi
log "Docker installed successfully"
else
log "Docker already installed"
fi
# Check if docker-compose is installed
if ! command -v docker-compose &> /dev/null && ! command -v docker compose &> /dev/null; then
log "Docker Compose not found. Installing Docker Compose..."
apt-get update
apt-get install -y docker-compose-plugin
log "Docker Compose installed successfully"
# Check if docker compose is available
if ! docker compose version &> /dev/null; then
log "ERROR: Docker Compose plugin not available!"
exit 1
else
log "Docker Compose already installed"
log "Docker Compose available"
fi
# Write docker-compose.yml
log "Writing docker-compose.yml to $INSTALL_DIR"
cat > "$INSTALL_DIR/docker-compose.yml" << 'EOF'
version: "3"
services:
postgresus:
container_name: postgresus
@@ -75,11 +126,15 @@ log "docker-compose.yml created successfully"
# Start PostgresUS
log "Starting PostgresUS..."
cd "$INSTALL_DIR"
docker compose up -d
log "PostgresUS started successfully"
if docker compose up -d; then
log "PostgresUS started successfully"
else
log "ERROR: Failed to start PostgresUS!"
exit 1
fi
log "Postgresus installation completed successfully!"
log "-------------------------------------------"
log "To launch:"
log "> cd $INSTALL_DIR && docker compose up -d"
log "Access Postgresus at: http://localhost:4005"
log "Access Postgresus at: http://localhost:4005"