From 5c1d2f104d9209d7c077d8468c5b2f9d3c9519bc Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Wed, 3 Jun 2026 15:01:48 +0300 Subject: [PATCH 01/20] feat: add multi-provider notification system with Telegram support --- internal/cli/notify/command.go | 64 ++++++++ internal/cli/notify/flags.go | 13 ++ internal/cli/notify/options.go | 82 ++++++++++ internal/cli/notify/telegram/command.go | 93 +++++++++++ internal/cli/notify/telegram/flags.go | 15 ++ internal/cli/notify/telegram/options.go | 65 ++++++++ internal/cli/root.go | 2 + internal/core/hook_core.go | 32 ++++ internal/domain/hook.go | 10 ++ internal/notify/provider.go | 53 ++++++ internal/notify/telegram.go | 86 ++++++++++ internal/repos/hook.go | 74 +++++++++ internal/server/contract.go | 5 + internal/server/handler.go | 153 ++++++++++++++++++ .../20260529000000_add_hook_notifications.sql | 14 ++ .../20260529000001_refactor_notifications.sql | 17 ++ internal/store/query/hooks.sql | 24 +++ internal/store/sqlc/hooks.sql.go | 110 +++++++++++++ internal/store/sqlc/models.go | 10 ++ .../endpoint-session/api/endpoint-api.ts | 23 +++ .../widgets/request-detail/request-detail.ts | 74 ++++++++- 21 files changed, 1018 insertions(+), 1 deletion(-) create mode 100644 internal/cli/notify/command.go create mode 100644 internal/cli/notify/flags.go create mode 100644 internal/cli/notify/options.go create mode 100644 internal/cli/notify/telegram/command.go create mode 100644 internal/cli/notify/telegram/flags.go create mode 100644 internal/cli/notify/telegram/options.go create mode 100644 internal/notify/provider.go create mode 100644 internal/notify/telegram.go create mode 100644 internal/store/migrations/20260529000000_add_hook_notifications.sql create mode 100644 internal/store/migrations/20260529000001_refactor_notifications.sql diff --git a/internal/cli/notify/command.go b/internal/cli/notify/command.go new file mode 100644 index 0000000..0dcc079 --- /dev/null +++ b/internal/cli/notify/command.go @@ -0,0 +1,64 @@ +package notify + +import ( + "context" + "net/url" + + "github.com/GaIsBAX/Webhix/internal/cli/notify/telegram" + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/spf13/cobra" +) + +type notificationChannel struct { + Provider string `json:"provider"` + Config map[string]string `json:"config"` +} + +func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { + opts := DefaultOptions() + if cfg.SecretKey != "" { + opts.AuthToken = cfg.SecretKey + } + + cmd := &cobra.Command{ + Use: "notify", + Short: "Manage endpoint notifications", + } + + RegisterFlags(cmd, &opts) + + cmd.AddCommand(newListCmd(ctx, &opts)) + cmd.AddCommand(telegram.NewCommand(ctx, &opts.Server, &opts.AuthToken)) + + return cmd +} + +func newListCmd(ctx context.Context, opts *Options) *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List all configured notification channels", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var channels []notificationChannel + if err := apiGet(ctx, opts, "/api/endpoints/"+url.PathEscape(args[0])+"/notifications", &channels); err != nil { + return err + } + + if len(channels) == 0 { + cmd.Println("No notifications configured.") + return nil + } + + for _, ch := range channels { + cmd.Printf("Provider: %s\n", ch.Provider) + for k, v := range ch.Config { + if k == "bot_token" { + v = maskToken(v) + } + cmd.Printf(" %s: %s\n", k, v) + } + } + return nil + }, + } +} diff --git a/internal/cli/notify/flags.go b/internal/cli/notify/flags.go new file mode 100644 index 0000000..05f9f5e --- /dev/null +++ b/internal/cli/notify/flags.go @@ -0,0 +1,13 @@ +package notify + +import "github.com/spf13/cobra" + +const ( + flagServer = "server" + flagAuthToken = "auth-token" +) + +func RegisterFlags(cmd *cobra.Command, opt *Options) { + cmd.PersistentFlags().StringVar(&opt.Server, flagServer, opt.Server, "Webhix server URL") + cmd.PersistentFlags().StringVar(&opt.AuthToken, flagAuthToken, opt.AuthToken, "auth token (env: WEBHIX_SECRET_KEY)") +} diff --git a/internal/cli/notify/options.go b/internal/cli/notify/options.go new file mode 100644 index 0000000..8937699 --- /dev/null +++ b/internal/cli/notify/options.go @@ -0,0 +1,82 @@ +package notify + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +type Options struct { + Server string + AuthToken string +} + +type apiResponse struct { + Success bool `json:"success"` + Body json.RawMessage `json:"body"` + Error *apiError `json:"error"` +} + +type apiError struct { + Message string `json:"message"` +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func DefaultOptions() Options { + return Options{ + Server: "http://localhost:8080", + } +} + +func (o *Options) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, o.Server+path, body) + if err != nil { + return nil, err + } + if o.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+o.AuthToken) + } + return req, nil +} + +func apiGet(ctx context.Context, opts *Options, path string, out any) error { + req, err := opts.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + var ar apiResponse + if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { + return err + } + if !ar.Success { + if ar.Error != nil { + return errors.New(ar.Error.Message) + } + return fmt.Errorf("server returned %d", resp.StatusCode) + } + return json.Unmarshal(ar.Body, out) +} + +func maskToken(token string) string { + if len(token) <= 14 { + return "***" + } + return token[:4] + "..." + token[len(token)-4:] +} diff --git a/internal/cli/notify/telegram/command.go b/internal/cli/notify/telegram/command.go new file mode 100644 index 0000000..0839049 --- /dev/null +++ b/internal/cli/notify/telegram/command.go @@ -0,0 +1,93 @@ +package telegram + +import ( + "context" + "net/http" + "net/url" + + "github.com/spf13/cobra" +) + +func NewCommand(ctx context.Context, server, authToken *string) *cobra.Command { + cmd := &cobra.Command{ + Use: "telegram", + Short: "Manage Telegram notifications", + } + + cmd.AddCommand(newSetCmd(ctx, server, authToken)) + cmd.AddCommand(newTestCmd(ctx, server, authToken)) + cmd.AddCommand(newRemoveCmd(ctx, server, authToken)) + + return cmd +} + +func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command { + opts := Options{} + + cmd := &cobra.Command{ + Use: "set ", + Short: "Configure Telegram notifications for an endpoint", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := map[string]string{"bot_token": opts.BotToken, "chat_id": opts.ChatID} + if opts.ProxyURL != "" { + cfg["proxy_url"] = opts.ProxyURL + } + + body := map[string]any{"provider": "telegram", "config": cfg} + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := do(ctx, http.MethodPut, path, *authToken, body); err != nil { + return err + } + + cmd.Println("Telegram notifications configured.") + return nil + }, + } + + RegisterFlags(cmd, &opts) + must(cmd.MarkFlagRequired(flagBotToken)) + must(cmd.MarkFlagRequired(flagChatID)) + + return cmd +} + +func newTestCmd(ctx context.Context, server, authToken *string) *cobra.Command { + return &cobra.Command{ + Use: "test ", + Short: "Send a test Telegram message", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test" + if err := do(ctx, http.MethodPost, path, *authToken, nil); err != nil { + return err + } + + cmd.Println("Test message sent.") + return nil + }, + } +} + +func newRemoveCmd(ctx context.Context, server, authToken *string) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove Telegram notifications from an endpoint", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := do(ctx, http.MethodDelete, path, *authToken, nil); err != nil { + return err + } + + cmd.Println("Telegram notifications removed.") + return nil + }, + } +} + +func must(err error) { + if err != nil { + panic(err) + } +} diff --git a/internal/cli/notify/telegram/flags.go b/internal/cli/notify/telegram/flags.go new file mode 100644 index 0000000..5d50721 --- /dev/null +++ b/internal/cli/notify/telegram/flags.go @@ -0,0 +1,15 @@ +package telegram + +import "github.com/spf13/cobra" + +const ( + flagBotToken = "bot-token" + flagChatID = "chat" + flagProxyURL = "proxy" +) + +func RegisterFlags(cmd *cobra.Command, opt *Options) { + cmd.Flags().StringVar(&opt.BotToken, flagBotToken, opt.BotToken, "Telegram bot token") + cmd.Flags().StringVar(&opt.ChatID, flagChatID, opt.ChatID, "Telegram chat ID") + cmd.Flags().StringVar(&opt.ProxyURL, flagProxyURL, opt.ProxyURL, "Proxy URL (e.g. socks5://127.0.0.1:1080)") +} diff --git a/internal/cli/notify/telegram/options.go b/internal/cli/notify/telegram/options.go new file mode 100644 index 0000000..87d292c --- /dev/null +++ b/internal/cli/notify/telegram/options.go @@ -0,0 +1,65 @@ +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +type Options struct { + BotToken string + ChatID string + ProxyURL string +} + +var httpClient = &http.Client{Timeout: 30 * time.Second} + +func do(ctx context.Context, method, url, authToken string, body any) error { + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + r = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, url, r) + if err != nil { + return err + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + var ar struct { + Error *struct{ Message string `json:"message"` } `json:"error"` + } + if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil { + return errors.New(ar.Error.Message) + } + return fmt.Errorf("server returned %d", resp.StatusCode) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6280d06..75dcba8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -4,6 +4,7 @@ import ( "context" "github.com/GaIsBAX/Webhix/internal/cli/forward" + "github.com/GaIsBAX/Webhix/internal/cli/notify" "github.com/GaIsBAX/Webhix/internal/cli/serve" "github.com/GaIsBAX/Webhix/internal/cli/tunnel" "github.com/GaIsBAX/Webhix/internal/cli/version" @@ -29,6 +30,7 @@ func NewRootCommand( cmd.AddCommand(serve.NewCommand(ctx, cfg, serveFactory)) cmd.AddCommand(forward.NewCommand(ctx, cfg)) + cmd.AddCommand(notify.NewCommand(ctx, cfg)) cmd.AddCommand(tunnel.NewCommand(ctx, cfg)) cmd.AddCommand(version.NewCommand(ctx)) diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 0488a69..8d239db 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -19,6 +19,10 @@ type HookRepository interface { ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) + ListNotificationChannels(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) + GetNotificationChannel(ctx context.Context, hookID int64, provider string) (domain.NotificationChannel, error) + UpsertNotificationChannel(ctx context.Context, hookID int64, provider string, config map[string]string) (domain.NotificationChannel, error) + DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error } type Hook struct { @@ -108,6 +112,34 @@ func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain. return s.repo.UpsertHookResponse(ctx, hook.ID, params) } +func (s *Hook) ListChannels(ctx context.Context, token string) ([]domain.NotificationChannel, error) { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return nil, err + } + return s.repo.ListNotificationChannels(ctx, hook.ID) +} + +func (s *Hook) UpsertChannel(ctx context.Context, token, provider string, config map[string]string) (domain.NotificationChannel, error) { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return domain.NotificationChannel{}, err + } + return s.repo.UpsertNotificationChannel(ctx, hook.ID, provider, config) +} + +func (s *Hook) DeleteChannel(ctx context.Context, token, provider string) error { + hook, err := s.repo.GetHookByToken(ctx, token) + if err != nil { + return err + } + return s.repo.DeleteNotificationChannel(ctx, hook.ID, provider) +} + +func (s *Hook) GetChannelsForHookID(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) { + return s.repo.ListNotificationChannels(ctx, hookID) +} + func defaultHookResponse() domain.HookResponse { return domain.HookResponse{ StatusCode: defaultHookResponseStatusCode, diff --git a/internal/domain/hook.go b/internal/domain/hook.go index 98911e2..c90c672 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -61,3 +61,13 @@ func (p UpsertHookResponseParams) Validate() error { } return nil } + +type NotificationChannel struct { + ID int64 + HookID int64 + Provider string + Config map[string]string + Enabled bool + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/notify/provider.go b/internal/notify/provider.go new file mode 100644 index 0000000..bfc72a4 --- /dev/null +++ b/internal/notify/provider.go @@ -0,0 +1,53 @@ +package notify + +import ( + "context" + "fmt" + "sort" + "sync" +) + +type Config map[string]string + +type Provider interface { + Send(ctx context.Context, config Config, message string) error +} + +type ProviderFunc func(ctx context.Context, config Config, message string) error + +func (f ProviderFunc) Send(ctx context.Context, config Config, message string) error { + return f(ctx, config, message) +} + +var ( + registryMu sync.RWMutex + registry = map[string]Provider{ + "telegram": ProviderFunc(telegramSend), + } +) + +func Send(ctx context.Context, provider string, config Config, message string) error { + registryMu.RLock() + + p, ok := registry[provider] + registryMu.RUnlock() + + if !ok { + return fmt.Errorf("unknown provider: %s", provider) + } + + return p.Send(ctx, config, message) +} + +func KnownProviders() []string { + registryMu.RLock() + + keys := make([]string, 0, len(registry)) + for k := range registry { + keys = append(keys, k) + } + + registryMu.RUnlock() + sort.Strings(keys) + return keys +} diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go new file mode 100644 index 0000000..6167e27 --- /dev/null +++ b/internal/notify/telegram.go @@ -0,0 +1,86 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" +) + +// telegramSend implements Provider for Telegram via ProviderFunc. +func telegramSend(ctx context.Context, config Config, message string) error { + return sendMessage(ctx, config["bot_token"], config["chat_id"], message, config["proxy_url"]) +} + +func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) error { + if botToken == "" || chatID == "" { + return fmt.Errorf("telegram: bot_token and chat_id are required") + } + + client := &http.Client{Timeout: 10 * time.Second} + if proxyURL != "" { + proxy, err := validateProxy(proxyURL) + if err != nil { + return err + } + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxy)} + } + + // text already contains HTML from the caller (handler.go escapes user data before calling Send) + payload, err := json.Marshal(map[string]string{ + "chat_id": chatID, + "text": text, + "parse_mode": "HTML", + }) + if err != nil { + return err + } + + apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + if resp.StatusCode == http.StatusOK { + return nil + } + + var tgErr struct { + Description string `json:"description"` + } + if err := json.NewDecoder(resp.Body).Decode(&tgErr); err == nil && tgErr.Description != "" { + return fmt.Errorf("telegram: %s", tgErr.Description) + } + return fmt.Errorf("telegram API returned %d", resp.StatusCode) +} + +// validateProxy parses and validates the proxy URL scheme. +// Returns the parsed URL so the caller doesn't parse it twice. +func validateProxy(rawURL string) (*url.URL, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, fmt.Errorf("invalid proxy URL: %w", err) + } + switch u.Scheme { + case "http", "https", "socks5": + return u, nil + default: + return nil, fmt.Errorf("proxy scheme %q not allowed: use http, https, or socks5", u.Scheme) + } +} diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 99d39d0..3713339 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "log/slog" "github.com/GaIsBAX/Webhix/internal/domain" "github.com/GaIsBAX/Webhix/internal/store/sqlc" @@ -123,6 +124,79 @@ func (r *Hook) UpsertHookResponse(ctx context.Context, hookID int64, params doma return toDomainHookResponse(row), nil } +func (r *Hook) ListNotificationChannels(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) { + rows, err := r.q.ListNotificationChannels(ctx, hookID) + if err != nil { + return nil, err + } + + result := make([]domain.NotificationChannel, len(rows)) + for i, row := range rows { + result[i] = toDomainChannel(row) + } + + return result, nil +} + +func (r *Hook) GetNotificationChannel(ctx context.Context, hookID int64, provider string) (domain.NotificationChannel, error) { + row, err := r.q.GetNotificationChannel(ctx, sqlc.GetNotificationChannelParams{ + HookID: hookID, + Provider: provider, + }) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return domain.NotificationChannel{}, domain.ErrNotFound + } + return domain.NotificationChannel{}, err + } + + return toDomainChannel(row), nil +} + +func (r *Hook) UpsertNotificationChannel(ctx context.Context, hookID int64, provider string, config map[string]string) (domain.NotificationChannel, error) { + configJSON, err := json.Marshal(config) + if err != nil { + return domain.NotificationChannel{}, err + } + + row, err := r.q.UpsertNotificationChannel(ctx, sqlc.UpsertNotificationChannelParams{ + HookID: hookID, + Provider: provider, + Config: string(configJSON), + }) + + if err != nil { + return domain.NotificationChannel{}, err + } + + return toDomainChannel(row), nil +} + +func (r *Hook) DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error { + return r.q.DeleteNotificationChannel(ctx, sqlc.DeleteNotificationChannelParams{ + HookID: hookID, + Provider: provider, + }) +} + +func toDomainChannel(row sqlc.HookNotificationChannel) domain.NotificationChannel { + cfg := map[string]string{} + if err := json.Unmarshal([]byte(row.Config), &cfg); err != nil { + slog.Warn("parse notification channel config", "err", err) + } + + return domain.NotificationChannel{ + ID: row.ID, + HookID: row.HookID, + Provider: row.Provider, + Config: cfg, + Enabled: row.Enabled != 0, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } +} + func toDomainHookResponse(row sqlc.HookResponse) domain.HookResponse { headers := map[string]string{} if err := json.Unmarshal([]byte(row.Headers), &headers); err != nil { diff --git a/internal/server/contract.go b/internal/server/contract.go index 11f5f73..599b3b6 100644 --- a/internal/server/contract.go +++ b/internal/server/contract.go @@ -47,6 +47,11 @@ type HookResponseContract struct { Body string `json:"body"` } +type NotificationContract struct { + Provider string `json:"provider"` + Config map[string]string `json:"config"` +} + type SetHookResponseRequestContract struct { StatusCode int `json:"statusCode"` Headers map[string]string `json:"headers"` diff --git a/internal/server/handler.go b/internal/server/handler.go index 1364e48..621874f 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -5,11 +5,13 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "log/slog" "net/http" "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/notify" ) const DefaultMaxBodySize int64 = 5 << 20 // 5MB @@ -21,6 +23,10 @@ type HookService interface { ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) + ListChannels(ctx context.Context, token string) ([]domain.NotificationChannel, error) + UpsertChannel(ctx context.Context, token, provider string, config map[string]string) (domain.NotificationChannel, error) + DeleteChannel(ctx context.Context, token, provider string) error + GetChannelsForHookID(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) } type EventBroker interface { @@ -61,6 +67,10 @@ func (h *Hook) RegisterRoutes() { h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/response", h.GetResponse) h.deps.Mux.HandleFunc("PUT /api/endpoints/{token}/response", h.SetResponse) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/notifications", h.GetNotification) + h.deps.Mux.HandleFunc("PUT /api/endpoints/{token}/notifications/{provider}", h.SetNotification) + h.deps.Mux.HandleFunc("DELETE /api/endpoints/{token}/notifications/{provider}", h.DeleteNotification) + h.deps.Mux.HandleFunc("POST /api/endpoints/{token}/notifications/{provider}/test", h.TestNotification) h.deps.Mux.HandleFunc("/r/{token}", h.ReceiveWebhook) } @@ -188,6 +198,7 @@ func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { } h.deps.Hub.Publish(token, data) + go h.sendNotifications(req, token, context.WithoutCancel(r.Context())) if customResp.StatusCode > 0 { for k, v := range customResp.Headers { @@ -340,6 +351,148 @@ func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } +func (h *Hook) GetNotification(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + + channels, err := h.deps.Service.ListChannels(r.Context(), token) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("list notification channels", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + contracts := make([]NotificationContract, len(channels)) + for i, ch := range channels { + contracts[i] = NotificationContract{Provider: ch.Provider, Config: ch.Config} + } + + data, err := json.Marshal(contracts) + if err != nil { + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, data) +} + +func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + + token := r.PathValue("token") + provider := r.PathValue("provider") + + contract, err := DecodeRequest[NotificationContract](r) + if err != nil { + SendError(w, http.StatusBadRequest, ErrBadRequest) + return + } + + ch, err := h.deps.Service.UpsertChannel(r.Context(), token, provider, contract.Config) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("upsert notification channel", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + data, err := json.Marshal(NotificationContract{Provider: ch.Provider, Config: ch.Config}) + if err != nil { + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, data) +} + +func (h *Hook) DeleteNotification(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + + token := r.PathValue("token") + provider := r.PathValue("provider") + + if err := h.deps.Service.DeleteChannel(r.Context(), token, provider); err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("delete notification channel", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, []byte(`{}`)) +} + +func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + provider := r.PathValue("provider") + + channels, err := h.deps.Service.ListChannels(r.Context(), token) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + SendError(w, http.StatusNotFound, ErrNotFound) + return + } + slog.Error("list channels for test", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + for _, ch := range channels { + if ch.Provider != provider { + continue + } + msg := fmt.Sprintf("✅ Webhix test notification for endpoint /r/%s", html.EscapeString(token)) + if err := notify.Send(r.Context(), ch.Provider, notify.Config(ch.Config), msg); err != nil { + slog.Error("test notification", "provider", provider, "err", err) + SendError(w, http.StatusBadGateway, WithDetails(ErrInternal, ErrorDetailContract{ + Field: provider, + Message: err.Error(), + })) + return + } + SendSuccess(w, http.StatusOK, []byte(`{"sent":true}`)) + return + } + + SendError(w, http.StatusNotFound, WithDetails(ErrNotFound, ErrorDetailContract{ + Field: "provider", + Message: provider + " is not configured for this endpoint", + })) +} + +func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx context.Context) { + channels, err := h.deps.Service.GetChannelsForHookID(ctx, req.HookID) + if err != nil || len(channels) == 0 { + return + } + + msg := fmt.Sprintf( + "📨 New webhook\nEndpoint: /r/%s\nMethod: %s\nPath: %s", + html.EscapeString(token), html.EscapeString(req.Method), html.EscapeString(req.Path), + ) + + for _, ch := range channels { + if !ch.Enabled { + continue + } + if err := notify.Send(ctx, ch.Provider, notify.Config(ch.Config), msg); err != nil { + slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) + } + } +} + func (h *Hook) readOnly(w http.ResponseWriter) bool { if !h.deps.Opts.ReadOnly { return false diff --git a/internal/store/migrations/20260529000000_add_hook_notifications.sql b/internal/store/migrations/20260529000000_add_hook_notifications.sql new file mode 100644 index 0000000..20f7252 --- /dev/null +++ b/internal/store/migrations/20260529000000_add_hook_notifications.sql @@ -0,0 +1,14 @@ +-- +goose Up +CREATE TABLE hook_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL UNIQUE, + telegram_bot_token TEXT NOT NULL DEFAULT '', + telegram_chat_id TEXT NOT NULL DEFAULT '', + proxy_url TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE IF EXISTS hook_notifications; diff --git a/internal/store/migrations/20260529000001_refactor_notifications.sql b/internal/store/migrations/20260529000001_refactor_notifications.sql new file mode 100644 index 0000000..3c106fc --- /dev/null +++ b/internal/store/migrations/20260529000001_refactor_notifications.sql @@ -0,0 +1,17 @@ +-- +goose Up +DROP TABLE IF EXISTS hook_notifications; + +CREATE TABLE hook_notification_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + hook_id INTEGER NOT NULL, + provider TEXT NOT NULL, + config TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE, + UNIQUE(hook_id, provider) +); + +-- +goose Down +DROP TABLE IF EXISTS hook_notification_channels; diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index 49ea469..5c94f97 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -75,3 +75,27 @@ ORDER BY created_at DESC; SELECT id, hook_id, status_code, headers, body, created_at, updated_at FROM hook_responses WHERE hook_id = ?; + +-- name: ListNotificationChannels :many +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? +ORDER BY provider; + +-- name: GetNotificationChannel :one +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? AND provider = ?; + +-- name: UpsertNotificationChannel :one +INSERT INTO hook_notification_channels (hook_id, provider, config) +VALUES (?, ?, ?) +ON CONFLICT (hook_id, provider) DO UPDATE SET + config = excluded.config, + enabled = 1, + updated_at = CURRENT_TIMESTAMP +RETURNING id, hook_id, provider, config, enabled, created_at, updated_at; + +-- name: DeleteNotificationChannel :exec +DELETE FROM hook_notification_channels +WHERE hook_id = ? AND provider = ?; diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 8a792b6..5badfdf 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -92,6 +92,21 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq return i, err } +const deleteNotificationChannel = `-- name: DeleteNotificationChannel :exec +DELETE FROM hook_notification_channels +WHERE hook_id = ? AND provider = ? +` + +type DeleteNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` +} + +func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) error { + _, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.HookID, arg.Provider) + return err +} + const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult DELETE FROM webhook_requests WHERE received_at < datetime('now', ?) @@ -153,6 +168,32 @@ func (q *Queries) GetHookResponseByHookID(ctx context.Context, hookID int64) (Ho return i, err } +const getNotificationChannel = `-- name: GetNotificationChannel :one +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? AND provider = ? +` + +type GetNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` +} + +func (q *Queries) GetNotificationChannel(ctx context.Context, arg GetNotificationChannelParams) (HookNotificationChannel, error) { + row := q.db.QueryRowContext(ctx, getNotificationChannel, arg.HookID, arg.Provider) + var i HookNotificationChannel + err := row.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const listHooks = `-- name: ListHooks :many SELECT id, token, name, created_at, updated_at FROM hooks @@ -187,6 +228,44 @@ func (q *Queries) ListHooks(ctx context.Context) ([]Hook, error) { return items, nil } +const listNotificationChannels = `-- name: ListNotificationChannels :many +SELECT id, hook_id, provider, config, enabled, created_at, updated_at +FROM hook_notification_channels +WHERE hook_id = ? +ORDER BY provider +` + +func (q *Queries) ListNotificationChannels(ctx context.Context, hookID int64) ([]HookNotificationChannel, error) { + rows, err := q.db.QueryContext(ctx, listNotificationChannels, hookID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []HookNotificationChannel + for rows.Next() { + var i HookNotificationChannel + if err := rows.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listWebhookRequestsByHookID = `-- name: ListWebhookRequestsByHookID :many SELECT id, hook_id, method, path, query, headers, body, remote_addr, content_type, body_size, received_at FROM webhook_requests @@ -337,3 +416,34 @@ func (q *Queries) UpsertHookResponse(ctx context.Context, arg UpsertHookResponse ) return i, err } + +const upsertNotificationChannel = `-- name: UpsertNotificationChannel :one +INSERT INTO hook_notification_channels (hook_id, provider, config) +VALUES (?, ?, ?) +ON CONFLICT (hook_id, provider) DO UPDATE SET + config = excluded.config, + enabled = 1, + updated_at = CURRENT_TIMESTAMP +RETURNING id, hook_id, provider, config, enabled, created_at, updated_at +` + +type UpsertNotificationChannelParams struct { + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` + Config string `json:"config"` +} + +func (q *Queries) UpsertNotificationChannel(ctx context.Context, arg UpsertNotificationChannelParams) (HookNotificationChannel, error) { + row := q.db.QueryRowContext(ctx, upsertNotificationChannel, arg.HookID, arg.Provider, arg.Config) + var i HookNotificationChannel + err := row.Scan( + &i.ID, + &i.HookID, + &i.Provider, + &i.Config, + &i.Enabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/store/sqlc/models.go b/internal/store/sqlc/models.go index 0c8fe84..9d98d7c 100644 --- a/internal/store/sqlc/models.go +++ b/internal/store/sqlc/models.go @@ -17,6 +17,16 @@ type Hook struct { UpdatedAt time.Time `json:"updated_at"` } +type HookNotificationChannel struct { + ID int64 `json:"id"` + HookID int64 `json:"hook_id"` + Provider string `json:"provider"` + Config string `json:"config"` + Enabled int64 `json:"enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type HookResponse struct { ID int64 `json:"id"` HookID int64 `json:"hook_id"` diff --git a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts index 0fd6ab2..c770117 100644 --- a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts +++ b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts @@ -57,6 +57,29 @@ export async function saveHookResponse(token: string, data: HookResponse): Promi if (!json.success) throw new Error(json.error?.message || 'Failed to save'); } +export interface Notification { + telegramBotToken: string; + telegramChatId: string; + proxyUrl: string; +} + +export async function fetchNotification(token: string): Promise { + const response = await fetch(`/api/endpoints/${token}/notifications`); + const json = (await response.json()) as ApiResponse; + if (!json.success || !json.body) return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; + return json.body; +} + +export async function saveNotification(token: string, data: Notification): Promise { + const response = await fetch(`/api/endpoints/${token}/notifications`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + const json = (await response.json()) as ApiResponse; + if (!json.success) throw new Error(json.error?.message || 'Failed to save'); +} + export function connectEvents( token: string, handlers: { diff --git a/internal/web/ui/src/widgets/request-detail/request-detail.ts b/internal/web/ui/src/widgets/request-detail/request-detail.ts index 88bb1a2..5f94480 100644 --- a/internal/web/ui/src/widgets/request-detail/request-detail.ts +++ b/internal/web/ui/src/widgets/request-detail/request-detail.ts @@ -12,6 +12,8 @@ import { import { fetchHookResponse, saveHookResponse, + fetchNotification, + saveNotification, } from '../../features/endpoint-session/api/endpoint-api'; export function renderSelectedDetail(elements: Elements, state: AppState): void { @@ -237,7 +239,51 @@ function createSettingsForm(token: string | null): HTMLDivElement { saveBtn.className = 'settings-save-btn'; saveBtn.textContent = 'Save'; - wrap.append(statusLabel, statusInput, headersLabel, headersInput, bodyLabel, bodyInput, saveBtn); + // Telegram notifications section + const divider = document.createElement('hr'); + divider.className = 'settings-divider'; + + const tgTitle = document.createElement('h4'); + tgTitle.className = 'settings-section-title'; + tgTitle.textContent = 'Telegram Notifications'; + + const tgTokenLabel = document.createElement('label'); + tgTokenLabel.textContent = 'Bot Token'; + const tgTokenInput = document.createElement('input'); + tgTokenInput.type = 'text'; + tgTokenInput.className = 'settings-input'; + tgTokenInput.placeholder = '123456:ABC-DEF...'; + + const tgChatLabel = document.createElement('label'); + tgChatLabel.textContent = 'Chat ID'; + const tgChatInput = document.createElement('input'); + tgChatInput.type = 'text'; + tgChatInput.className = 'settings-input'; + tgChatInput.placeholder = '-1001234567890'; + + const tgProxyLabel = document.createElement('label'); + tgProxyLabel.textContent = 'Proxy URL (optional)'; + const tgProxyInput = document.createElement('input'); + tgProxyInput.type = 'text'; + tgProxyInput.className = 'settings-input'; + tgProxyInput.placeholder = 'socks5://127.0.0.1:1080'; + + const tgSaveBtn = document.createElement('button'); + tgSaveBtn.className = 'settings-save-btn'; + tgSaveBtn.textContent = 'Save Notifications'; + + wrap.append( + statusLabel, statusInput, + headersLabel, headersInput, + bodyLabel, bodyInput, + saveBtn, + divider, + tgTitle, + tgTokenLabel, tgTokenInput, + tgChatLabel, tgChatInput, + tgProxyLabel, tgProxyInput, + tgSaveBtn, + ); if (token) { void fetchHookResponse(token).then((resp) => { @@ -248,6 +294,12 @@ function createSettingsForm(token: string | null): HTMLDivElement { bodyInput.value = resp.body || ''; }); + void fetchNotification(token).then((n) => { + tgTokenInput.value = n.telegramBotToken; + tgChatInput.value = n.telegramChatId; + tgProxyInput.value = n.proxyUrl; + }); + saveBtn.addEventListener('click', () => { let headers: Record = {}; try { @@ -278,6 +330,26 @@ function createSettingsForm(token: string | null): HTMLDivElement { saveBtn.disabled = false; }); }); + + tgSaveBtn.addEventListener('click', () => { + tgSaveBtn.disabled = true; + void saveNotification(token, { + telegramBotToken: tgTokenInput.value.trim(), + telegramChatId: tgChatInput.value.trim(), + proxyUrl: tgProxyInput.value.trim(), + }) + .then(() => { + tgSaveBtn.textContent = 'Saved!'; + setTimeout(() => (tgSaveBtn.textContent = 'Save Notifications'), 2000); + }) + .catch(() => { + tgSaveBtn.textContent = 'Error'; + setTimeout(() => (tgSaveBtn.textContent = 'Save Notifications'), 2000); + }) + .finally(() => { + tgSaveBtn.disabled = false; + }); + }); } return wrap; From c01fba3cf228e8cf61a5ee9a1424c11255403365 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 7 Jun 2026 20:51:59 +0300 Subject: [PATCH 02/20] style: fix go fmt formatting in telegram options --- internal/cli/notify/telegram/options.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/cli/notify/telegram/options.go b/internal/cli/notify/telegram/options.go index 87d292c..91011a7 100644 --- a/internal/cli/notify/telegram/options.go +++ b/internal/cli/notify/telegram/options.go @@ -56,7 +56,9 @@ func do(ctx context.Context, method, url, authToken string, body any) error { } var ar struct { - Error *struct{ Message string `json:"message"` } `json:"error"` + Error *struct { + Message string `json:"message"` + } `json:"error"` } if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil { return errors.New(ar.Error.Message) From cbf2194f7ed9ba4639574e174a9d7066954188cb Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 7 Jun 2026 20:57:35 +0300 Subject: [PATCH 03/20] style: fix prettier formatting in notification UI files --- .../endpoint-session/api/endpoint-api.ts | 3 ++- .../widgets/request-detail/request-detail.ts | 18 ++++++++++++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts index c770117..e0a4d90 100644 --- a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts +++ b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts @@ -66,7 +66,8 @@ export interface Notification { export async function fetchNotification(token: string): Promise { const response = await fetch(`/api/endpoints/${token}/notifications`); const json = (await response.json()) as ApiResponse; - if (!json.success || !json.body) return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; + if (!json.success || !json.body) + return { telegramBotToken: '', telegramChatId: '', proxyUrl: '' }; return json.body; } diff --git a/internal/web/ui/src/widgets/request-detail/request-detail.ts b/internal/web/ui/src/widgets/request-detail/request-detail.ts index 5f94480..3004bcc 100644 --- a/internal/web/ui/src/widgets/request-detail/request-detail.ts +++ b/internal/web/ui/src/widgets/request-detail/request-detail.ts @@ -273,15 +273,21 @@ function createSettingsForm(token: string | null): HTMLDivElement { tgSaveBtn.textContent = 'Save Notifications'; wrap.append( - statusLabel, statusInput, - headersLabel, headersInput, - bodyLabel, bodyInput, + statusLabel, + statusInput, + headersLabel, + headersInput, + bodyLabel, + bodyInput, saveBtn, divider, tgTitle, - tgTokenLabel, tgTokenInput, - tgChatLabel, tgChatInput, - tgProxyLabel, tgProxyInput, + tgTokenLabel, + tgTokenInput, + tgChatLabel, + tgChatInput, + tgProxyLabel, + tgProxyInput, tgSaveBtn, ); From 5c7035ba444e1e8cba486b389db0fbd85a23788b Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 14 Jun 2026 23:28:28 +0300 Subject: [PATCH 04/20] fix(api): redact notification secrets from GET response --- internal/notify/provider.go | 8 ++++++++ internal/server/contract.go | 20 ++++++++++++++++++++ internal/server/handler.go | 20 ++++++++++++++++++-- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/notify/provider.go b/internal/notify/provider.go index bfc72a4..bdb36fa 100644 --- a/internal/notify/provider.go +++ b/internal/notify/provider.go @@ -24,6 +24,10 @@ var ( registry = map[string]Provider{ "telegram": ProviderFunc(telegramSend), } + + secretKeys = map[string][]string{ + "telegram": {"bot_token"}, + } ) func Send(ctx context.Context, provider string, config Config, message string) error { @@ -39,6 +43,10 @@ func Send(ctx context.Context, provider string, config Config, message string) e return p.Send(ctx, config, message) } +func SecretKeys(provider string) []string { + return secretKeys[provider] +} + func KnownProviders() []string { registryMu.RLock() diff --git a/internal/server/contract.go b/internal/server/contract.go index 599b3b6..a16982e 100644 --- a/internal/server/contract.go +++ b/internal/server/contract.go @@ -50,6 +50,7 @@ type HookResponseContract struct { type NotificationContract struct { Provider string `json:"provider"` Config map[string]string `json:"config"` + Redacted []string `json:"redacted,omitempty"` } type SetHookResponseRequestContract struct { @@ -127,3 +128,22 @@ func toWebhookRequestContract(req domain.WebhookRequest) WebhookRequestContract ReceivedAt: req.ReceivedAt, } } + +func toNotificationContract(ch domain.NotificationChannel, secretKeys []string) NotificationContract { + secretSet := make(map[string]bool, len(secretKeys)) + for _, s := range secretKeys { + secretSet[s] = true + } + + cfg := make(map[string]string, len(ch.Config)) + var redacted []string + for k, v := range ch.Config { + if secretSet[k] && v != "" { + redacted = append(redacted, k) + } else { + cfg[k] = v + } + } + + return NotificationContract{Provider: ch.Provider, Config: cfg, Redacted: redacted} +} diff --git a/internal/server/handler.go b/internal/server/handler.go index 621874f..d4c61ba 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -367,7 +367,7 @@ func (h *Hook) GetNotification(w http.ResponseWriter, r *http.Request) { contracts := make([]NotificationContract, len(channels)) for i, ch := range channels { - contracts[i] = NotificationContract{Provider: ch.Provider, Config: ch.Config} + contracts[i] = toNotificationContract(ch, notify.SecretKeys(ch.Provider)) } data, err := json.Marshal(contracts) @@ -393,6 +393,22 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { return } + if secrets := notify.SecretKeys(provider); len(secrets) > 0 { + if existing, err := h.deps.Service.ListChannels(r.Context(), token); err == nil { + for _, exc := range existing { + if exc.Provider != provider { + continue + } + for _, key := range secrets { + if contract.Config[key] == "" && exc.Config[key] != "" { + contract.Config[key] = exc.Config[key] + } + } + break + } + } + } + ch, err := h.deps.Service.UpsertChannel(r.Context(), token, provider, contract.Config) if err != nil { if errors.Is(err, domain.ErrNotFound) { @@ -404,7 +420,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { return } - data, err := json.Marshal(NotificationContract{Provider: ch.Provider, Config: ch.Config}) + data, err := json.Marshal(toNotificationContract(ch, notify.SecretKeys(ch.Provider))) if err != nil { SendError(w, http.StatusInternalServerError, ErrInternal) return From 18f364a623f014be3b8c48b453be9e5395f40845 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 14 Jun 2026 23:35:42 +0300 Subject: [PATCH 05/20] db: collapse notification migrations into single clean migration --- ...60529000000_add_hook_notification_channels.sql} | 2 -- .../20260529000000_add_hook_notifications.sql | 14 -------------- 2 files changed, 16 deletions(-) rename internal/store/migrations/{20260529000001_refactor_notifications.sql => 20260529000000_add_hook_notification_channels.sql} (92%) delete mode 100644 internal/store/migrations/20260529000000_add_hook_notifications.sql diff --git a/internal/store/migrations/20260529000001_refactor_notifications.sql b/internal/store/migrations/20260529000000_add_hook_notification_channels.sql similarity index 92% rename from internal/store/migrations/20260529000001_refactor_notifications.sql rename to internal/store/migrations/20260529000000_add_hook_notification_channels.sql index 3c106fc..db6220b 100644 --- a/internal/store/migrations/20260529000001_refactor_notifications.sql +++ b/internal/store/migrations/20260529000000_add_hook_notification_channels.sql @@ -1,6 +1,4 @@ -- +goose Up -DROP TABLE IF EXISTS hook_notifications; - CREATE TABLE hook_notification_channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, hook_id INTEGER NOT NULL, diff --git a/internal/store/migrations/20260529000000_add_hook_notifications.sql b/internal/store/migrations/20260529000000_add_hook_notifications.sql deleted file mode 100644 index 20f7252..0000000 --- a/internal/store/migrations/20260529000000_add_hook_notifications.sql +++ /dev/null @@ -1,14 +0,0 @@ --- +goose Up -CREATE TABLE hook_notifications ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - hook_id INTEGER NOT NULL UNIQUE, - telegram_bot_token TEXT NOT NULL DEFAULT '', - telegram_chat_id TEXT NOT NULL DEFAULT '', - proxy_url TEXT NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (hook_id) REFERENCES hooks(id) ON DELETE CASCADE -); - --- +goose Down -DROP TABLE IF EXISTS hook_notifications; From bea8107a4e006f8e71fab55ddbb10575532b6105 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 14 Jun 2026 23:43:41 +0300 Subject: [PATCH 06/20] fix(api): return 404 when deleting non-existent notification channel --- internal/repos/hook.go | 9 ++++++++- internal/store/query/hooks.sql | 2 +- internal/store/sqlc/hooks.sql.go | 14 +++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 3713339..6d7c2c4 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -174,10 +174,17 @@ func (r *Hook) UpsertNotificationChannel(ctx context.Context, hookID int64, prov } func (r *Hook) DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error { - return r.q.DeleteNotificationChannel(ctx, sqlc.DeleteNotificationChannelParams{ + n, err := r.q.DeleteNotificationChannel(ctx, sqlc.DeleteNotificationChannelParams{ HookID: hookID, Provider: provider, }) + if err != nil { + return err + } + if n == 0 { + return domain.ErrNotFound + } + return nil } func toDomainChannel(row sqlc.HookNotificationChannel) domain.NotificationChannel { diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index 5c94f97..a5b6356 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -96,6 +96,6 @@ ON CONFLICT (hook_id, provider) DO UPDATE SET updated_at = CURRENT_TIMESTAMP RETURNING id, hook_id, provider, config, enabled, created_at, updated_at; --- name: DeleteNotificationChannel :exec +-- name: DeleteNotificationChannel :execrows DELETE FROM hook_notification_channels WHERE hook_id = ? AND provider = ?; diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 5badfdf..eff583d 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -92,7 +92,7 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq return i, err } -const deleteNotificationChannel = `-- name: DeleteNotificationChannel :exec +const deleteNotificationChannel = `-- name: DeleteNotificationChannel :execrows DELETE FROM hook_notification_channels WHERE hook_id = ? AND provider = ? ` @@ -102,9 +102,12 @@ type DeleteNotificationChannelParams struct { Provider string `json:"provider"` } -func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) error { - _, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.HookID, arg.Provider) - return err +func (q *Queries) DeleteNotificationChannel(ctx context.Context, arg DeleteNotificationChannelParams) (int64, error) { + result, err := q.db.ExecContext(ctx, deleteNotificationChannel, arg.HookID, arg.Provider) + if err != nil { + return 0, err + } + return result.RowsAffected() } const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult @@ -197,7 +200,8 @@ func (q *Queries) GetNotificationChannel(ctx context.Context, arg GetNotificatio const listHooks = `-- name: ListHooks :many SELECT id, token, name, created_at, updated_at FROM hooks -ORDER BY created_at DESC` +ORDER BY created_at DESC +` func (q *Queries) ListHooks(ctx context.Context) ([]Hook, error) { rows, err := q.db.QueryContext(ctx, listHooks) From 2fb78fff087461f1db5a1164be7bfa44e6bc87e1 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 14 Jun 2026 23:47:06 +0300 Subject: [PATCH 07/20] fix(api): validate provider and required config fields before saving --- internal/notify/provider.go | 20 ++++++++++++++++++++ internal/server/handler.go | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/internal/notify/provider.go b/internal/notify/provider.go index bdb36fa..0a25f03 100644 --- a/internal/notify/provider.go +++ b/internal/notify/provider.go @@ -28,6 +28,10 @@ var ( secretKeys = map[string][]string{ "telegram": {"bot_token"}, } + + requiredKeys = map[string][]string{ + "telegram": {"bot_token", "chat_id"}, + } ) func Send(ctx context.Context, provider string, config Config, message string) error { @@ -43,6 +47,22 @@ func Send(ctx context.Context, provider string, config Config, message string) e return p.Send(ctx, config, message) } +func ValidateConfig(provider string, config Config) error { + registryMu.RLock() + _, ok := registry[provider] + registryMu.RUnlock() + + if !ok { + return fmt.Errorf("unknown provider %q", provider) + } + for _, k := range requiredKeys[provider] { + if config[k] == "" { + return fmt.Errorf("%s: %q is required", provider, k) + } + } + return nil +} + func SecretKeys(provider string) []string { return secretKeys[provider] } diff --git a/internal/server/handler.go b/internal/server/handler.go index d4c61ba..100d9c5 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -409,6 +409,11 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { } } + if err := notify.ValidateConfig(provider, notify.Config(contract.Config)); err != nil { + SendError(w, http.StatusBadRequest, WithDetails(ErrBadRequest, ErrorDetailContract{Message: err.Error()})) + return + } + ch, err := h.deps.Service.UpsertChannel(r.Context(), token, provider, contract.Config) if err != nil { if errors.Is(err, domain.ErrNotFound) { From 3b7a76da10d9dd2f2cbbcd4eccde4ae55798022d Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 14 Jun 2026 23:54:34 +0300 Subject: [PATCH 08/20] perf(notify): reuse telegram HTTP client across sends --- internal/notify/telegram.go | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 6167e27..ae1efb7 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -8,9 +8,15 @@ import ( "log/slog" "net/http" "net/url" + "sync" "time" ) +var ( + defaultTelegramClient = &http.Client{Timeout: 10 * time.Second} + proxyClients sync.Map +) + // telegramSend implements Provider for Telegram via ProviderFunc. func telegramSend(ctx context.Context, config Config, message string) error { return sendMessage(ctx, config["bot_token"], config["chat_id"], message, config["proxy_url"]) @@ -21,13 +27,22 @@ func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) e return fmt.Errorf("telegram: bot_token and chat_id are required") } - client := &http.Client{Timeout: 10 * time.Second} + client := defaultTelegramClient if proxyURL != "" { - proxy, err := validateProxy(proxyURL) - if err != nil { - return err + if v, ok := proxyClients.Load(proxyURL); ok { + client = v.(*http.Client) + } else { + proxy, err := validateProxy(proxyURL) + if err != nil { + return err + } + c := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{Proxy: http.ProxyURL(proxy)}, + } + proxyClients.Store(proxyURL, c) + client = c } - client.Transport = &http.Transport{Proxy: http.ProxyURL(proxy)} } // text already contains HTML from the caller (handler.go escapes user data before calling Send) From b72cd9fe3d605b67f0d59d41d1f7fdcf3cd13e44 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 00:00:09 +0300 Subject: [PATCH 09/20] perf(notify): reuse telegram HTTP client across sends --- internal/notify/telegram.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index ae1efb7..f49e072 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -30,7 +30,9 @@ func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) e client := defaultTelegramClient if proxyURL != "" { if v, ok := proxyClients.Load(proxyURL); ok { - client = v.(*http.Client) + if c, ok := v.(*http.Client); ok { + client = c + } } else { proxy, err := validateProxy(proxyURL) if err != nil { From fd3ca5203e119d5a0a009c0e3701d9cd276624bf Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 21:38:17 +0300 Subject: [PATCH 10/20] fix(server): bound concurrent notification goroutines with semaphore --- internal/server/handler.go | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 100d9c5..dfd5dd3 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -14,7 +14,10 @@ import ( "github.com/GaIsBAX/Webhix/internal/notify" ) -const DefaultMaxBodySize int64 = 5 << 20 // 5MB +const ( + DefaultMaxBodySize int64 = 5 << 20 // 5MB + maxConcurrentNotifications = 64 +) type HookService interface { ListHooks(ctx context.Context) ([]domain.Hook, error) @@ -49,7 +52,8 @@ type HookDeps struct { } type Hook struct { - deps *HookDeps + deps *HookDeps + notifySem chan struct{} } func NewHook(deps *HookDeps) *Hook { @@ -57,7 +61,7 @@ func NewHook(deps *HookDeps) *Hook { deps.Opts.MaxBodySize = DefaultMaxBodySize } - return &Hook{deps: deps} + return &Hook{deps: deps, notifySem: make(chan struct{}, maxConcurrentNotifications)} } func (h *Hook) RegisterRoutes() { @@ -198,7 +202,15 @@ func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { } h.deps.Hub.Publish(token, data) - go h.sendNotifications(req, token, context.WithoutCancel(r.Context())) + go func() { + select { + case h.notifySem <- struct{}{}: + defer func() { <-h.notifySem }() + h.sendNotifications(req, token, context.WithoutCancel(r.Context())) + default: + slog.Warn("notification queue full, dropping", "token", token) + } + }() if customResp.StatusCode > 0 { for k, v := range customResp.Headers { From bbd40ce237c002c7dfe134e9fe05f7e0867a1934 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 21:51:10 +0300 Subject: [PATCH 11/20] refactor(cli): extract shared API client and fix notify list output --- internal/cli/apiclient/client.go | 100 ++++++++++++++++++++++++ internal/cli/notify/command.go | 18 +++-- internal/cli/notify/options.go | 69 ---------------- internal/cli/notify/telegram/command.go | 34 ++++---- internal/cli/notify/telegram/options.go | 67 ---------------- 5 files changed, 131 insertions(+), 157 deletions(-) create mode 100644 internal/cli/apiclient/client.go delete mode 100644 internal/cli/notify/telegram/options.go diff --git a/internal/cli/apiclient/client.go b/internal/cli/apiclient/client.go new file mode 100644 index 0000000..967c9cc --- /dev/null +++ b/internal/cli/apiclient/client.go @@ -0,0 +1,100 @@ +package apiclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +type Client struct { + server *string + authToken *string + http *http.Client +} + +func New(server, authToken *string) *Client { + return &Client{ + server: server, + authToken: authToken, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +type apiResponse struct { + Success bool `json:"success"` + Body json.RawMessage `json:"body"` + Error *apiError `json:"error"` +} + +type apiError struct { + Message string `json:"message"` +} + +func (c *Client) Get(ctx context.Context, path string, out any) error { + return c.do(ctx, http.MethodGet, path, nil, out) +} + +func (c *Client) Put(ctx context.Context, path string, body any) error { + return c.do(ctx, http.MethodPut, path, body, nil) +} + +func (c *Client) Post(ctx context.Context, path string, body any) error { + return c.do(ctx, http.MethodPost, path, body, nil) +} + +func (c *Client) Delete(ctx context.Context, path string) error { + return c.do(ctx, http.MethodDelete, path, nil, nil) +} + +func (c *Client) do(ctx context.Context, method, path string, body any, out any) error { + var r io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + r = bytes.NewReader(data) + } + + req, err := http.NewRequestWithContext(ctx, method, *c.server+path, r) + if err != nil { + return err + } + if *c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+*c.authToken) + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close response body", "err", err) + } + }() + + var ar apiResponse + if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { + return fmt.Errorf("server returned %d", resp.StatusCode) + } + if !ar.Success { + if ar.Error != nil { + return errors.New(ar.Error.Message) + } + return fmt.Errorf("server returned %d", resp.StatusCode) + } + if out != nil { + return json.Unmarshal(ar.Body, out) + } + return nil +} diff --git a/internal/cli/notify/command.go b/internal/cli/notify/command.go index 0dcc079..de80eb5 100644 --- a/internal/cli/notify/command.go +++ b/internal/cli/notify/command.go @@ -4,6 +4,7 @@ import ( "context" "net/url" + "github.com/GaIsBAX/Webhix/internal/cli/apiclient" "github.com/GaIsBAX/Webhix/internal/cli/notify/telegram" "github.com/GaIsBAX/Webhix/internal/config" "github.com/spf13/cobra" @@ -12,6 +13,7 @@ import ( type notificationChannel struct { Provider string `json:"provider"` Config map[string]string `json:"config"` + Redacted []string `json:"redacted"` } func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { @@ -27,20 +29,22 @@ func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { RegisterFlags(cmd, &opts) - cmd.AddCommand(newListCmd(ctx, &opts)) - cmd.AddCommand(telegram.NewCommand(ctx, &opts.Server, &opts.AuthToken)) + client := apiclient.New(&opts.Server, &opts.AuthToken) + + cmd.AddCommand(newListCmd(ctx, client)) + cmd.AddCommand(telegram.NewCommand(ctx, client)) return cmd } -func newListCmd(ctx context.Context, opts *Options) *cobra.Command { +func newListCmd(ctx context.Context, client *apiclient.Client) *cobra.Command { return &cobra.Command{ Use: "list ", Short: "List all configured notification channels", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { var channels []notificationChannel - if err := apiGet(ctx, opts, "/api/endpoints/"+url.PathEscape(args[0])+"/notifications", &channels); err != nil { + if err := client.Get(ctx, "/api/endpoints/"+url.PathEscape(args[0])+"/notifications", &channels); err != nil { return err } @@ -52,11 +56,11 @@ func newListCmd(ctx context.Context, opts *Options) *cobra.Command { for _, ch := range channels { cmd.Printf("Provider: %s\n", ch.Provider) for k, v := range ch.Config { - if k == "bot_token" { - v = maskToken(v) - } cmd.Printf(" %s: %s\n", k, v) } + for _, k := range ch.Redacted { + cmd.Printf(" %s: [set]\n", k) + } } return nil }, diff --git a/internal/cli/notify/options.go b/internal/cli/notify/options.go index 8937699..ab8a016 100644 --- a/internal/cli/notify/options.go +++ b/internal/cli/notify/options.go @@ -1,82 +1,13 @@ package notify -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "time" -) - type Options struct { Server string AuthToken string } -type apiResponse struct { - Success bool `json:"success"` - Body json.RawMessage `json:"body"` - Error *apiError `json:"error"` -} - -type apiError struct { - Message string `json:"message"` -} - -var httpClient = &http.Client{Timeout: 30 * time.Second} - func DefaultOptions() Options { return Options{ Server: "http://localhost:8080", } } -func (o *Options) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, o.Server+path, body) - if err != nil { - return nil, err - } - if o.AuthToken != "" { - req.Header.Set("Authorization", "Bearer "+o.AuthToken) - } - return req, nil -} - -func apiGet(ctx context.Context, opts *Options, path string, out any) error { - req, err := opts.newRequest(ctx, http.MethodGet, path, nil) - if err != nil { - return err - } - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - slog.Warn("close response body", "err", err) - } - }() - - var ar apiResponse - if err := json.NewDecoder(resp.Body).Decode(&ar); err != nil { - return err - } - if !ar.Success { - if ar.Error != nil { - return errors.New(ar.Error.Message) - } - return fmt.Errorf("server returned %d", resp.StatusCode) - } - return json.Unmarshal(ar.Body, out) -} - -func maskToken(token string) string { - if len(token) <= 14 { - return "***" - } - return token[:4] + "..." + token[len(token)-4:] -} diff --git a/internal/cli/notify/telegram/command.go b/internal/cli/notify/telegram/command.go index 0839049..26b5911 100644 --- a/internal/cli/notify/telegram/command.go +++ b/internal/cli/notify/telegram/command.go @@ -2,26 +2,32 @@ package telegram import ( "context" - "net/http" "net/url" + "github.com/GaIsBAX/Webhix/internal/cli/apiclient" "github.com/spf13/cobra" ) -func NewCommand(ctx context.Context, server, authToken *string) *cobra.Command { +type Options struct { + BotToken string + ChatID string + ProxyURL string +} + +func NewCommand(ctx context.Context, client *apiclient.Client) *cobra.Command { cmd := &cobra.Command{ Use: "telegram", Short: "Manage Telegram notifications", } - cmd.AddCommand(newSetCmd(ctx, server, authToken)) - cmd.AddCommand(newTestCmd(ctx, server, authToken)) - cmd.AddCommand(newRemoveCmd(ctx, server, authToken)) + cmd.AddCommand(newSetCmd(ctx, client)) + cmd.AddCommand(newTestCmd(ctx, client)) + cmd.AddCommand(newRemoveCmd(ctx, client)) return cmd } -func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command { +func newSetCmd(ctx context.Context, client *apiclient.Client) *cobra.Command { opts := Options{} cmd := &cobra.Command{ @@ -35,8 +41,8 @@ func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command { } body := map[string]any{"provider": "telegram", "config": cfg} - path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" - if err := do(ctx, http.MethodPut, path, *authToken, body); err != nil { + path := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := client.Put(ctx, path, body); err != nil { return err } @@ -52,14 +58,14 @@ func newSetCmd(ctx context.Context, server, authToken *string) *cobra.Command { return cmd } -func newTestCmd(ctx context.Context, server, authToken *string) *cobra.Command { +func newTestCmd(ctx context.Context, client *apiclient.Client) *cobra.Command { return &cobra.Command{ Use: "test ", Short: "Send a test Telegram message", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test" - if err := do(ctx, http.MethodPost, path, *authToken, nil); err != nil { + path := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test" + if err := client.Post(ctx, path, nil); err != nil { return err } @@ -69,14 +75,14 @@ func newTestCmd(ctx context.Context, server, authToken *string) *cobra.Command { } } -func newRemoveCmd(ctx context.Context, server, authToken *string) *cobra.Command { +func newRemoveCmd(ctx context.Context, client *apiclient.Client) *cobra.Command { return &cobra.Command{ Use: "remove ", Short: "Remove Telegram notifications from an endpoint", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - path := *server + "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" - if err := do(ctx, http.MethodDelete, path, *authToken, nil); err != nil { + path := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram" + if err := client.Delete(ctx, path); err != nil { return err } diff --git a/internal/cli/notify/telegram/options.go b/internal/cli/notify/telegram/options.go deleted file mode 100644 index 91011a7..0000000 --- a/internal/cli/notify/telegram/options.go +++ /dev/null @@ -1,67 +0,0 @@ -package telegram - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" - "net/http" - "time" -) - -type Options struct { - BotToken string - ChatID string - ProxyURL string -} - -var httpClient = &http.Client{Timeout: 30 * time.Second} - -func do(ctx context.Context, method, url, authToken string, body any) error { - var r io.Reader - if body != nil { - data, err := json.Marshal(body) - if err != nil { - return err - } - r = bytes.NewReader(data) - } - - req, err := http.NewRequestWithContext(ctx, method, url, r) - if err != nil { - return err - } - if authToken != "" { - req.Header.Set("Authorization", "Bearer "+authToken) - } - if body != nil { - req.Header.Set("Content-Type", "application/json") - } - - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer func() { - if err := resp.Body.Close(); err != nil { - slog.Warn("close response body", "err", err) - } - }() - - if resp.StatusCode >= 200 && resp.StatusCode < 300 { - return nil - } - - var ar struct { - Error *struct { - Message string `json:"message"` - } `json:"error"` - } - if json.NewDecoder(resp.Body).Decode(&ar) == nil && ar.Error != nil { - return errors.New(ar.Error.Message) - } - return fmt.Errorf("server returned %d", resp.StatusCode) -} From dc57a8ebce58afd816a60eccb759ef99eafe9d81 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 21:54:00 +0300 Subject: [PATCH 12/20] style: fix trailing newline in notify options --- internal/cli/notify/options.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/cli/notify/options.go b/internal/cli/notify/options.go index ab8a016..97a9099 100644 --- a/internal/cli/notify/options.go +++ b/internal/cli/notify/options.go @@ -10,4 +10,3 @@ func DefaultOptions() Options { Server: "http://localhost:8080", } } - From deac3784e3cffc4259003c2cea571de2e3c578f1 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 22:03:26 +0300 Subject: [PATCH 13/20] refactor(notify): enrich Provider interface with ValidateConfig and SecretKeys --- internal/notify/provider.go | 38 +++++++++++++++---------------------- internal/notify/telegram.go | 18 ++++++++++++++++-- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/internal/notify/provider.go b/internal/notify/provider.go index 0a25f03..e5afebb 100644 --- a/internal/notify/provider.go +++ b/internal/notify/provider.go @@ -11,26 +11,14 @@ type Config map[string]string type Provider interface { Send(ctx context.Context, config Config, message string) error -} - -type ProviderFunc func(ctx context.Context, config Config, message string) error - -func (f ProviderFunc) Send(ctx context.Context, config Config, message string) error { - return f(ctx, config, message) + ValidateConfig(config Config) error + SecretKeys() []string } var ( registryMu sync.RWMutex registry = map[string]Provider{ - "telegram": ProviderFunc(telegramSend), - } - - secretKeys = map[string][]string{ - "telegram": {"bot_token"}, - } - - requiredKeys = map[string][]string{ - "telegram": {"bot_token", "chat_id"}, + "telegram": telegramProvider{}, } ) @@ -49,22 +37,26 @@ func Send(ctx context.Context, provider string, config Config, message string) e func ValidateConfig(provider string, config Config) error { registryMu.RLock() - _, ok := registry[provider] + p, ok := registry[provider] registryMu.RUnlock() if !ok { return fmt.Errorf("unknown provider %q", provider) } - for _, k := range requiredKeys[provider] { - if config[k] == "" { - return fmt.Errorf("%s: %q is required", provider, k) - } - } - return nil + + return p.ValidateConfig(config) } func SecretKeys(provider string) []string { - return secretKeys[provider] + registryMu.RLock() + p, ok := registry[provider] + registryMu.RUnlock() + + if !ok { + return nil + } + + return p.SecretKeys() } func KnownProviders() []string { diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index f49e072..b6e403b 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -17,11 +17,25 @@ var ( proxyClients sync.Map ) -// telegramSend implements Provider for Telegram via ProviderFunc. -func telegramSend(ctx context.Context, config Config, message string) error { +type telegramProvider struct{} + +func (telegramProvider) Send(ctx context.Context, config Config, message string) error { return sendMessage(ctx, config["bot_token"], config["chat_id"], message, config["proxy_url"]) } +func (telegramProvider) ValidateConfig(config Config) error { + for _, k := range []string{"bot_token", "chat_id"} { + if config[k] == "" { + return fmt.Errorf("telegram: %q is required", k) + } + } + return nil +} + +func (telegramProvider) SecretKeys() []string { + return []string{"bot_token"} +} + func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) error { if botToken == "" || chatID == "" { return fmt.Errorf("telegram: bot_token and chat_id are required") From 95bc2089b9075346a5e08b11a40a3a21ec243bdf Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 15 Jun 2026 22:05:17 +0300 Subject: [PATCH 14/20] fix(server): initialize config map before merging secrets to prevent panic --- internal/server/handler.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/server/handler.go b/internal/server/handler.go index dfd5dd3..e225046 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -405,6 +405,10 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { return } + if contract.Config == nil { + contract.Config = make(map[string]string) + } + if secrets := notify.SecretKeys(provider); len(secrets) > 0 { if existing, err := h.deps.Service.ListChannels(r.Context(), token); err == nil { for _, exc := range existing { From 820f6710ca63fcfc662e695caa3b9605b96ef362 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 18:28:56 +0300 Subject: [PATCH 15/20] fix(core): separate name from token on endpoint creation and remove unused repo method --- internal/core/hook_core.go | 11 +++-------- internal/repos/hook.go | 4 ++-- internal/server/handler.go | 2 +- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 8d239db..6a28aac 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -12,7 +12,7 @@ const defaultHookResponseStatusCode int64 = 200 type TokenGenerator func() string type HookRepository interface { - CreateHook(ctx context.Context, token string) (domain.Hook, error) + CreateHook(ctx context.Context, token, name string) (domain.Hook, error) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) ListHooks(ctx context.Context) ([]domain.Hook, error) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) @@ -20,7 +20,6 @@ type HookRepository interface { GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) ListNotificationChannels(ctx context.Context, hookID int64) ([]domain.NotificationChannel, error) - GetNotificationChannel(ctx context.Context, hookID int64, provider string) (domain.NotificationChannel, error) UpsertNotificationChannel(ctx context.Context, hookID int64, provider string, config map[string]string) (domain.NotificationChannel, error) DeleteNotificationChannel(ctx context.Context, hookID int64, provider string) error } @@ -45,12 +44,8 @@ func (s *Hook) ListHooks(ctx context.Context) ([]domain.Hook, error) { return s.repo.ListHooks(ctx) } -func (s *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { - if token == "" { - token = s.generateToken() - } - - return s.repo.CreateHook(ctx, token) +func (s *Hook) CreateHook(ctx context.Context, name string) (domain.Hook, error) { + return s.repo.CreateHook(ctx, s.generateToken(), name) } func (s *Hook) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) { diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 6d7c2c4..316f510 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -21,10 +21,10 @@ func NewHook(db sqlc.DBTX) *Hook { } } -func (r *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { +func (r *Hook) CreateHook(ctx context.Context, token, name string) (domain.Hook, error) { hook, err := r.q.CreateHook(ctx, sqlc.CreateHookParams{ Token: token, - Name: sql.NullString{}, + Name: sql.NullString{String: name, Valid: name != ""}, }) if err != nil { return domain.Hook{}, err diff --git a/internal/server/handler.go b/internal/server/handler.go index e225046..760346b 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -21,7 +21,7 @@ const ( type HookService interface { ListHooks(ctx context.Context) ([]domain.Hook, error) - CreateHook(ctx context.Context, token string) (domain.Hook, error) + CreateHook(ctx context.Context, name string) (domain.Hook, error) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) From 068b1b85c4cc173ecef2d8d8cc86b33445cecd70 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 18:38:01 +0300 Subject: [PATCH 16/20] refactor(server): split HookService, parallelize notification delivery --- internal/app/deps.go | 7 ++++--- internal/server/handler.go | 37 ++++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/internal/app/deps.go b/internal/app/deps.go index 817e481..b67c010 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -132,9 +132,10 @@ type handlers struct { func newHandlers(deps *dependencies) *handlers { return &handlers{ hook: server.NewHook(&server.HookDeps{ - Mux: deps.mux, - Service: deps.services.hook, - Hub: deps.infra.hub, + Mux: deps.mux, + Service: deps.services.hook, + Notifications: deps.services.hook, + Hub: deps.infra.hub, Opts: server.HookOptions{ BaseURL: deps.cfg.BaseURL, MaxBodySize: deps.cfg.MaxBodySize, diff --git a/internal/server/handler.go b/internal/server/handler.go index 760346b..3c02814 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -9,6 +9,7 @@ import ( "io" "log/slog" "net/http" + "sync" "github.com/GaIsBAX/Webhix/internal/domain" "github.com/GaIsBAX/Webhix/internal/notify" @@ -26,6 +27,9 @@ type HookService interface { ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) +} + +type NotificationService interface { ListChannels(ctx context.Context, token string) ([]domain.NotificationChannel, error) UpsertChannel(ctx context.Context, token, provider string, config map[string]string) (domain.NotificationChannel, error) DeleteChannel(ctx context.Context, token, provider string) error @@ -45,10 +49,11 @@ type HookOptions struct { } type HookDeps struct { - Mux *http.ServeMux - Service HookService - Hub EventBroker - Opts HookOptions + Mux *http.ServeMux + Service HookService + Notifications NotificationService + Hub EventBroker + Opts HookOptions } type Hook struct { @@ -366,7 +371,7 @@ func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { func (h *Hook) GetNotification(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - channels, err := h.deps.Service.ListChannels(r.Context(), token) + channels, err := h.deps.Notifications.ListChannels(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -410,7 +415,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { } if secrets := notify.SecretKeys(provider); len(secrets) > 0 { - if existing, err := h.deps.Service.ListChannels(r.Context(), token); err == nil { + if existing, err := h.deps.Notifications.ListChannels(r.Context(), token); err == nil { for _, exc := range existing { if exc.Provider != provider { continue @@ -430,7 +435,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { return } - ch, err := h.deps.Service.UpsertChannel(r.Context(), token, provider, contract.Config) + ch, err := h.deps.Notifications.UpsertChannel(r.Context(), token, provider, contract.Config) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -458,7 +463,7 @@ func (h *Hook) DeleteNotification(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") provider := r.PathValue("provider") - if err := h.deps.Service.DeleteChannel(r.Context(), token, provider); err != nil { + if err := h.deps.Notifications.DeleteChannel(r.Context(), token, provider); err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) return @@ -475,7 +480,7 @@ func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") provider := r.PathValue("provider") - channels, err := h.deps.Service.ListChannels(r.Context(), token) + channels, err := h.deps.Notifications.ListChannels(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -510,7 +515,7 @@ func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { } func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx context.Context) { - channels, err := h.deps.Service.GetChannelsForHookID(ctx, req.HookID) + channels, err := h.deps.Notifications.GetChannelsForHookID(ctx, req.HookID) if err != nil || len(channels) == 0 { return } @@ -520,14 +525,20 @@ func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx co html.EscapeString(token), html.EscapeString(req.Method), html.EscapeString(req.Path), ) + var wg sync.WaitGroup for _, ch := range channels { if !ch.Enabled { continue } - if err := notify.Send(ctx, ch.Provider, notify.Config(ch.Config), msg); err != nil { - slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) - } + wg.Add(1) + go func(ch domain.NotificationChannel) { + defer wg.Done() + if err := notify.Send(ctx, ch.Provider, notify.Config(ch.Config), msg); err != nil { + slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) + } + }(ch) } + wg.Wait() } func (h *Hook) readOnly(w http.ResponseWriter) bool { From e9946d196bc30776c1e8666537ead09151c46ff0 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 18:47:38 +0300 Subject: [PATCH 17/20] refactor(notify): replace global registry with explicit Registry struct --- internal/app/deps.go | 8 +++++-- internal/notify/provider.go | 47 ++++++++++++++----------------------- internal/notify/telegram.go | 4 ++++ internal/server/handler.go | 20 ++++++++++------ 4 files changed, 40 insertions(+), 39 deletions(-) diff --git a/internal/app/deps.go b/internal/app/deps.go index b67c010..ae2cada 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -10,6 +10,7 @@ import ( "github.com/GaIsBAX/Webhix/internal/config" "github.com/GaIsBAX/Webhix/internal/core" "github.com/GaIsBAX/Webhix/internal/hub" + "github.com/GaIsBAX/Webhix/internal/notify" "github.com/GaIsBAX/Webhix/internal/repos" "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/store" @@ -132,9 +133,12 @@ type handlers struct { func newHandlers(deps *dependencies) *handlers { return &handlers{ hook: server.NewHook(&server.HookDeps{ - Mux: deps.mux, - Service: deps.services.hook, + Mux: deps.mux, + Service: deps.services.hook, Notifications: deps.services.hook, + Registry: notify.NewRegistry(map[string]notify.Provider{ + "telegram": notify.NewTelegramProvider(), + }), Hub: deps.infra.hub, Opts: server.HookOptions{ BaseURL: deps.cfg.BaseURL, diff --git a/internal/notify/provider.go b/internal/notify/provider.go index e5afebb..5492710 100644 --- a/internal/notify/provider.go +++ b/internal/notify/provider.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "sort" - "sync" ) type Config map[string]string @@ -15,43 +14,34 @@ type Provider interface { SecretKeys() []string } -var ( - registryMu sync.RWMutex - registry = map[string]Provider{ - "telegram": telegramProvider{}, - } -) - -func Send(ctx context.Context, provider string, config Config, message string) error { - registryMu.RLock() +type Registry struct { + providers map[string]Provider +} - p, ok := registry[provider] - registryMu.RUnlock() +func NewRegistry(providers map[string]Provider) *Registry { + return &Registry{providers: providers} +} +func (r *Registry) Send(ctx context.Context, provider string, config map[string]string, message string) error { + p, ok := r.providers[provider] if !ok { return fmt.Errorf("unknown provider: %s", provider) } - return p.Send(ctx, config, message) + return p.Send(ctx, Config(config), message) } -func ValidateConfig(provider string, config Config) error { - registryMu.RLock() - p, ok := registry[provider] - registryMu.RUnlock() - +func (r *Registry) ValidateConfig(provider string, config map[string]string) error { + p, ok := r.providers[provider] if !ok { return fmt.Errorf("unknown provider %q", provider) } - return p.ValidateConfig(config) + return p.ValidateConfig(Config(config)) } -func SecretKeys(provider string) []string { - registryMu.RLock() - p, ok := registry[provider] - registryMu.RUnlock() - +func (r *Registry) SecretKeys(provider string) []string { + p, ok := r.providers[provider] if !ok { return nil } @@ -59,15 +49,12 @@ func SecretKeys(provider string) []string { return p.SecretKeys() } -func KnownProviders() []string { - registryMu.RLock() - - keys := make([]string, 0, len(registry)) - for k := range registry { +func (r *Registry) KnownProviders() []string { + keys := make([]string, 0, len(r.providers)) + for k := range r.providers { keys = append(keys, k) } - registryMu.RUnlock() sort.Strings(keys) return keys } diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index b6e403b..6c468c4 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -17,6 +17,10 @@ var ( proxyClients sync.Map ) +func NewTelegramProvider() Provider { + return telegramProvider{} +} + type telegramProvider struct{} func (telegramProvider) Send(ctx context.Context, config Config, message string) error { diff --git a/internal/server/handler.go b/internal/server/handler.go index 3c02814..375e585 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -12,7 +12,6 @@ import ( "sync" "github.com/GaIsBAX/Webhix/internal/domain" - "github.com/GaIsBAX/Webhix/internal/notify" ) const ( @@ -42,6 +41,12 @@ type EventBroker interface { Publish(token string, data []byte) } +type NotificationRegistry interface { + Send(ctx context.Context, provider string, config map[string]string, message string) error + ValidateConfig(provider string, config map[string]string) error + SecretKeys(provider string) []string +} + type HookOptions struct { BaseURL string MaxBodySize int64 @@ -52,6 +57,7 @@ type HookDeps struct { Mux *http.ServeMux Service HookService Notifications NotificationService + Registry NotificationRegistry Hub EventBroker Opts HookOptions } @@ -384,7 +390,7 @@ func (h *Hook) GetNotification(w http.ResponseWriter, r *http.Request) { contracts := make([]NotificationContract, len(channels)) for i, ch := range channels { - contracts[i] = toNotificationContract(ch, notify.SecretKeys(ch.Provider)) + contracts[i] = toNotificationContract(ch, h.deps.Registry.SecretKeys(ch.Provider)) } data, err := json.Marshal(contracts) @@ -414,7 +420,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { contract.Config = make(map[string]string) } - if secrets := notify.SecretKeys(provider); len(secrets) > 0 { + if secrets := h.deps.Registry.SecretKeys(provider); len(secrets) > 0 { if existing, err := h.deps.Notifications.ListChannels(r.Context(), token); err == nil { for _, exc := range existing { if exc.Provider != provider { @@ -430,7 +436,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { } } - if err := notify.ValidateConfig(provider, notify.Config(contract.Config)); err != nil { + if err := h.deps.Registry.ValidateConfig(provider, contract.Config); err != nil { SendError(w, http.StatusBadRequest, WithDetails(ErrBadRequest, ErrorDetailContract{Message: err.Error()})) return } @@ -446,7 +452,7 @@ func (h *Hook) SetNotification(w http.ResponseWriter, r *http.Request) { return } - data, err := json.Marshal(toNotificationContract(ch, notify.SecretKeys(ch.Provider))) + data, err := json.Marshal(toNotificationContract(ch, h.deps.Registry.SecretKeys(ch.Provider))) if err != nil { SendError(w, http.StatusInternalServerError, ErrInternal) return @@ -496,7 +502,7 @@ func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { continue } msg := fmt.Sprintf("✅ Webhix test notification for endpoint /r/%s", html.EscapeString(token)) - if err := notify.Send(r.Context(), ch.Provider, notify.Config(ch.Config), msg); err != nil { + if err := h.deps.Registry.Send(r.Context(), ch.Provider, ch.Config, msg); err != nil { slog.Error("test notification", "provider", provider, "err", err) SendError(w, http.StatusBadGateway, WithDetails(ErrInternal, ErrorDetailContract{ Field: provider, @@ -533,7 +539,7 @@ func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx co wg.Add(1) go func(ch domain.NotificationChannel) { defer wg.Done() - if err := notify.Send(ctx, ch.Provider, notify.Config(ch.Config), msg); err != nil { + if err := h.deps.Registry.Send(ctx, ch.Provider, ch.Config, msg); err != nil { slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) } }(ch) From 9db03008e5698531d9978166599faca3cf3712e1 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 19:03:09 +0300 Subject: [PATCH 18/20] refactor(core): move notification dispatch use case out of HTTP layer --- internal/app/deps.go | 23 ++++++++------- internal/core/hook_core.go | 59 ++++++++++++++++++++++++++++++++++++-- internal/server/handler.go | 48 ++++--------------------------- 3 files changed, 75 insertions(+), 55 deletions(-) diff --git a/internal/app/deps.go b/internal/app/deps.go index ae2cada..e28ec72 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -39,7 +39,12 @@ func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, er } repos := newRepositories(infra.db) - services := newServices(repos) + + notifyRegistry := notify.NewRegistry(map[string]notify.Provider{ + "telegram": notify.NewTelegramProvider(), + }) + + services := newServices(repos, notifyRegistry) deps.mux = mux deps.cfg = cfg @@ -47,7 +52,7 @@ func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, er deps.infra = infra deps.repos = repos deps.services = services - deps.handlers = newHandlers(&deps) + deps.handlers = newHandlers(&deps, notifyRegistry) deps.handlers.registerRoutes() staticFS, err := fs.Sub(web.Static, "static") @@ -64,10 +69,10 @@ type services struct { serve *core.Serve } -func newServices(repos *repositories) *services { +func newServices(repos *repositories, sender core.NotificationSender) *services { hook := core.NewHook(repos.hook, func() string { return pkg.GeneratePrefixedString("ho") - }) + }, sender) serve := core.NewServe(repos.serve) return &services{ @@ -130,15 +135,13 @@ type handlers struct { hook *server.Hook } -func newHandlers(deps *dependencies) *handlers { +func newHandlers(deps *dependencies, registry *notify.Registry) *handlers { return &handlers{ hook: server.NewHook(&server.HookDeps{ - Mux: deps.mux, - Service: deps.services.hook, + Mux: deps.mux, + Service: deps.services.hook, Notifications: deps.services.hook, - Registry: notify.NewRegistry(map[string]notify.Provider{ - "telegram": notify.NewTelegramProvider(), - }), + Registry: registry, Hub: deps.infra.hub, Opts: server.HookOptions{ BaseURL: deps.cfg.BaseURL, diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 6a28aac..e660bfa 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -3,14 +3,25 @@ package core import ( "context" "errors" + "fmt" + "html" + "log/slog" + "sync" "github.com/GaIsBAX/Webhix/internal/domain" ) -const defaultHookResponseStatusCode int64 = 200 +const ( + defaultHookResponseStatusCode int64 = 200 + maxConcurrentNotifications int = 64 +) type TokenGenerator func() string +type NotificationSender interface { + Send(ctx context.Context, provider string, config map[string]string, message string) error +} + type HookRepository interface { CreateHook(ctx context.Context, token, name string) (domain.Hook, error) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) @@ -27,9 +38,11 @@ type HookRepository interface { type Hook struct { repo HookRepository generateToken TokenGenerator + sender NotificationSender + notifySem chan struct{} } -func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { +func NewHook(repo HookRepository, generateToken TokenGenerator, sender NotificationSender) *Hook { if generateToken == nil { generateToken = func() string { return "" } } @@ -37,6 +50,8 @@ func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { return &Hook{ repo: repo, generateToken: generateToken, + sender: sender, + notifySem: make(chan struct{}, maxConcurrentNotifications), } } @@ -135,6 +150,46 @@ func (s *Hook) GetChannelsForHookID(ctx context.Context, hookID int64) ([]domain return s.repo.ListNotificationChannels(ctx, hookID) } +func (s *Hook) DispatchNotifications(ctx context.Context, req domain.WebhookRequest, token string) { + ctx = context.WithoutCancel(ctx) + go func() { + select { + case s.notifySem <- struct{}{}: + defer func() { <-s.notifySem }() + s.sendNotifications(ctx, req, token) + default: + slog.Warn("notification queue full, dropping", "token", token) + } + }() +} + +func (s *Hook) sendNotifications(ctx context.Context, req domain.WebhookRequest, token string) { + channels, err := s.repo.ListNotificationChannels(ctx, req.HookID) + if err != nil || len(channels) == 0 { + return + } + + msg := fmt.Sprintf( + "📨 New webhook\nEndpoint: /r/%s\nMethod: %s\nPath: %s", + html.EscapeString(token), html.EscapeString(req.Method), html.EscapeString(req.Path), + ) + + var wg sync.WaitGroup + for _, ch := range channels { + if !ch.Enabled { + continue + } + wg.Add(1) + go func(ch domain.NotificationChannel) { + defer wg.Done() + if err := s.sender.Send(ctx, ch.Provider, ch.Config, msg); err != nil { + slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) + } + }(ch) + } + wg.Wait() +} + func defaultHookResponse() domain.HookResponse { return domain.HookResponse{ StatusCode: defaultHookResponseStatusCode, diff --git a/internal/server/handler.go b/internal/server/handler.go index 375e585..89b5044 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -9,15 +9,11 @@ import ( "io" "log/slog" "net/http" - "sync" "github.com/GaIsBAX/Webhix/internal/domain" ) -const ( - DefaultMaxBodySize int64 = 5 << 20 // 5MB - maxConcurrentNotifications = 64 -) +const DefaultMaxBodySize int64 = 5 << 20 // 5MB type HookService interface { ListHooks(ctx context.Context) ([]domain.Hook, error) @@ -26,6 +22,7 @@ type HookService interface { ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) + DispatchNotifications(ctx context.Context, req domain.WebhookRequest, token string) } type NotificationService interface { @@ -63,8 +60,7 @@ type HookDeps struct { } type Hook struct { - deps *HookDeps - notifySem chan struct{} + deps *HookDeps } func NewHook(deps *HookDeps) *Hook { @@ -72,7 +68,7 @@ func NewHook(deps *HookDeps) *Hook { deps.Opts.MaxBodySize = DefaultMaxBodySize } - return &Hook{deps: deps, notifySem: make(chan struct{}, maxConcurrentNotifications)} + return &Hook{deps: deps} } func (h *Hook) RegisterRoutes() { @@ -213,15 +209,7 @@ func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { } h.deps.Hub.Publish(token, data) - go func() { - select { - case h.notifySem <- struct{}{}: - defer func() { <-h.notifySem }() - h.sendNotifications(req, token, context.WithoutCancel(r.Context())) - default: - slog.Warn("notification queue full, dropping", "token", token) - } - }() + h.deps.Service.DispatchNotifications(r.Context(), req, token) if customResp.StatusCode > 0 { for k, v := range customResp.Headers { @@ -520,32 +508,6 @@ func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { })) } -func (h *Hook) sendNotifications(req domain.WebhookRequest, token string, ctx context.Context) { - channels, err := h.deps.Notifications.GetChannelsForHookID(ctx, req.HookID) - if err != nil || len(channels) == 0 { - return - } - - msg := fmt.Sprintf( - "📨 New webhook\nEndpoint: /r/%s\nMethod: %s\nPath: %s", - html.EscapeString(token), html.EscapeString(req.Method), html.EscapeString(req.Path), - ) - - var wg sync.WaitGroup - for _, ch := range channels { - if !ch.Enabled { - continue - } - wg.Add(1) - go func(ch domain.NotificationChannel) { - defer wg.Done() - if err := h.deps.Registry.Send(ctx, ch.Provider, ch.Config, msg); err != nil { - slog.Warn("notification failed", "provider", ch.Provider, "token", token, "err", err) - } - }(ch) - } - wg.Wait() -} func (h *Hook) readOnly(w http.ResponseWriter) bool { if !h.deps.Opts.ReadOnly { From 09046adf6bfd226f111859ef9a49f690ca5b167e Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 19:06:39 +0300 Subject: [PATCH 19/20] refactor(core): move notification dispatch use case out of HTTP layer --- internal/server/handler.go | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/server/handler.go b/internal/server/handler.go index 89b5044..b5269e9 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -508,7 +508,6 @@ func (h *Hook) TestNotification(w http.ResponseWriter, r *http.Request) { })) } - func (h *Hook) readOnly(w http.ResponseWriter) bool { if !h.deps.Opts.ReadOnly { return false From e9c7da50a019165661940f82b8cddec9a9ad0a03 Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Tue, 16 Jun 2026 19:12:27 +0300 Subject: [PATCH 20/20] fix(core): log DB error on notification channel fetch, remove stale comment --- internal/core/hook_core.go | 6 +++++- internal/notify/telegram.go | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index e660bfa..2e16b4c 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -165,7 +165,11 @@ func (s *Hook) DispatchNotifications(ctx context.Context, req domain.WebhookRequ func (s *Hook) sendNotifications(ctx context.Context, req domain.WebhookRequest, token string) { channels, err := s.repo.ListNotificationChannels(ctx, req.HookID) - if err != nil || len(channels) == 0 { + if err != nil { + slog.Warn("fetch notification channels", "hookID", req.HookID, "err", err) + return + } + if len(channels) == 0 { return } diff --git a/internal/notify/telegram.go b/internal/notify/telegram.go index 6c468c4..e4d6201 100644 --- a/internal/notify/telegram.go +++ b/internal/notify/telegram.go @@ -65,7 +65,6 @@ func sendMessage(ctx context.Context, botToken, chatID, text, proxyURL string) e } } - // text already contains HTML from the caller (handler.go escapes user data before calling Send) payload, err := json.Marshal(map[string]string{ "chat_id": chatID, "text": text,