diff --git a/README.md b/README.md index ba4fad4..bfa364d 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,11 @@ You create an endpoint, point your webhook source at it, and watch requests come Beyond just inspecting requests, you can: -- **Replay** any request with one click, optionally with edits -- **Custom responses** — configure the status code, headers, and body your endpoint returns (useful as a lightweight mock server) +- **Replay** any captured request with one click +- **Custom responses** — configure the status code, headers, and body your endpoint returns to senders (useful as a lightweight mock server) - **CLI forwarding** — pipe incoming requests to a local port: `webhix forward --to localhost:3000` -- **Export as curl or HTTPie** -- copy any request as a runnable command +- **Export as curl** — copy any request as a runnable curl command +- **Search and filter** — filter requests by text or HTTP method ## Quick start @@ -57,12 +58,21 @@ Endpoint URLs follow the pattern `https:///r/`. ## Auth -Single-user by default. Set a password via env or let Webhix generate one on first run: +Auth is required. Set at least one of: ```sh +# Basic auth password (browser login) WEBHIX_PASSWORD=yourpassword webhix serve + +# Secret key for API / CLI access (Authorization: Bearer or X-Webhix-Key header) +WEBHIX_SECRET_KEY=yourkey webhix serve + +# Both at once +webhix serve --password yourpassword --secret-key yourkey ``` +Webhook capture URLs (`/r/`) are always public — no auth required there. + ## Reverse proxy Works behind Caddy, Nginx, Traefik. Reads `X-Forwarded-*` headers automatically. Set `--base-url` or `WEBHIX_BASE_URL` to match your public domain. diff --git a/cmd/webhix/main.go b/cmd/webhix/main.go index e36899a..3e7ea30 100644 --- a/cmd/webhix/main.go +++ b/cmd/webhix/main.go @@ -7,7 +7,7 @@ import ( "os/signal" "syscall" - "github.com/GaIsBAX/Webhix/internal/cli" + "github.com/GaIsBAX/Webhix/internal/app" "github.com/GaIsBAX/Webhix/internal/config" _ "github.com/GaIsBAX/Webhix/pkg" ) @@ -26,8 +26,14 @@ func main() { os.Exit(1) } - if err := cli.Run(ctx, cfg, os.Args[1:]); err != nil { - slog.Error("app run", "err", err) + application, err := app.New(ctx, cfg) + if err != nil { + slog.Error("up app", "err", err) + os.Exit(1) + } + + if err := application.Start(ctx); err != nil { + slog.Error("start app", "err", err) os.Exit(1) } } diff --git a/go.mod b/go.mod index e267381..992a03e 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/pressly/goose/v3 v3.27.1 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -23,6 +24,5 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect ) diff --git a/internal/app/app.go b/internal/app/app.go index 89cbc51..2672e38 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,121 +2,109 @@ package app import ( "context" - "fmt" - "io/fs" + "errors" "log/slog" "net/http" "time" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/core" - "github.com/GaIsBAX/Webhix/internal/hub" "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/server/middleware" - "github.com/GaIsBAX/Webhix/internal/store" - "github.com/GaIsBAX/Webhix/internal/web" +) + +const ( + readHeaderTimeout = 5 * time.Second + shutdownTimeout = 10 * time.Second ) type App struct { - srv *http.Server - cfg *config.Config - deps *Deps - hub *hub.Hub + server *http.Server + + config *config.Config + deps *dependencies } func New(ctx context.Context, cfg *config.Config) (*App, error) { - mux := http.NewServeMux() - - deps, err := NewDeps(ctx, cfg) + deps, err := newDependencies(ctx, cfg) if err != nil { return nil, err } - eventHub := hub.New() - - hookRepository := store.NewHookRepository(deps.DB.DB) - hookService := core.NewHookService(hookRepository) - hookHandler := server.NewHookHandler(mux, hookService, eventHub, server.HookHandlerOptions{ - BaseURL: cfg.BaseURL, - MaxBodySize: cfg.MaxBodySize, - }) - - hookHandler.RegisterRoutes() - - staticSub, err := fs.Sub(web.Static, "static") + handler, err := newHTTPHandler(deps.mux, cfg) if err != nil { + if closeErr := deps.close(); closeErr != nil { + return nil, errors.Join(err, closeErr) + } return nil, err } - staticFS := http.FileServer(http.FS(staticSub)) - mux.Handle("/ui/", http.StripPrefix("/ui/", staticFS)) - mux.Handle("/", staticFS) - password, secretKey, err := resolveAuth(cfg) - if err != nil { - return nil, fmt.Errorf("auth setup: %w", err) + server := &http.Server{ + Addr: cfg.Addr, + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, } - handler := http.Handler(mux) - handler = middleware.NewAuth(password, secretKey).Protect(handler) + return &App{ + server: server, + config: cfg, + deps: deps, + }, nil +} + +func newHTTPHandler(mux *http.ServeMux, cfg *config.Config) (http.Handler, error) { + var middlewares []func(http.Handler) http.Handler if len(cfg.TrustedProxies) > 0 { trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) if trustedProxies == nil { - return nil, fmt.Errorf("invalid trusted proxies") + return nil, ErrInvalidTrustedProxies } + middlewares = append(middlewares, trustedProxies.BehindProxy) + } - handler = trustedProxies.BehindProxy(handler) + if cfg.Password != "" || cfg.SecretKey != "" { + auth := middleware.NewAuth(cfg.Password, cfg.SecretKey) + middlewares = append(middlewares, auth.Protect) } - return &App{ - srv: &http.Server{Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second}, - cfg: cfg, - deps: deps, - hub: eventHub, - }, nil + return server.Chain(mux, middlewares...), nil } func (a *App) Start(ctx context.Context) error { - errCh := make(chan error, 1) + serverErr := make(chan error, 1) go func() { - slog.Info("webhix started", "addr", a.cfg.Addr, "base_url", a.cfg.BaseURL) - if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - errCh <- err + slog.Info("webhix started", "addr", a.config.Addr, "base_url", a.config.BaseURL) + if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- err } }() select { - case err := <-errCh: + case err := <-serverErr: return err - case <-ctx.Done(): - return a.Shutdown() - } -} -func resolveAuth(cfg *config.Config) (password, secretKey string, err error) { - if cfg.Password == "" && cfg.SecretKey == "" { - return "", "", fmt.Errorf("auth is required: set WEBHIX_PASSWORD or WEBHIX_SECRET_KEY") + case <-ctx.Done(): + return a.Shutdown(ctx) } - - return cfg.Password, cfg.SecretKey, nil } -func (a *App) Shutdown() error { +func (a *App) Shutdown(ctx context.Context) error { slog.Info("shutting down") - a.hub.Close() + a.deps.infra.hub.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() - if err := a.srv.Shutdown(ctx); err != nil { + if err := a.server.Shutdown(shutdownCtx); err != nil { slog.Error("graceful shutdown failed, forcing close", "err", err) - if closeErr := a.srv.Close(); closeErr != nil { + if closeErr := a.server.Close(); closeErr != nil { slog.Error("server close failed", "err", closeErr) } } - if err := a.deps.teardownInfrastructure(); err != nil { + if err := a.deps.close(); err != nil { slog.Error("teardown error", "err", err) + return err } return nil diff --git a/internal/app/deps.go b/internal/app/deps.go index 00a1505..67e2b82 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -3,55 +3,142 @@ package app import ( "context" "errors" + "fmt" + "net/http" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/core" + "github.com/GaIsBAX/Webhix/internal/hub" + "github.com/GaIsBAX/Webhix/internal/repos" + "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/store" + "github.com/GaIsBAX/Webhix/pkg" ) -type Deps struct { - DB *store.Database +type dependencies struct { + mux *http.ServeMux cfg *config.Config + + infra *infrastructure + repos *repositories + services *services + handlers *handlers } -func NewDeps(ctx context.Context, cfg *config.Config) (*Deps, error) { - deps := &Deps{ - cfg: cfg, - } +func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { + var deps dependencies - if err := deps.setupInfrastructure(ctx); err != nil { + mux := http.NewServeMux() + + infra, err := newInfrastructure(ctx, cfg) + if err != nil { return nil, err } - return deps, nil + repos := newRepositories(infra.db) + services := newServices(repos) + + deps.mux = mux + deps.cfg = cfg + + deps.infra = infra + deps.repos = repos + deps.services = services + deps.handlers = newHandlers(&deps) + deps.handlers.registerRoutes() + + return &deps, nil } -func (d *Deps) setupInfrastructure(ctx context.Context) error { - var errs []error +type services struct { + hook *core.Hook + serve *core.Serve + version *core.Version +} - database, err := store.New(ctx, d.cfg.DBPath) - if err != nil { - errs = append(errs, err) +func newServices(repos *repositories) *services { + hook := core.NewHook(repos.hook, func() string { + return pkg.GeneratePrefixedString("ho") + }) + serve := core.NewServe(repos.serve) + version := core.NewVersion() + + return &services{ + hook: hook, + serve: serve, + version: version, } +} - d.DB = database +type repositories struct { + hook *repos.Hook + serve *repos.Serve +} - if d.DB != nil { - if err := d.DB.Migrate(); err != nil { - errs = append(errs, err) - } +func newRepositories(db *store.Database) *repositories { + return &repositories{ + hook: repos.NewHook(db.DB), + serve: repos.NewServe(db.DB), + } +} + +func (d *dependencies) close() error { + if d.infra.db != nil { + return d.infra.db.Close() } - return errors.Join(errs...) + return nil +} + +type infrastructure struct { + db *store.Database + hub *hub.Hub } -func (d *Deps) teardownInfrastructure() error { - var errs []error +func newInfrastructure(ctx context.Context, cfg *config.Config) (*infrastructure, error) { + db, err := store.New(ctx, cfg.DBPath) + if err != nil { + return nil, err + } - if d.DB != nil { - if err := d.DB.Close(); err != nil { - errs = append(errs, err) + if err := db.Migrate(); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, errors.Join( + fmt.Errorf("%w: %w", ErrMigrateDatabase, err), + fmt.Errorf("%w after migration failure: %w", ErrCloseDatabase, closeErr), + ) } + + return nil, fmt.Errorf("%w: %w", ErrMigrateDatabase, err) } - return errors.Join(errs...) + hub := hub.New() + + return &infrastructure{ + db: db, + hub: hub, + }, nil +} + +type handlers struct { + hook *server.Hook +} + +func newHandlers(deps *dependencies) *handlers { + return &handlers{ + hook: server.NewHook(&server.HookDeps{ + Mux: deps.mux, + Service: deps.services.hook, + Hub: deps.infra.hub, + Opts: server.HookOptions{ + BaseURL: deps.cfg.BaseURL, + MaxBodySize: deps.cfg.MaxBodySize, + ReadOnly: deps.cfg.ReadOnly, + }, + }), + } +} + +func (h *handlers) registerRoutes() { + h.hook.RegisterRoutes() } diff --git a/internal/app/errors.go b/internal/app/errors.go new file mode 100644 index 0000000..9c6adc9 --- /dev/null +++ b/internal/app/errors.go @@ -0,0 +1,12 @@ +package app + +import "errors" + +var ( + ErrOpenDatabase = errors.New("open database") + ErrMigrateDatabase = errors.New("migrate database") + ErrCloseDatabase = errors.New("close database") + ErrAuthSetup = errors.New("auth setup") + ErrAuthRequired = errors.New("auth is required") + ErrInvalidTrustedProxies = errors.New("invalid trusted proxies") +) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 86265b6..afe7cf6 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -3,11 +3,19 @@ package cli import ( "context" + "github.com/GaIsBAX/Webhix/internal/cli/serve" + "github.com/GaIsBAX/Webhix/internal/cli/version" "github.com/GaIsBAX/Webhix/internal/config" ) -func Run(ctx context.Context, cfg *config.Config, args []string) error { - root := NewRootCommand(ctx, cfg) +func Run( + ctx context.Context, + cfg *config.Config, + args []string, + versionService version.Service, + serveFactory serve.ServiceFactory, +) error { + root := NewRootCommand(ctx, cfg, versionService, serveFactory) root.SetArgs(args) if err := root.Execute(); err != nil { diff --git a/internal/cli/forward/command.go b/internal/cli/forward/command.go new file mode 100644 index 0000000..23fbdaf --- /dev/null +++ b/internal/cli/forward/command.go @@ -0,0 +1,31 @@ +package forward + +import ( + "context" + + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/spf13/cobra" +) + +func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { + opts := DefaultOptions() + opts.AuthToken = cfg.SecretKey + + cmd := &cobra.Command{ + Use: "forward ", + Short: "Forward incoming webhook requests to a local server", + Args: cobra.ExactArgs(1), + + RunE: func(cmd *cobra.Command, args []string) error { + opts.Token = args[0] + if err := opts.Validate(); err != nil { + return err + } + return run(ctx, opts) + }, + } + + RegisterFlags(cmd, &opts) + + return cmd +} diff --git a/internal/cli/forward/flags.go b/internal/cli/forward/flags.go new file mode 100644 index 0000000..3a6dacc --- /dev/null +++ b/internal/cli/forward/flags.go @@ -0,0 +1,23 @@ +package forward + +import "github.com/spf13/cobra" + +const ( + flagTo = "to" + flagServer = "server" + flagAuthToken = "auth-token" + flagRewriteHost = "rewrite-host" +) + +func RegisterFlags(cmd *cobra.Command, opt *Options) { + flags := cmd.Flags() + + flags.StringVar(&opt.To, flagTo, opt.To, "target URL to forward requests to") + flags.StringVar(&opt.Server, flagServer, opt.Server, "Webhix server URL") + flags.StringVar(&opt.AuthToken, flagAuthToken, opt.AuthToken, "auth token for Webhix server (env: WEBHIX_SECRET_KEY)") + flags.BoolVar(&opt.RewriteHost, flagRewriteHost, opt.RewriteHost, "rewrite Host header to match target") + + if err := cmd.MarkFlagRequired(flagTo); err != nil { + panic(err) + } +} diff --git a/internal/cli/forward/options.go b/internal/cli/forward/options.go new file mode 100644 index 0000000..1218471 --- /dev/null +++ b/internal/cli/forward/options.go @@ -0,0 +1,30 @@ +package forward + +import ( + "fmt" + "strings" +) + +type Options struct { + Token string + To string + Server string + AuthToken string + RewriteHost bool +} + +func DefaultOptions() Options { + return Options{ + Server: "http://localhost:8080", + } +} + +func (o *Options) Validate() error { + if strings.TrimSpace(o.Token) == "" { + return fmt.Errorf("token is required") + } + if strings.TrimSpace(o.To) == "" { + return fmt.Errorf("--to is required") + } + return nil +} diff --git a/internal/cli/forward/runner.go b/internal/cli/forward/runner.go new file mode 100644 index 0000000..d90c79a --- /dev/null +++ b/internal/cli/forward/runner.go @@ -0,0 +1,135 @@ +package forward + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" +) + +type webhookEvent struct { + Method string `json:"method"` + Path string `json:"path"` + Headers string `json:"headers"` + Body []byte `json:"body"` +} + +func run(ctx context.Context, opts Options) error { + targetURL := opts.To + if !strings.HasPrefix(targetURL, "http://") && !strings.HasPrefix(targetURL, "https://") { + targetURL = "http://" + targetURL + } + + sseURL := fmt.Sprintf("%s/api/endpoints/%s/events", opts.Server, opts.Token) + + slog.Info("forwarding", "token", opts.Token, "to", targetURL) + + for { + if err := stream(ctx, opts, sseURL, targetURL); err != nil { + slog.Warn("stream interrupted, reconnecting", "err", err) + } + + select { + case <-ctx.Done(): + return nil + case <-time.After(3 * time.Second): + } + } +} + +func stream(ctx context.Context, opts Options, sseURL, targetURL string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sseURL, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "text/event-stream") + if opts.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+opts.AuthToken) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close stream body", "err", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("server returned %d", resp.StatusCode) + } + + reader := bufio.NewReader(resp.Body) + for { + line, err := reader.ReadString('\n') + if err != nil { + return err + } + + line = strings.TrimRight(line, "\r\n") + if !strings.HasPrefix(line, "data: ") { + continue + } + + var event webhookEvent + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &event); err != nil { + slog.Warn("parse event", "err", err) + continue + } + + go forward(ctx, opts, event, targetURL) + } +} + +func forward(ctx context.Context, opts Options, event webhookEvent, targetURL string) { + var rawHeaders map[string][]string + if err := json.Unmarshal([]byte(event.Headers), &rawHeaders); err != nil { + slog.Warn("parse headers", "err", err) + } + + var body io.Reader + if len(event.Body) > 0 { + body = bytes.NewReader(event.Body) + } + + req, err := http.NewRequestWithContext(ctx, event.Method, targetURL, body) + if err != nil { + slog.Error("create forward request", "err", err) + return + } + + for k, vals := range rawHeaders { + for _, v := range vals { + req.Header.Add(k, v) + } + } + + if opts.RewriteHost { + if u, err := url.Parse(targetURL); err == nil { + req.Host = u.Host + } + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Error("forward failed", "method", event.Method, "path", event.Path, "err", err) + return + } + defer func() { + if err := resp.Body.Close(); err != nil { + slog.Warn("close forward body", "err", err) + } + }() + + slog.Info("forwarded", "method", event.Method, "path", event.Path, "status", resp.StatusCode) +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3d9e545..90732ee 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,21 +3,32 @@ package cli import ( "context" + "github.com/GaIsBAX/Webhix/internal/cli/forward" "github.com/GaIsBAX/Webhix/internal/cli/serve" + "github.com/GaIsBAX/Webhix/internal/cli/version" "github.com/GaIsBAX/Webhix/internal/config" "github.com/spf13/cobra" ) -func NewRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { +func NewRootCommand( + ctx context.Context, + cfg *config.Config, + versionService version.Service, + serveFactory serve.ServiceFactory, +) *cobra.Command { cmd := &cobra.Command{ Use: "webhix", SilenceUsage: true, SilenceErrors: true, + Version: versionService.Info().Version, } + cmd.SetVersionTemplate("webhix {{.Version}}\n") addGroup(cmd, serve.ServeGroup, serve.ServeTitle) - cmd.AddCommand(serve.NewCommand(ctx, cfg)) + cmd.AddCommand(serve.NewCommand(ctx, cfg, serveFactory)) + cmd.AddCommand(forward.NewCommand(ctx, cfg)) + cmd.AddCommand(version.NewCommand(ctx, versionService)) return cmd } diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 307ae66..d3b04ea 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -4,8 +4,8 @@ import ( "context" "log/slog" - "github.com/GaIsBAX/Webhix/internal/app" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/core" "github.com/spf13/cobra" ) @@ -14,7 +14,21 @@ const ( ServeTitle = "" ) -func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { +type Service interface { + Run(ctx context.Context, opts core.ServeRunOptions, start core.ServeStartFunc, onRetentionError func(error)) error +} + +type ServiceFactory interface { + New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) +} + +type ServiceFactoryFunc func(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) + +func (f ServiceFactoryFunc) New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) { + return f(ctx, cfg) +} + +func NewCommand(ctx context.Context, cfg *config.Config, factory ServiceFactory) *cobra.Command { opts := DefaultOptions() cmd := &cobra.Command{ @@ -26,13 +40,13 @@ func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return err } - app, err := app.New(ctx, cfg) + service, start, err := factory.New(ctx, cfg) if err != nil { slog.Error("init app", "err", err) return err } - return run(ctx, app) + return run(ctx, service, start, cfg, opts) }, } @@ -40,12 +54,3 @@ func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return cmd } - -func run(ctx context.Context, app *app.App) error { - if err := app.Start(ctx); err != nil { - slog.Error("server", "err", err) - return err - } - - return nil -} diff --git a/internal/cli/serve/flags.go b/internal/cli/serve/flags.go index 9bb6c78..c7125c1 100644 --- a/internal/cli/serve/flags.go +++ b/internal/cli/serve/flags.go @@ -15,6 +15,10 @@ const ( flagMaxBodySize = "max-body-size" flagTrustedProxies = "trusted-proxies" + + flagRetention = "retention" + flagMaxRequests = "max-requests" + flagReadonly = "readonly" ) func RegisterFlags(cmd *cobra.Command, cfg *config.Config, opt *Options) { @@ -26,6 +30,12 @@ func RegisterFlags(cmd *cobra.Command, cfg *config.Config, opt *Options) { flags.StringVar(&cfg.BaseURL, flagBaseURL, cfg.BaseURL, "public base URL used for endpoint links") flags.StringVar(&cfg.Password, flagPassword, cfg.Password, "basic auth password (env: WEBHIX_PASSWORD)") flags.StringVar(&cfg.SecretKey, flagSecretKey, cfg.SecretKey, "API secret key via X-Webhix-Key or Bearer (env: WEBHIX_SECRET_KEY)") + flags.Int64Var(&cfg.MaxBodySize, flagMaxBodySize, cfg.MaxBodySize, "maximum webhook request body size in bytes") + flags.Int64Var(&cfg.MaxRequests, flagMaxRequests, cfg.MaxRequests, "TODO") + flags.BoolVar(&cfg.ReadOnly, flagReadonly, cfg.ReadOnly, "start Webhix in read-only mode") + flags.StringSliceVar(&cfg.TrustedProxies, flagTrustedProxies, cfg.TrustedProxies, "trusted proxy CIDRs") + + flags.DurationVar(&opt.Retention, flagRetention, opt.Retention, "TODO") } diff --git a/internal/cli/serve/options.go b/internal/cli/serve/options.go index fba5fb1..d3c1702 100644 --- a/internal/cli/serve/options.go +++ b/internal/cli/serve/options.go @@ -4,14 +4,19 @@ import ( "fmt" "net/url" "strings" + "time" "github.com/GaIsBAX/Webhix/internal/config" ) -type Options struct{} +type Options struct { + Retention time.Duration +} func DefaultOptions() Options { - return Options{} + return Options{ + Retention: time.Hour * 24, + } } func (o *Options) Validate(cfg *config.Config) error { diff --git a/internal/cli/serve/runner.go b/internal/cli/serve/runner.go new file mode 100644 index 0000000..da04e0d --- /dev/null +++ b/internal/cli/serve/runner.go @@ -0,0 +1,18 @@ +package serve + +import ( + "context" + "log/slog" + + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/core" +) + +func run(ctx context.Context, service Service, start core.ServeStartFunc, cfg *config.Config, opts Options) error { + return service.Run(ctx, core.ServeRunOptions{ + Retention: opts.Retention, + ReadOnly: cfg.ReadOnly, + }, start, func(err error) { + slog.Error("retention cleaner", "err", err) + }) +} diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go new file mode 100644 index 0000000..76fcc30 --- /dev/null +++ b/internal/cli/version/command.go @@ -0,0 +1,78 @@ +package version + +import ( + "context" + "encoding/json" + "fmt" + "io" + + "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type Service interface { + Info() domain.VersionInfo +} + +type versionInfoContract struct { + Version string `json:"version" yaml:"version"` + Commit string `json:"commit" yaml:"commit"` + Built string `json:"built" yaml:"built"` + Go string `json:"go" yaml:"go"` +} + +func NewCommand(ctx context.Context, service Service) *cobra.Command { + opts := NewOptions() + + cmd := &cobra.Command{ + Use: "version", + Short: "Print version information", + RunE: func(cmd *cobra.Command, args []string) error { + if err := opts.Validate(); err != nil { + return err + } + + return print(cmd.OutOrStdout(), service.Info(), opts.Output) + }, + } + + RegisterFlags(cmd, opts) + + return cmd +} + +func print(w io.Writer, info domain.VersionInfo, output string) error { + switch output { + case outputJSON: + encoder := json.NewEncoder(w) + return encoder.Encode(toContract(info)) + + case outputYAML: + encoder := yaml.NewEncoder(w) + if err := encoder.Encode(toContract(info)); err != nil { + return err + } + return encoder.Close() + + default: + _, err := fmt.Fprintf( + w, + "webhix %s\ncommit: %s\nbuilt: %s\ngo: %s\n", + info.Version, + info.Commit, + info.Built, + info.Go, + ) + return err + } +} + +func toContract(info domain.VersionInfo) versionInfoContract { + return versionInfoContract{ + Version: info.Version, + Commit: info.Commit, + Built: info.Built, + Go: info.Go, + } +} diff --git a/internal/cli/version/flags.go b/internal/cli/version/flags.go new file mode 100644 index 0000000..510c544 --- /dev/null +++ b/internal/cli/version/flags.go @@ -0,0 +1,9 @@ +package version + +import "github.com/spf13/cobra" + +const flagOutput = "output" + +func RegisterFlags(cmd *cobra.Command, opts *Options) { + cmd.Flags().StringVar(&opts.Output, flagOutput, opts.Output, "output format: text, json, or yaml") +} diff --git a/internal/cli/version/options.go b/internal/cli/version/options.go new file mode 100644 index 0000000..cd870ee --- /dev/null +++ b/internal/cli/version/options.go @@ -0,0 +1,28 @@ +package version + +import "fmt" + +const ( + outputText = "text" + outputJSON = "json" + outputYAML = "yaml" +) + +type Options struct { + Output string +} + +func NewOptions() *Options { + return &Options{ + Output: outputText, + } +} + +func (o Options) Validate() error { + switch o.Output { + case outputText, outputJSON, outputYAML: + return nil + default: + return fmt.Errorf("unsupported output format %q (want text, json, or yaml)", o.Output) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index e4d25d4..d5bd993 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,6 +18,8 @@ type Config struct { SecretKey string `env:"WEBHIX_SECRET_KEY"` TrustedProxies []string `env:"WEBHIX_TRUSTED_PROXIES"` + MaxRequests int64 `env:"MAX_REQUESTS" env-default:"10000"` + ReadOnly bool `env:"WEBHIX_READONLY"` } func LoadConfig() (*Config, error) { diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index e539ab9..5457319 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -2,11 +2,15 @@ package core import ( "context" + "errors" "github.com/GaIsBAX/Webhix/internal/domain" - "github.com/GaIsBAX/Webhix/pkg" ) +const defaultHookResponseStatusCode int64 = 200 + +type TokenGenerator func() string + type HookRepository interface { CreateHook(ctx context.Context, token string) (domain.Hook, error) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) @@ -16,25 +20,31 @@ type HookRepository interface { UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookService struct { - repo HookRepository +type Hook struct { + repo HookRepository + generateToken TokenGenerator } -func NewHookService(repo HookRepository) *HookService { - return &HookService{ - repo: repo, +func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { + if generateToken == nil { + generateToken = func() string { return "" } + } + + return &Hook{ + repo: repo, + generateToken: generateToken, } } -func (s *HookService) CreateHook(ctx context.Context, token string) (domain.Hook, error) { +func (s *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { if token == "" { - token = pkg.GeneratePrefixedString("ho") + token = s.generateToken() } return s.repo.CreateHook(ctx, token) } -func (s *HookService) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) { +func (s *Hook) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.WebhookRequest{}, domain.HookResponse{}, err @@ -49,13 +59,16 @@ func (s *HookService) ReceiveWebhook(ctx context.Context, token string, params d resp, err := s.repo.GetHookResponse(ctx, hook.ID) if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return req, defaultHookResponse(), nil + } return domain.WebhookRequest{}, domain.HookResponse{}, err } return req, resp, nil } -func (s *HookService) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) { +func (s *Hook) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return nil, err @@ -64,16 +77,24 @@ func (s *HookService) ListWebhookRequests(ctx context.Context, token string) ([] return s.repo.ListWebhookRequests(ctx, hook.ID) } -func (s *HookService) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) { +func (s *Hook) GetHookResponse(ctx context.Context, token string) (domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.HookResponse{}, err } - return s.repo.GetHookResponse(ctx, hook.ID) + resp, err := s.repo.GetHookResponse(ctx, hook.ID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return defaultHookResponse(), nil + } + return domain.HookResponse{}, err + } + + return resp, nil } -func (s *HookService) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { +func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { hook, err := s.repo.GetHookByToken(ctx, token) if err != nil { return domain.HookResponse{}, err @@ -81,3 +102,10 @@ func (s *HookService) SetHookResponse(ctx context.Context, token string, params return s.repo.UpsertHookResponse(ctx, hook.ID, params) } + +func defaultHookResponse() domain.HookResponse { + return domain.HookResponse{ + StatusCode: defaultHookResponseStatusCode, + Headers: map[string]string{}, + } +} diff --git a/internal/core/serve_core.go b/internal/core/serve_core.go new file mode 100644 index 0000000..a4f397b --- /dev/null +++ b/internal/core/serve_core.go @@ -0,0 +1,85 @@ +package core + +import ( + "context" + "errors" + "time" +) + +type ServeRunOptions struct { + Retention time.Duration + ReadOnly bool +} + +type ServeStartFunc func(context.Context) error + +type ServeRepository interface { + DeleteWebhookRequestsOlderThan(ctx context.Context, retention time.Duration) (int64, error) + GetCountRequests(ctx context.Context) (int64, error) +} + +type Serve struct { + repo ServeRepository +} + +func NewServe(repo ServeRepository) *Serve { + return &Serve{ + repo: repo, + } +} + +func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) (int64, error) { + cleanup := func() (int64, error) { + return s.repo.DeleteWebhookRequestsOlderThan(ctx, retention) + } + + _, err := cleanup() + if err != nil { + return 0, err + } + + ticker := time.NewTicker(time.Hour * 24) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if _, err := cleanup(); err != nil { + return 0, err + } + + case <-ctx.Done(): + return 0, ctx.Err() + } + } +} + +func (s *Serve) Run(ctx context.Context, opts ServeRunOptions, start ServeStartFunc, onRetentionError func(error)) error { + s.StartRetentionCleaner(ctx, opts, onRetentionError) + return start(ctx) +} + +func (s *Serve) StartRetentionCleaner(ctx context.Context, opts ServeRunOptions, onError func(error)) { + if opts.Retention <= 0 || opts.ReadOnly { + return + } + + go func() { + if _, err := s.RetentionCleaner(ctx, opts.Retention); err != nil && onError != nil { + onError(err) + } + }() +} + +func (s *Serve) RequestLimitGuard(ctx context.Context, limit int64) error { + count, err := s.repo.GetCountRequests(ctx) + if err != nil { + return err + } + + if count > limit { + return errors.New("request: rate limit exceeded") + } + + return nil +} diff --git a/internal/core/version_core.go b/internal/core/version_core.go new file mode 100644 index 0000000..ae5b5e9 --- /dev/null +++ b/internal/core/version_core.go @@ -0,0 +1,28 @@ +package core + +import ( + "runtime" + + "github.com/GaIsBAX/Webhix/internal/domain" +) + +var ( + WebhixVersion = "unknown" + Commit = "unknown" + Built = "unknown" +) + +type Version struct{} + +func NewVersion() *Version { + return &Version{} +} + +func (v *Version) Info() domain.VersionInfo { + return domain.VersionInfo{ + Version: WebhixVersion, + Commit: Commit, + Built: Built, + Go: runtime.Version(), + } +} diff --git a/internal/domain/hook.go b/internal/domain/hook.go index 38096ed..98911e2 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -6,35 +6,35 @@ import ( ) type Hook struct { - ID int64 `json:"id"` - Token string `json:"token"` - Name string `json:"name,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID int64 + Token string + Name string + CreatedAt time.Time + UpdatedAt time.Time } type WebhookRequest struct { - ID int64 `json:"id"` - HookID int64 `json:"hookId"` - Method string `json:"method"` - Path string `json:"path"` - Query string `json:"query,omitempty"` - Headers string `json:"headers"` - Body []byte `json:"body,omitempty"` - RemoteAddr string `json:"remoteAddr,omitempty"` - ContentType string `json:"contentType,omitempty"` - BodySize int64 `json:"bodySize"` - ReceivedAt time.Time `json:"receivedAt"` + ID int64 + HookID int64 + Method string + Path string + Query string + Headers string + Body []byte + RemoteAddr string + ContentType string + BodySize int64 + ReceivedAt time.Time } type HookResponse struct { - ID int64 `json:"id"` - HookID int64 `json:"hookId"` - StatusCode int64 `json:"statusCode"` - Headers map[string]string `json:"headers"` - Body []byte `json:"body,omitempty"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID int64 + HookID int64 + StatusCode int64 + Headers map[string]string + Body []byte + CreatedAt time.Time + UpdatedAt time.Time } type CreateWebhookRequestParams struct { diff --git a/internal/domain/version.go b/internal/domain/version.go new file mode 100644 index 0000000..d44961d --- /dev/null +++ b/internal/domain/version.go @@ -0,0 +1,8 @@ +package domain + +type VersionInfo struct { + Version string + Commit string + Built string + Go string +} diff --git a/internal/store/repository.go b/internal/repos/hook.go similarity index 77% rename from internal/store/repository.go rename to internal/repos/hook.go index fde950e..5cf2973 100644 --- a/internal/store/repository.go +++ b/internal/repos/hook.go @@ -1,4 +1,4 @@ -package store +package repos import ( "context" @@ -10,17 +10,17 @@ import ( "github.com/GaIsBAX/Webhix/internal/store/sqlc" ) -type HookRepository struct { +type Hook struct { q *sqlc.Queries } -func NewHookRepository(db sqlc.DBTX) *HookRepository { - return &HookRepository{ +func NewHook(db sqlc.DBTX) *Hook { + return &Hook{ q: sqlc.New(db), } } -func (r *HookRepository) CreateHook(ctx context.Context, token string) (domain.Hook, error) { +func (r *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { hook, err := r.q.CreateHook(ctx, sqlc.CreateHookParams{ Token: token, Name: sql.NullString{}, @@ -32,7 +32,7 @@ func (r *HookRepository) CreateHook(ctx context.Context, token string) (domain.H return toDomainHook(hook), nil } -func (r *HookRepository) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) { +func (r *Hook) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) { hook, err := r.q.GetHookByToken(ctx, token) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -44,7 +44,7 @@ func (r *HookRepository) GetHookByToken(ctx context.Context, token string) (doma return toDomainHook(hook), nil } -func (r *HookRepository) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) { +func (r *Hook) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) { req, err := r.q.CreateWebhookRequest(ctx, sqlc.CreateWebhookRequestParams{ HookID: params.HookID, @@ -64,7 +64,7 @@ func (r *HookRepository) CreateWebhookRequest(ctx context.Context, params domain return toDomainWebhookRequest(req), nil } -func (r *HookRepository) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) { +func (r *Hook) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) { rows, err := r.q.ListWebhookRequestsByHookID(ctx, hookID) if err != nil { return nil, err @@ -78,11 +78,11 @@ func (r *HookRepository) ListWebhookRequests(ctx context.Context, hookID int64) return result, nil } -func (r *HookRepository) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) { +func (r *Hook) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) { row, err := r.q.GetHookResponseByHookID(ctx, hookID) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return domain.HookResponse{StatusCode: 200, Headers: map[string]string{}}, nil + return domain.HookResponse{}, domain.ErrNotFound } return domain.HookResponse{}, err } @@ -90,7 +90,7 @@ func (r *HookRepository) GetHookResponse(ctx context.Context, hookID int64) (dom return toDomainHookResponse(row), nil } -func (r *HookRepository) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { +func (r *Hook) UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { headersJSON, err := json.Marshal(params.Headers) if err != nil { return domain.HookResponse{}, err diff --git a/internal/repos/serve.go b/internal/repos/serve.go new file mode 100644 index 0000000..96da35d --- /dev/null +++ b/internal/repos/serve.go @@ -0,0 +1,41 @@ +package repos + +import ( + "context" + "time" + + "github.com/GaIsBAX/Webhix/internal/store/sqlc" +) + +type Serve struct { + q *sqlc.Queries +} + +func NewServe(db sqlc.DBTX) *Serve { + return &Serve{ + q: sqlc.New(db), + } +} + +func (r *Serve) DeleteWebhookRequestsOlderThan(ctx context.Context, retention time.Duration) (int64, error) { + res, err := r.q.DeleteWebhookRequestsOlderThan(ctx, retention) + if err != nil { + return 0, err + } + + affected, err := res.RowsAffected() + if err != nil { + return 0, err + } + + return affected, nil +} + +func (r *Serve) GetCountRequests(ctx context.Context) (int64, error) { + count, err := r.q.GetCountRequests(ctx) + if err != nil { + return 0, err + } + + return count, nil +} diff --git a/internal/server/errors.go b/internal/server/errors.go index f352282..37b812a 100644 --- a/internal/server/errors.go +++ b/internal/server/errors.go @@ -9,6 +9,7 @@ const ( ErrCodeInternal = "INTERNAL_ERROR" ErrCodeBadRequest = "BAD_REQUEST" ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE" + ErrCodeReadOnly = "READ_ONLY" ) var ( @@ -51,4 +52,9 @@ var ( Code: ErrCodePayloadTooLarge, Message: "Payload too large", } + + ErrReadOnly = ErrorContract{ + Code: ErrCodeReadOnly, + Message: "Read-only mode is enabled", + } ) diff --git a/internal/server/handler.go b/internal/server/handler.go index d631154..9f91d69 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -10,7 +10,6 @@ import ( "net/http" "github.com/GaIsBAX/Webhix/internal/domain" - "github.com/GaIsBAX/Webhix/internal/hub" ) const DefaultMaxBodySize int64 = 5 << 20 // 5MB @@ -23,50 +22,59 @@ type HookService interface { SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookHandlerOptions struct { +type EventBroker interface { + Done() <-chan struct{} + Subscribe(token string) (<-chan []byte, func()) + Publish(token string, data []byte) +} + +type HookOptions struct { BaseURL string MaxBodySize int64 + ReadOnly bool } -type HookHandler struct { - mux *http.ServeMux - service HookService - hub *hub.Hub +type HookDeps struct { + Mux *http.ServeMux + Service HookService + Hub EventBroker + Opts HookOptions +} - opts HookHandlerOptions +type Hook struct { + deps *HookDeps } -func NewHookHandler(mux *http.ServeMux, srv HookService, hub *hub.Hub, opts HookHandlerOptions) *HookHandler { - if opts.MaxBodySize <= 0 { - opts.MaxBodySize = DefaultMaxBodySize +func NewHook(deps *HookDeps) *Hook { + if deps.Opts.MaxBodySize <= 0 { + deps.Opts.MaxBodySize = DefaultMaxBodySize } - return &HookHandler{ - mux: mux, - service: srv, - hub: hub, - opts: opts, - } + return &Hook{deps: deps} } -func (h *HookHandler) RegisterRoutes() { - h.mux.HandleFunc("POST /api/endpoints", h.CreateEndpoint) - h.mux.HandleFunc("GET /api/endpoints/{token}/requests", h.ListRequests) - h.mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) - h.mux.HandleFunc("GET /api/endpoints/{token}/response", h.GetResponse) - h.mux.HandleFunc("PUT /api/endpoints/{token}/response", h.SetResponse) - h.mux.HandleFunc("/r/{token}", h.ReceiveWebhook) +func (h *Hook) RegisterRoutes() { + h.deps.Mux.HandleFunc("POST /api/endpoints", h.CreateEndpoint) + h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/requests", h.ListRequests) + 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("/r/{token}", h.ReceiveWebhook) } -func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { - contract, err := DecodeContract[CreateEndpointRequestContract](r) +func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + + contract, err := DecodeRequest[CreateEndpointRequestContract](r) if err != nil { slog.Error("create endpoint", "err", err) SendError(w, http.StatusInternalServerError, ErrInternal) return } - hook, err := h.service.CreateHook(r.Context(), contract.Name) + hook, err := h.deps.Service.CreateHook(r.Context(), contract.Name) if err != nil { slog.Error("create endpoint", "err", err) SendError(w, http.StatusInternalServerError, ErrInternal) @@ -77,7 +85,7 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { ID: hook.ID, Token: hook.Token, Name: hook.Name, - URL: h.opts.BaseURL + "/r/" + hook.Token, + URL: h.deps.Opts.BaseURL + "/r/" + hook.Token, }) if err != nil { slog.Error("marshal endpoint", "err", err) @@ -88,7 +96,11 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusCreated, data) } -func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { +func (h *Hook) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + token := r.PathValue("token") headersJSON, err := json.Marshal(r.Header) @@ -98,14 +110,14 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - r.Body = http.MaxBytesReader(w, r.Body, h.opts.MaxBodySize) + r.Body = http.MaxBytesReader(w, r.Body, h.deps.Opts.MaxBodySize) body, err := io.ReadAll(r.Body) if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { SendError(w, http.StatusRequestEntityTooLarge, WithDetails(ErrPayloadTooLarge, ErrorDetailContract{ Field: "body", - Message: fmt.Sprintf("body exceeds %d bytes limit", h.opts.MaxBodySize), + Message: fmt.Sprintf("body exceeds %d bytes limit", h.deps.Opts.MaxBodySize), })) return } @@ -117,7 +129,7 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - req, customResp, err := h.service.ReceiveWebhook(r.Context(), token, domain.CreateWebhookRequestParams{ + req, customResp, err := h.deps.Service.ReceiveWebhook(r.Context(), token, domain.CreateWebhookRequestParams{ Method: r.Method, Path: r.URL.Path, Query: r.URL.RawQuery, @@ -144,7 +156,7 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { return } - h.hub.Publish(token, data) + h.deps.Hub.Publish(token, data) if customResp.StatusCode > 0 { for k, v := range customResp.Headers { @@ -162,10 +174,10 @@ func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) ListRequests(w http.ResponseWriter, r *http.Request) { +func (h *Hook) ListRequests(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - reqs, err := h.service.ListWebhookRequests(r.Context(), token) + reqs, err := h.deps.Service.ListWebhookRequests(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -191,7 +203,7 @@ func (h *HookHandler) ListRequests(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { +func (h *Hook) StreamEvents(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") w.Header().Set("Content-Type", "text/event-stream") @@ -204,7 +216,7 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { f.Flush() } - ch, unsubscribe := h.hub.Subscribe(token) + ch, unsubscribe := h.deps.Hub.Subscribe(token) defer unsubscribe() flusher, canFlush := w.(http.Flusher) @@ -213,8 +225,10 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { select { case <-r.Context().Done(): return - case <-h.hub.Done(): + + case <-h.deps.Hub.Done(): return + case data := <-ch: if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return @@ -226,10 +240,10 @@ func (h *HookHandler) StreamEvents(w http.ResponseWriter, r *http.Request) { } } -func (h *HookHandler) GetResponse(w http.ResponseWriter, r *http.Request) { +func (h *Hook) GetResponse(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - resp, err := h.service.GetHookResponse(r.Context(), token) + resp, err := h.deps.Service.GetHookResponse(r.Context(), token) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -249,10 +263,14 @@ func (h *HookHandler) GetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { +func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + token := r.PathValue("token") - contract, err := DecodeContract[SetHookResponseRequestContract](r) + contract, err := DecodeRequest[SetHookResponseRequestContract](r) if err != nil { SendError(w, http.StatusBadRequest, ErrBadRequest) return @@ -271,7 +289,7 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { return } - resp, err := h.service.SetHookResponse(r.Context(), token, params) + resp, err := h.deps.Service.SetHookResponse(r.Context(), token, params) if err != nil { if errors.Is(err, domain.ErrNotFound) { SendError(w, http.StatusNotFound, ErrNotFound) @@ -290,3 +308,12 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } + +func (h *Hook) readOnly(w http.ResponseWriter) bool { + if !h.deps.Opts.ReadOnly { + return false + } + + SendError(w, http.StatusForbidden, ErrReadOnly) + return true +} diff --git a/internal/server/helpers.go b/internal/server/helpers.go index 8e3d869..b7eb0a9 100644 --- a/internal/server/helpers.go +++ b/internal/server/helpers.go @@ -22,7 +22,7 @@ func SendError(w http.ResponseWriter, status int, msg ErrorContract) { Send(w, status, NewErrorResponseContract(msg)) } -func DecodeContract[T any](req *http.Request) (*T, error) { +func DecodeRequest[T any](req *http.Request) (*T, error) { var body T if err := json.NewDecoder(req.Body).Decode(&body); err != nil { diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index b7e5d4f..5672d69 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -8,6 +8,10 @@ SELECT id, token, name, created_at, updated_at FROM hooks WHERE token = ?; +-- name: GetCountRequests :one +SELECT COUNT(*) +FROM webhook_requests; + -- name: CreateWebhookRequest :one INSERT INTO webhook_requests ( hook_id, @@ -29,6 +33,29 @@ FROM webhook_requests WHERE hook_id = ? ORDER BY received_at DESC, id DESC; +-- name: ListWebhookRequestsByTime :many +SELECT + wr.id, + wr.hook_id, + h.token, + h.name, + wr.method, + wr.path, + wr.query, + wr.headers, + wr.remote_addr, + wr.content_type, + wr.body_size, + wr.received_at +FROM webhook_requests wr +JOIN hooks h ON h.id = wr.hook_id +WHERE wr.received_at <= datetime('now', ?) +ORDER BY wr.received_at DESC; + +-- name: DeleteWebhookRequestsOlderThan :execresult +DELETE FROM webhook_requests +WHERE received_at < datetime('now', ?); + -- name: UpsertHookResponse :one INSERT INTO hook_responses (hook_id, status_code, headers, body) VALUES (?, ?, ?, ?) diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 23579b7..3d7a6c5 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -8,6 +8,7 @@ package sqlc import ( "context" "database/sql" + "time" ) const createHook = `-- name: CreateHook :one @@ -91,6 +92,27 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq return i, err } +const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult +DELETE FROM webhook_requests +WHERE received_at < datetime('now', ?) +` + +func (q *Queries) DeleteWebhookRequestsOlderThan(ctx context.Context, datetime interface{}) (sql.Result, error) { + return q.db.ExecContext(ctx, deleteWebhookRequestsOlderThan, datetime) +} + +const getCountRequests = `-- name: GetCountRequests :one +SELECT COUNT(*) +FROM webhook_requests +` + +func (q *Queries) GetCountRequests(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, getCountRequests) + var count int64 + err := row.Scan(&count) + return count, err +} + const getHookByToken = `-- name: GetHookByToken :one SELECT id, token, name, created_at, updated_at FROM hooks @@ -173,6 +195,77 @@ func (q *Queries) ListWebhookRequestsByHookID(ctx context.Context, hookID int64) return items, nil } +const listWebhookRequestsByTime = `-- name: ListWebhookRequestsByTime :many +SELECT + wr.id, + wr.hook_id, + h.token, + h.name, + wr.method, + wr.path, + wr.query, + wr.headers, + wr.remote_addr, + wr.content_type, + wr.body_size, + wr.received_at +FROM webhook_requests wr +JOIN hooks h ON h.id = wr.hook_id +WHERE wr.received_at <= datetime('now', ?) +ORDER BY wr.received_at DESC +` + +type ListWebhookRequestsByTimeRow struct { + ID int64 `json:"id"` + HookID int64 `json:"hook_id"` + Token string `json:"token"` + Name sql.NullString `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Headers string `json:"headers"` + RemoteAddr sql.NullString `json:"remote_addr"` + ContentType sql.NullString `json:"content_type"` + BodySize int64 `json:"body_size"` + ReceivedAt time.Time `json:"received_at"` +} + +func (q *Queries) ListWebhookRequestsByTime(ctx context.Context, datetime interface{}) ([]ListWebhookRequestsByTimeRow, error) { + rows, err := q.db.QueryContext(ctx, listWebhookRequestsByTime, datetime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListWebhookRequestsByTimeRow + for rows.Next() { + var i ListWebhookRequestsByTimeRow + if err := rows.Scan( + &i.ID, + &i.HookID, + &i.Token, + &i.Name, + &i.Method, + &i.Path, + &i.Query, + &i.Headers, + &i.RemoteAddr, + &i.ContentType, + &i.BodySize, + &i.ReceivedAt, + ); 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 upsertHookResponse = `-- name: UpsertHookResponse :one INSERT INTO hook_responses (hook_id, status_code, headers, body) VALUES (?, ?, ?, ?) diff --git a/internal/web/ui/index.html b/internal/web/ui/index.html index 6f712ec..a734f00 100644 --- a/internal/web/ui/index.html +++ b/internal/web/ui/index.html @@ -126,9 +126,13 @@

Endpoint

- - - + +
diff --git a/internal/web/ui/src/app/dom.ts b/internal/web/ui/src/app/dom.ts index ed0b568..0b03dbf 100644 --- a/internal/web/ui/src/app/dom.ts +++ b/internal/web/ui/src/app/dom.ts @@ -30,6 +30,8 @@ export function getElements() { newEndpointButton: requireElement('newEndpointButton', HTMLButtonElement), loadTokenButton: requireElement('loadTokenButton', HTMLButtonElement), createEndpointButton: requireElement('createEndpointButton', HTMLButtonElement), + searchInput: requireElement('searchInput', HTMLInputElement), + methodFilterButton: requireElement('methodFilter', HTMLButtonElement), tabButtons: Array.from(document.querySelectorAll('.tab[data-tab]')), }; } diff --git a/internal/web/ui/src/app/main.ts b/internal/web/ui/src/app/main.ts index eabc52e..19cdbba 100644 --- a/internal/web/ui/src/app/main.ts +++ b/internal/web/ui/src/app/main.ts @@ -15,6 +15,9 @@ import { selectRequest, selectedRequest, setActiveTab, + setMethodFilter, + setSearchQuery, + uniqueMethods, } from '../entities/request/model/request-state'; import type { RequestTab } from '../entities/request/model/types'; import { buildCurlCommand } from '../shared/lib/format'; @@ -44,6 +47,19 @@ function init(): void { elements.loadTokenButton.addEventListener('click', loadToken); elements.createEndpointButton.addEventListener('click', createNewEndpoint); elements.requestList.addEventListener('click', handleRequestClick); + elements.searchInput.addEventListener('input', () => { + setSearchQuery(state, elements.searchInput.value); + renderRequestList(elements, state); + }); + elements.methodFilterButton.addEventListener('click', () => { + const methods = uniqueMethods(state); + const current = state.methodFilter; + const idx = current ? methods.indexOf(current) : -1; + const next = methods[idx + 1] ?? null; + setMethodFilter(state, next); + elements.methodFilterButton.textContent = next ?? 'All Methods'; + renderRequestList(elements, state); + }); for (const button of elements.tabButtons) { button.addEventListener('click', () => { @@ -99,7 +115,7 @@ async function createNewEndpoint(): Promise { async function loadHistory(token: string): Promise { try { const requests = await fetchRequests(token); - addRequests(state, requests.reverse()); + addRequests(state, requests); renderRequestList(elements, state); } catch { toast('Failed to load request history'); @@ -156,8 +172,8 @@ function replayRequest(): void { } let body: BodyInit | undefined; - if (request.body) { - body = typeof request.body === 'string' ? request.body : JSON.stringify(request.body); + if (request.body && typeof request.body === 'string') { + body = Uint8Array.from(atob(request.body), (c) => c.charCodeAt(0)); } elements.replayButton.disabled = true; diff --git a/internal/web/ui/src/entities/request/model/request-state.test.ts b/internal/web/ui/src/entities/request/model/request-state.test.ts index 06601c1..6fc660f 100644 --- a/internal/web/ui/src/entities/request/model/request-state.test.ts +++ b/internal/web/ui/src/entities/request/model/request-state.test.ts @@ -6,45 +6,41 @@ import { addRequests, createInitialState, resetForToken, selectRequest } from '. test('addRequests prepends live requests and ignores duplicate ids', () => { const state = createInitialState(); - addRequests(state, [ - { id: 'old', method: 'POST', path: '/old', receivedAt: '2026-05-16T10:00:00Z' }, - ]); + addRequests(state, [{ id: 1, method: 'POST', path: '/old', receivedAt: '2026-05-16T10:00:00Z' }]); addRequests( state, - [{ id: 'new', method: 'POST', path: '/new', receivedAt: '2026-05-16T11:00:00Z' }], + [{ id: 2, method: 'POST', path: '/new', receivedAt: '2026-05-16T11:00:00Z' }], { prepend: true }, ); addRequests( state, - [{ id: 'new', method: 'POST', path: '/new', receivedAt: '2026-05-16T11:00:00Z' }], + [{ id: 2, method: 'POST', path: '/new', receivedAt: '2026-05-16T11:00:00Z' }], { prepend: true }, ); assert.deepEqual( state.requests.map((request) => request.id), - ['new', 'old'], + [2, 1], ); }); test('selectRequest stores request id instead of list index', () => { const state = createInitialState(); addRequests(state, [ - { id: 'first', method: 'POST', path: '/first', receivedAt: '2026-05-16T10:00:00Z' }, - { id: 'second', method: 'POST', path: '/second', receivedAt: '2026-05-16T11:00:00Z' }, + { id: 1, method: 'POST', path: '/first', receivedAt: '2026-05-16T10:00:00Z' }, + { id: 2, method: 'POST', path: '/second', receivedAt: '2026-05-16T11:00:00Z' }, ]); - selectRequest(state, 'second'); + selectRequest(state, '2'); - assert.equal(state.selectedRequestId, 'second'); + assert.equal(state.selectedRequestId, '2'); }); test('resetForToken clears request-specific state and keeps the active tab', () => { const state = createInitialState(); state.activeTab = 'body'; - addRequests(state, [ - { id: 'old', method: 'POST', path: '/old', receivedAt: '2026-05-16T10:00:00Z' }, - ]); - selectRequest(state, 'old'); + addRequests(state, [{ id: 1, method: 'POST', path: '/old', receivedAt: '2026-05-16T10:00:00Z' }]); + selectRequest(state, '1'); resetForToken(state, 'token-123'); diff --git a/internal/web/ui/src/entities/request/model/request-state.ts b/internal/web/ui/src/entities/request/model/request-state.ts index 788d11f..613ec04 100644 --- a/internal/web/ui/src/entities/request/model/request-state.ts +++ b/internal/web/ui/src/entities/request/model/request-state.ts @@ -5,7 +5,9 @@ export interface AppState { requests: WebhookRequest[]; selectedRequestId: string | null; activeTab: RequestTab; - seenIds: Set; + seenIds: Set; + searchQuery: string; + methodFilter: string | null; } export function createInitialState(): AppState { @@ -14,7 +16,9 @@ export function createInitialState(): AppState { requests: [], selectedRequestId: null, activeTab: 'headers', - seenIds: new Set(), + seenIds: new Set(), + searchQuery: '', + methodFilter: null, }; } @@ -22,7 +26,9 @@ export function resetForToken(state: AppState, token: string): void { state.token = token; state.requests = []; state.selectedRequestId = null; - state.seenIds = new Set(); + state.seenIds = new Set(); + state.searchQuery = ''; + state.methodFilter = null; } export function addRequests( @@ -51,9 +57,34 @@ export function selectRequest(state: AppState, requestId: string): void { } export function selectedRequest(state: AppState): WebhookRequest | null { - return state.requests.find((request) => request.id === state.selectedRequestId) || null; + return state.requests.find((request) => String(request.id) === state.selectedRequestId) || null; } export function setActiveTab(state: AppState, tab: RequestTab): void { state.activeTab = tab; } + +export function setSearchQuery(state: AppState, query: string): void { + state.searchQuery = query; +} + +export function setMethodFilter(state: AppState, method: string | null): void { + state.methodFilter = method; +} + +export function filteredRequests(state: AppState): WebhookRequest[] { + const query = state.searchQuery.trim().toLowerCase(); + return state.requests.filter((request) => { + if (state.methodFilter && request.method !== state.methodFilter) return false; + if (!query) return true; + return ( + request.path.toLowerCase().includes(query) || + (request.headers ?? '').toLowerCase().includes(query) || + (typeof request.body === 'string' ? atob(request.body) : '').toLowerCase().includes(query) + ); + }); +} + +export function uniqueMethods(state: AppState): string[] { + return [...new Set(state.requests.map((r) => r.method))].sort(); +} diff --git a/internal/web/ui/src/entities/request/model/types.ts b/internal/web/ui/src/entities/request/model/types.ts index 0abf191..a1edca0 100644 --- a/internal/web/ui/src/entities/request/model/types.ts +++ b/internal/web/ui/src/entities/request/model/types.ts @@ -1,7 +1,7 @@ export type RequestTab = 'headers' | 'body' | 'query' | 'info' | 'settings'; export interface WebhookRequest { - id: string; + id: number; method: string; path: string; receivedAt: string; diff --git a/internal/web/ui/src/widgets/request-list/request-list.ts b/internal/web/ui/src/widgets/request-list/request-list.ts index a353159..9d26d92 100644 --- a/internal/web/ui/src/widgets/request-list/request-list.ts +++ b/internal/web/ui/src/widgets/request-list/request-list.ts @@ -1,15 +1,17 @@ import type { Elements } from '../../app/dom'; -import type { AppState } from '../../entities/request/model/request-state'; +import { filteredRequests, type AppState } from '../../entities/request/model/request-state'; import { formatRelativeTime, methodClass } from '../../shared/lib/format'; export function renderRequestList( elements: Elements, state: AppState, - options: { highlightRequestId?: string; scrollTop?: boolean } = {}, + options: { highlightRequestId?: number; scrollTop?: boolean } = {}, ): void { + const visible = filteredRequests(state); + elements.countBadge.textContent = String(state.requests.length); - if (state.requests.length === 0) { + if (visible.length === 0) { elements.requestList.replaceChildren(); elements.requestList.appendChild(elements.emptyState); return; @@ -18,11 +20,11 @@ export function renderRequestList( elements.emptyState.remove(); elements.requestList.replaceChildren(); - for (const request of state.requests) { + for (const request of visible) { const item = document.createElement('button'); item.type = 'button'; - item.className = `request-item${request.id === state.selectedRequestId ? ' active' : ''}`; - item.dataset.requestId = request.id; + item.className = `request-item${String(request.id) === state.selectedRequestId ? ' active' : ''}`; + item.dataset.requestId = String(request.id); const method = document.createElement('span'); method.className = `method-badge ${methodClass(request.method)}`; diff --git a/sqlc.yaml b/sqlc.yaml index afb8637..56ca5a7 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -1,7 +1,7 @@ version: "2" sql: - engine: sqlite - schema: migrations + schema: internal/store/migrations queries: internal/store/query gen: go: