Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
5c1d2f1
feat: add multi-provider notification system with Telegram support
GaIsBAX Jun 3, 2026
c01fba3
style: fix go fmt formatting in telegram options
GaIsBAX Jun 7, 2026
cbf2194
style: fix prettier formatting in notification UI files
GaIsBAX Jun 7, 2026
5c7035b
fix(api): redact notification secrets from GET response
GaIsBAX Jun 14, 2026
18f364a
db: collapse notification migrations into single clean migration
GaIsBAX Jun 14, 2026
bea8107
fix(api): return 404 when deleting non-existent notification channel
GaIsBAX Jun 14, 2026
2fb78ff
fix(api): validate provider and required config fields before saving
GaIsBAX Jun 14, 2026
3b7a76d
perf(notify): reuse telegram HTTP client across sends
GaIsBAX Jun 14, 2026
b72cd9f
perf(notify): reuse telegram HTTP client across sends
GaIsBAX Jun 14, 2026
fd3ca52
fix(server): bound concurrent notification goroutines with semaphore
GaIsBAX Jun 15, 2026
bbd40ce
refactor(cli): extract shared API client and fix notify list output
GaIsBAX Jun 15, 2026
dc57a8e
style: fix trailing newline in notify options
GaIsBAX Jun 15, 2026
deac378
refactor(notify): enrich Provider interface with ValidateConfig and S…
GaIsBAX Jun 15, 2026
95bc208
fix(server): initialize config map before merging secrets to prevent …
GaIsBAX Jun 15, 2026
820f671
fix(core): separate name from token on endpoint creation and remove u…
GaIsBAX Jun 16, 2026
068b1b8
refactor(server): split HookService, parallelize notification delivery
GaIsBAX Jun 16, 2026
e9946d1
refactor(notify): replace global registry with explicit Registry struct
GaIsBAX Jun 16, 2026
9db0300
refactor(core): move notification dispatch use case out of HTTP layer
GaIsBAX Jun 16, 2026
09046ad
refactor(core): move notification dispatch use case out of HTTP layer
GaIsBAX Jun 16, 2026
e9c7da5
fix(core): log DB error on notification channel fetch, remove stale c…
GaIsBAX Jun 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 16 additions & 8 deletions internal/app/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -38,15 +39,20 @@ 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

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")
Expand All @@ -63,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{
Expand Down Expand Up @@ -129,12 +135,14 @@ 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,
Hub: deps.infra.hub,
Mux: deps.mux,
Service: deps.services.hook,
Notifications: deps.services.hook,
Registry: registry,
Hub: deps.infra.hub,
Opts: server.HookOptions{
BaseURL: deps.cfg.BaseURL,
MaxBodySize: deps.cfg.MaxBodySize,
Expand Down
100 changes: 100 additions & 0 deletions internal/cli/apiclient/client.go
Original file line number Diff line number Diff line change
@@ -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
}
68 changes: 68 additions & 0 deletions internal/cli/notify/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package notify

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"
)

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 {
opts := DefaultOptions()
if cfg.SecretKey != "" {
opts.AuthToken = cfg.SecretKey
}

cmd := &cobra.Command{
Use: "notify",
Short: "Manage endpoint notifications",
}

RegisterFlags(cmd, &opts)

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, client *apiclient.Client) *cobra.Command {
return &cobra.Command{
Use: "list <token>",
Short: "List all configured notification channels",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var channels []notificationChannel
if err := client.Get(ctx, "/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 {
cmd.Printf(" %s: %s\n", k, v)
}
for _, k := range ch.Redacted {
cmd.Printf(" %s: [set]\n", k)
}
}
return nil
},
}
}
13 changes: 13 additions & 0 deletions internal/cli/notify/flags.go
Original file line number Diff line number Diff line change
@@ -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)")
}
12 changes: 12 additions & 0 deletions internal/cli/notify/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package notify

type Options struct {
Server string
AuthToken string
}

func DefaultOptions() Options {
return Options{
Server: "http://localhost:8080",
}
}
99 changes: 99 additions & 0 deletions internal/cli/notify/telegram/command.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package telegram

import (
"context"
"net/url"

"github.com/GaIsBAX/Webhix/internal/cli/apiclient"
"github.com/spf13/cobra"
)

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, client))
cmd.AddCommand(newTestCmd(ctx, client))
cmd.AddCommand(newRemoveCmd(ctx, client))

return cmd
}

func newSetCmd(ctx context.Context, client *apiclient.Client) *cobra.Command {
opts := Options{}

cmd := &cobra.Command{
Use: "set <token>",
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 := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram"
if err := client.Put(ctx, path, 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, client *apiclient.Client) *cobra.Command {
return &cobra.Command{
Use: "test <token>",
Short: "Send a test Telegram message",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram/test"
if err := client.Post(ctx, path, nil); err != nil {
return err
}

cmd.Println("Test message sent.")
return nil
},
}
}

func newRemoveCmd(ctx context.Context, client *apiclient.Client) *cobra.Command {
return &cobra.Command{
Use: "remove <token>",
Short: "Remove Telegram notifications from an endpoint",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path := "/api/endpoints/" + url.PathEscape(args[0]) + "/notifications/telegram"
if err := client.Delete(ctx, path); err != nil {
return err
}

cmd.Println("Telegram notifications removed.")
return nil
},
}
}

func must(err error) {
if err != nil {
panic(err)
}
}
15 changes: 15 additions & 0 deletions internal/cli/notify/telegram/flags.go
Original file line number Diff line number Diff line change
@@ -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)")
}
2 changes: 2 additions & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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))

Expand Down
Loading