From a8823b98f86510b167839304f4bd70afa79e074c Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 17 May 2026 18:34:23 +0300 Subject: [PATCH 01/17] feat: add forward command, search/filter UI, fix request id type --- README.md | 18 ++- internal/cli/forward/command.go | 29 ++++ internal/cli/forward/flags.go | 23 +++ internal/cli/forward/options.go | 30 ++++ internal/cli/forward/runner.go | 136 ++++++++++++++++++ internal/cli/root.go | 2 + internal/web/ui/index.html | 10 +- internal/web/ui/src/app/dom.ts | 2 + internal/web/ui/src/app/main.ts | 22 ++- .../request/model/request-state.test.ts | 24 ++-- .../entities/request/model/request-state.ts | 39 ++++- .../ui/src/entities/request/model/types.ts | 2 +- .../src/widgets/request-list/request-list.ts | 14 +- 13 files changed, 316 insertions(+), 35 deletions(-) create mode 100644 internal/cli/forward/command.go create mode 100644 internal/cli/forward/flags.go create mode 100644 internal/cli/forward/options.go create mode 100644 internal/cli/forward/runner.go 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/internal/cli/forward/command.go b/internal/cli/forward/command.go new file mode 100644 index 0000000..62f85b1 --- /dev/null +++ b/internal/cli/forward/command.go @@ -0,0 +1,29 @@ +package forward + +import ( + "context" + + "github.com/spf13/cobra" +) + +func NewCommand(ctx context.Context) *cobra.Command { + opts := DefaultOptions() + + 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..dca8a2c --- /dev/null +++ b/internal/cli/forward/runner.go @@ -0,0 +1,136 @@ +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 ctx.Err() != nil { + return nil + } + + 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) + } + + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + 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) + } + + return scanner.Err() +} + +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..6ec07eb 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -3,6 +3,7 @@ package cli import ( "context" + "github.com/GaIsBAX/Webhix/internal/cli/forward" "github.com/GaIsBAX/Webhix/internal/cli/serve" "github.com/GaIsBAX/Webhix/internal/config" "github.com/spf13/cobra" @@ -18,6 +19,7 @@ func NewRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { addGroup(cmd, serve.ServeGroup, serve.ServeTitle) cmd.AddCommand(serve.NewCommand(ctx, cfg)) + cmd.AddCommand(forward.NewCommand(ctx)) return cmd } 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)}`; From 80c4c79a9a638c614eb175656273f11b807ce27b Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 17 May 2026 18:36:53 +0300 Subject: [PATCH 02/17] fix(lint): resolve nilerr and errcheck in forward runner --- internal/cli/forward/runner.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/cli/forward/runner.go b/internal/cli/forward/runner.go index dca8a2c..0f659e0 100644 --- a/internal/cli/forward/runner.go +++ b/internal/cli/forward/runner.go @@ -32,10 +32,6 @@ func run(ctx context.Context, opts Options) error { slog.Info("forwarding", "token", opts.Token, "to", targetURL) for { - if ctx.Err() != nil { - return nil - } - if err := stream(ctx, opts, sseURL, targetURL); err != nil { slog.Warn("stream interrupted, reconnecting", "err", err) } From 36f7833d67352c0b5a88c5f1f354c7aa7d2db08b Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Sun, 17 May 2026 19:23:35 +0300 Subject: [PATCH 03/17] fix(forward): use bufio.Reader, cfg-based auth, scanner buffer limit --- internal/cli/forward/command.go | 4 +++- internal/cli/forward/runner.go | 13 ++++++++----- internal/cli/root.go | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/cli/forward/command.go b/internal/cli/forward/command.go index 62f85b1..23fbdaf 100644 --- a/internal/cli/forward/command.go +++ b/internal/cli/forward/command.go @@ -3,11 +3,13 @@ package forward import ( "context" + "github.com/GaIsBAX/Webhix/internal/config" "github.com/spf13/cobra" ) -func NewCommand(ctx context.Context) *cobra.Command { +func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { opts := DefaultOptions() + opts.AuthToken = cfg.SecretKey cmd := &cobra.Command{ Use: "forward ", diff --git a/internal/cli/forward/runner.go b/internal/cli/forward/runner.go index 0f659e0..d90c79a 100644 --- a/internal/cli/forward/runner.go +++ b/internal/cli/forward/runner.go @@ -69,9 +69,14 @@ func stream(ctx context.Context, opts Options, sseURL, targetURL string) error { return fmt.Errorf("server returned %d", resp.StatusCode) } - scanner := bufio.NewScanner(resp.Body) - for scanner.Scan() { - line := scanner.Text() + 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 } @@ -84,8 +89,6 @@ func stream(ctx context.Context, opts Options, sseURL, targetURL string) error { go forward(ctx, opts, event, targetURL) } - - return scanner.Err() } func forward(ctx context.Context, opts Options, event webhookEvent, targetURL string) { diff --git a/internal/cli/root.go b/internal/cli/root.go index 6ec07eb..83a4708 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -19,7 +19,7 @@ func NewRootCommand(ctx context.Context, cfg *config.Config) *cobra.Command { addGroup(cmd, serve.ServeGroup, serve.ServeTitle) cmd.AddCommand(serve.NewCommand(ctx, cfg)) - cmd.AddCommand(forward.NewCommand(ctx)) + cmd.AddCommand(forward.NewCommand(ctx, cfg)) return cmd } From 42bdf9c70227604cf370ccbeadc531b6cd25fc6f Mon Sep 17 00:00:00 2001 From: Denis Date: Sun, 17 May 2026 21:02:09 +0300 Subject: [PATCH 04/17] refactor(repos): move hook repository out of store --- internal/{store/repository.go => repos/hook.go} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename internal/{store/repository.go => repos/hook.go} (99%) diff --git a/internal/store/repository.go b/internal/repos/hook.go similarity index 99% rename from internal/store/repository.go rename to internal/repos/hook.go index fde950e..6e9b1de 100644 --- a/internal/store/repository.go +++ b/internal/repos/hook.go @@ -1,4 +1,4 @@ -package store +package repos import ( "context" From a6d8774aa16280a07772eb5f9d6713db85254bb2 Mon Sep 17 00:00:00 2001 From: Denis Date: Sun, 17 May 2026 21:02:18 +0300 Subject: [PATCH 05/17] feat(serve): add retention cleanup flow --- internal/cli/serve/command.go | 11 +------- internal/cli/serve/flags.go | 4 +++ internal/cli/serve/options.go | 9 +++++-- internal/cli/serve/runner.go | 13 ++++++++++ internal/core/serve_core.go | 44 ++++++++++++++++++++++++++++++++ internal/repos/serve.go | 32 +++++++++++++++++++++++ internal/store/query/hooks.sql | 9 +++++++ internal/store/sqlc/hooks.sql.go | 44 ++++++++++++++++++++++++++++++++ sqlc.yaml | 2 +- 9 files changed, 155 insertions(+), 13 deletions(-) create mode 100644 internal/cli/serve/runner.go create mode 100644 internal/core/serve_core.go create mode 100644 internal/repos/serve.go diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index 307ae66..0e2e420 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -32,7 +32,7 @@ func NewCommand(ctx context.Context, cfg *config.Config) *cobra.Command { return err } - return run(ctx, app) + return run(ctx, app, opts) }, } @@ -40,12 +40,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..f46e010 100644 --- a/internal/cli/serve/flags.go +++ b/internal/cli/serve/flags.go @@ -15,6 +15,8 @@ const ( flagMaxBodySize = "max-body-size" flagTrustedProxies = "trusted-proxies" + + flagRetention = "retention" ) func RegisterFlags(cmd *cobra.Command, cfg *config.Config, opt *Options) { @@ -28,4 +30,6 @@ func RegisterFlags(cmd *cobra.Command, cfg *config.Config, opt *Options) { 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.StringSliceVar(&cfg.TrustedProxies, flagTrustedProxies, cfg.TrustedProxies, "trusted proxy CIDRs") + + flags.DurationVarP(&opt.Retention, flagRetention, "re", 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..bd4f5a8 --- /dev/null +++ b/internal/cli/serve/runner.go @@ -0,0 +1,13 @@ +package serve + +import ( + "context" + + "github.com/GaIsBAX/Webhix/internal/app" +) + +func run(ctx context.Context, application *app.App, opts Options) error { + return application.RunServe(ctx, app.ServeOptions{ + Retention: opts.Retention, + }) +} diff --git a/internal/core/serve_core.go b/internal/core/serve_core.go new file mode 100644 index 0000000..20ef8bd --- /dev/null +++ b/internal/core/serve_core.go @@ -0,0 +1,44 @@ +package core + +import ( + "context" + "time" +) + +type ServeRepository interface { + DeleteWebhookRequestsOlderThan(ctx context.Context, retention time.Duration) (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: + return cleanup() + + case <-ctx.Done(): + return 0, ctx.Err() + } + } +} diff --git a/internal/repos/serve.go b/internal/repos/serve.go new file mode 100644 index 0000000..be74f5c --- /dev/null +++ b/internal/repos/serve.go @@ -0,0 +1,32 @@ +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 +} diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index b7e5d4f..a4c3880 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -29,6 +29,15 @@ FROM webhook_requests WHERE hook_id = ? ORDER BY received_at DESC, id DESC; +-- name: ListWebhookRequestsByTime :many +SELECT id, token, name, created_at, updated_at +FROM hooks +WHERE created_at <= datetime('now', ?); + +-- name: DeleteWebhookRequestsOlderThan :execresult +DELETE FROM hooks +WHERE created_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..ecf6a75 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -91,6 +91,15 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq return i, err } +const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult +DELETE FROM hooks +WHERE created_at < datetime('now', ?) +` + +func (q *Queries) DeleteWebhookRequestsOlderThan(ctx context.Context, datetime interface{}) (sql.Result, error) { + return q.db.ExecContext(ctx, deleteWebhookRequestsOlderThan, datetime) +} + const getHookByToken = `-- name: GetHookByToken :one SELECT id, token, name, created_at, updated_at FROM hooks @@ -173,6 +182,41 @@ func (q *Queries) ListWebhookRequestsByHookID(ctx context.Context, hookID int64) return items, nil } +const listWebhookRequestsByTime = `-- name: ListWebhookRequestsByTime :many +SELECT id, token, name, created_at, updated_at +FROM hooks +WHERE created_at <= datetime('now', ?) +` + +func (q *Queries) ListWebhookRequestsByTime(ctx context.Context, datetime interface{}) ([]Hook, error) { + rows, err := q.db.QueryContext(ctx, listWebhookRequestsByTime, datetime) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Hook + for rows.Next() { + var i Hook + if err := rows.Scan( + &i.ID, + &i.Token, + &i.Name, + &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 upsertHookResponse = `-- name: UpsertHookResponse :one INSERT INTO hook_responses (hook_id, status_code, headers, body) VALUES (?, ?, ?, ?) 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: From 45539058c48c2bd9bbd993b6bb8d9731da603ced Mon Sep 17 00:00:00 2001 From: Denis Date: Sun, 17 May 2026 21:02:29 +0300 Subject: [PATCH 06/17] refactor(app): split application wiring --- internal/app/app.go | 107 ++++++++++++++++---------------------- internal/app/deps.go | 66 ++++++++++++------------ internal/app/http.go | 108 +++++++++++++++++++++++++++++++++++++++ internal/app/services.go | 15 ++++++ 4 files changed, 200 insertions(+), 96 deletions(-) create mode 100644 internal/app/http.go create mode 100644 internal/app/services.go diff --git a/internal/app/app.go b/internal/app/app.go index 89cbc51..ab5337a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,122 +2,101 @@ package app import ( "context" - "fmt" - "io/fs" "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 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 + events *hub.Hub + services *services } 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, - }) + services := newServices(deps.repositories) + events := hub.New() - hookHandler.RegisterRoutes() - - staticSub, err := fs.Sub(web.Static, "static") + mux, err := newMux(cfg, services, events) if err != nil { return nil, err } - staticFS := http.FileServer(http.FS(staticSub)) - mux.Handle("/ui/", http.StripPrefix("/ui/", staticFS)) - mux.Handle("/", staticFS) - password, secretKey, err := resolveAuth(cfg) + handler, err := newHTTPHandler(cfg, mux) if err != nil { - return nil, fmt.Errorf("auth setup: %w", err) - } - - handler := http.Handler(mux) - handler = middleware.NewAuth(password, secretKey).Protect(handler) - - if len(cfg.TrustedProxies) > 0 { - trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) - if trustedProxies == nil { - return nil, fmt.Errorf("invalid trusted proxies") - } - - handler = trustedProxies.BehindProxy(handler) + return nil, err } return &App{ - srv: &http.Server{Addr: cfg.Addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second}, - cfg: cfg, - deps: deps, - hub: eventHub, + server: newHTTPServer(cfg, handler), + config: cfg, + deps: deps, + events: events, + services: services, }, 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") - } - - return cfg.Password, cfg.SecretKey, nil -} - func (a *App) Shutdown() error { slog.Info("shutting down") - a.hub.Close() + a.events.Close() - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() - if err := a.srv.Shutdown(ctx); err != nil { + if err := a.server.Shutdown(ctx); 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 nil } + +type ServeOptions struct { + Retention time.Duration +} + +func (a *App) RunServe(ctx context.Context, opts ServeOptions) error { + go func() { + if _, err := a.services.serve.RetentionCleaner(ctx, opts.Retention); err != nil { + slog.Error("retention cleaner", "err", err) + } + }() + + return a.Start(ctx) +} diff --git a/internal/app/deps.go b/internal/app/deps.go index 00a1505..b9bf0d0 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -3,55 +3,57 @@ package app import ( "context" "errors" + "fmt" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/repos" "github.com/GaIsBAX/Webhix/internal/store" ) -type Deps struct { - DB *store.Database - cfg *config.Config +type dependencies struct { + db *store.Database + repositories *repositories } -func NewDeps(ctx context.Context, cfg *config.Config) (*Deps, error) { - deps := &Deps{ - cfg: cfg, +func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { + db, err := store.New(ctx, cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("open database: %w", err) } - if err := deps.setupInfrastructure(ctx); err != nil { - return nil, err + if err := db.Migrate(); err != nil { + if closeErr := db.Close(); closeErr != nil { + return nil, errors.Join( + fmt.Errorf("migrate database: %w", err), + fmt.Errorf("close database after migration failure: %w", closeErr), + ) + } + + return nil, fmt.Errorf("migrate database: %w", err) } - return deps, nil + return &dependencies{ + db: db, + repositories: newRepositories(db), + }, nil } -func (d *Deps) setupInfrastructure(ctx context.Context) error { - var errs []error - - database, err := store.New(ctx, d.cfg.DBPath) - if err != nil { - errs = append(errs, err) - } - - d.DB = database +type repositories struct { + hook *repos.HookRepository + 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.NewHookRepository(db.DB), + serve: repos.NewServe(db.DB), } - - return errors.Join(errs...) } -func (d *Deps) teardownInfrastructure() error { - var errs []error - - if d.DB != nil { - if err := d.DB.Close(); err != nil { - errs = append(errs, err) - } +func (d *dependencies) close() error { + if d.db != nil { + return d.db.Close() } - return errors.Join(errs...) + return nil } diff --git a/internal/app/http.go b/internal/app/http.go new file mode 100644 index 0000000..052e45a --- /dev/null +++ b/internal/app/http.go @@ -0,0 +1,108 @@ +package app + +import ( + "fmt" + "io/fs" + "net/http" + "time" + + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/hub" + "github.com/GaIsBAX/Webhix/internal/server" + "github.com/GaIsBAX/Webhix/internal/server/middleware" + "github.com/GaIsBAX/Webhix/internal/web" +) + +const readHeaderTimeout = 5 * time.Second + +func newMux(cfg *config.Config, services *services, events *hub.Hub) (*http.ServeMux, error) { + mux := http.NewServeMux() + + registerWebhookRoutes(mux, cfg, services.hook, events) + + if err := registerStaticRoutes(mux); err != nil { + return nil, err + } + + return mux, nil +} + +func registerWebhookRoutes( + mux *http.ServeMux, + cfg *config.Config, + hookService server.HookService, + events *hub.Hub, +) { + handler := server.NewHookHandler( + mux, + hookService, + events, + server.HookHandlerOptions{ + BaseURL: cfg.BaseURL, + MaxBodySize: cfg.MaxBodySize, + }, + ) + + handler.RegisterRoutes() +} + +func registerStaticRoutes(mux *http.ServeMux) error { + staticSub, err := fs.Sub(web.Static, "static") + if err != nil { + return err + } + + staticHandler := http.FileServer(http.FS(staticSub)) + + mux.Handle("/ui/", http.StripPrefix("/ui/", staticHandler)) + mux.Handle("/", staticHandler) + + return nil +} + +func newHTTPHandler(cfg *config.Config, mux *http.ServeMux) (http.Handler, error) { + handler := http.Handler(mux) + + auth, err := newAuthMiddleware(cfg) + if err != nil { + return nil, err + } + + handler = auth.Protect(handler) + + if len(cfg.TrustedProxies) > 0 { + trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) + if trustedProxies == nil { + return nil, fmt.Errorf("invalid trusted proxies") + } + + handler = trustedProxies.BehindProxy(handler) + } + + return handler, nil +} + +func newAuthMiddleware(cfg *config.Config) (*middleware.Auth, error) { + password, secretKey, err := authCredentials(cfg) + if err != nil { + return nil, fmt.Errorf("auth setup: %w", err) + } + + return middleware.NewAuth(password, secretKey), nil +} + +func newHTTPServer(cfg *config.Config, handler http.Handler) *http.Server { + return &http.Server{ + Addr: cfg.Addr, + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, + } +} + +func authCredentials(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") + } + + return cfg.Password, cfg.SecretKey, nil +} diff --git a/internal/app/services.go b/internal/app/services.go new file mode 100644 index 0000000..6154053 --- /dev/null +++ b/internal/app/services.go @@ -0,0 +1,15 @@ +package app + +import "github.com/GaIsBAX/Webhix/internal/core" + +type services struct { + hook *core.HookService + serve *core.Serve +} + +func newServices(repositories *repositories) *services { + return &services{ + hook: core.NewHookService(repositories.hook), + serve: core.NewServe(repositories.serve), + } +} From 88bffe18163df11c284dc82b9a9637ab682fe12f Mon Sep 17 00:00:00 2001 From: GaIsBAX Date: Mon, 18 May 2026 12:09:51 +0300 Subject: [PATCH 07/17] fix(app): pass context to Shutdown, return deps close error --- internal/app/app.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index ab5337a..7b80e0d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -62,18 +62,18 @@ func (a *App) Start(ctx context.Context) error { return err case <-ctx.Done(): - return a.Shutdown() + return a.Shutdown(ctx) } } -func (a *App) Shutdown() error { +func (a *App) Shutdown(ctx context.Context) error { slog.Info("shutting down") a.events.Close() - ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() - if err := a.server.Shutdown(ctx); err != nil { + if err := a.server.Shutdown(shutdownCtx); err != nil { slog.Error("graceful shutdown failed, forcing close", "err", err) if closeErr := a.server.Close(); closeErr != nil { slog.Error("server close failed", "err", closeErr) @@ -82,6 +82,7 @@ func (a *App) Shutdown() error { if err := a.deps.close(); err != nil { slog.Error("teardown error", "err", err) + return err } return nil From 0a545d4aa3fb576f31a282f93fcc5e723a8ebe71 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 11:51:32 +0300 Subject: [PATCH 08/17] fix(cli): RetentionCleaner --- internal/core/serve_core.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/core/serve_core.go b/internal/core/serve_core.go index 20ef8bd..b21ca46 100644 --- a/internal/core/serve_core.go +++ b/internal/core/serve_core.go @@ -35,7 +35,7 @@ func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) ( for { select { case <-ticker.C: - return cleanup() + cleanup() case <-ctx.Done(): return 0, ctx.Err() From a07d398f568245e29facca6d7e02deb6cfa14182 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 16:46:15 +0300 Subject: [PATCH 09/17] feat(cli): add version command and app wiring --- cmd/webhix/main.go | 4 +-- internal/app/services.go | 28 ++++++++++++++- internal/cli/cli.go | 12 +++++-- internal/cli/root.go | 13 +++++-- internal/cli/serve/command.go | 22 +++++++++--- internal/cli/serve/runner.go | 11 ++++-- internal/cli/version/command.go | 60 ++++++++++++++++++++++++++++++++ internal/cli/version/flags.go | 9 +++++ internal/cli/version/options.go | 28 +++++++++++++++ internal/config/config.go | 2 ++ internal/core/serve_core.go | 34 ++++++++++++++++++ internal/core/version_core.go | 28 +++++++++++++++ internal/domain/hook.go | 8 +++++ internal/domain/version.go | 8 +++++ internal/repos/serve.go | 9 +++++ internal/store/query/hooks.sql | 4 +++ internal/store/sqlc/hooks.sql.go | 12 +++++++ 17 files changed, 278 insertions(+), 14 deletions(-) create mode 100644 internal/cli/version/command.go create mode 100644 internal/cli/version/flags.go create mode 100644 internal/cli/version/options.go create mode 100644 internal/core/version_core.go create mode 100644 internal/domain/version.go diff --git a/cmd/webhix/main.go b/cmd/webhix/main.go index e36899a..21433b5 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,7 +26,7 @@ func main() { os.Exit(1) } - if err := cli.Run(ctx, cfg, os.Args[1:]); err != nil { + if err := app.Start(ctx, cfg, os.Args[1:]); err != nil { slog.Error("app run", "err", err) os.Exit(1) } diff --git a/internal/app/services.go b/internal/app/services.go index 6154053..055b595 100644 --- a/internal/app/services.go +++ b/internal/app/services.go @@ -1,6 +1,14 @@ package app -import "github.com/GaIsBAX/Webhix/internal/core" +import ( + "context" + + "github.com/GaIsBAX/Webhix/internal/cli" + "github.com/GaIsBAX/Webhix/internal/cli/serve" + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/core" + "github.com/GaIsBAX/Webhix/internal/domain" +) type services struct { hook *core.HookService @@ -13,3 +21,21 @@ func newServices(repositories *repositories) *services { serve: core.NewServe(repositories.serve), } } + +func NewVersionService() *core.Version { + return core.NewVersion() +} + +func Start(ctx context.Context, cfg *config.Config, args []string) error { + versionService := NewVersionService() + serveFactory := serve.ServiceFactoryFunc(func(ctx context.Context, cfg *config.Config) (serve.Service, domain.ServeStartFunc, error) { + application, err := New(ctx, cfg) + if err != nil { + return nil, nil, err + } + + return application.services.serve, application.Start, nil + }) + + return cli.Run(ctx, cfg, args, versionService, serveFactory) +} 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/root.go b/internal/cli/root.go index 83a4708..90732ee 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -5,21 +5,30 @@ import ( "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 0e2e420..cfd16c0 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/domain" "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 domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error +} + +type ServiceFactory interface { + New(ctx context.Context, cfg *config.Config) (Service, domain.ServeStartFunc, error) +} + +type ServiceFactoryFunc func(ctx context.Context, cfg *config.Config) (Service, domain.ServeStartFunc, error) + +func (f ServiceFactoryFunc) New(ctx context.Context, cfg *config.Config) (Service, domain.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, opts) + return run(ctx, service, start, cfg, opts) }, } diff --git a/internal/cli/serve/runner.go b/internal/cli/serve/runner.go index bd4f5a8..32f77f2 100644 --- a/internal/cli/serve/runner.go +++ b/internal/cli/serve/runner.go @@ -2,12 +2,17 @@ package serve import ( "context" + "log/slog" - "github.com/GaIsBAX/Webhix/internal/app" + "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/domain" ) -func run(ctx context.Context, application *app.App, opts Options) error { - return application.RunServe(ctx, app.ServeOptions{ +func run(ctx context.Context, service Service, start domain.ServeStartFunc, cfg *config.Config, opts Options) error { + return service.Run(ctx, domain.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..03af0fb --- /dev/null +++ b/internal/cli/version/command.go @@ -0,0 +1,60 @@ +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 +} + +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(info) + + case outputYAML: + encoder := yaml.NewEncoder(w) + defer encoder.Close() + return encoder.Encode(info) + + default: + _, err := fmt.Fprintf( + w, + "webhix %s\ncommit: %s\nbuilt: %s\ngo: %s\n", + info.Version, + info.Commit, + info.Built, + info.Go, + ) + return err + } +} 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/serve_core.go b/internal/core/serve_core.go index b21ca46..044eea4 100644 --- a/internal/core/serve_core.go +++ b/internal/core/serve_core.go @@ -2,11 +2,15 @@ package core import ( "context" + "errors" "time" + + "github.com/GaIsBAX/Webhix/internal/domain" ) type ServeRepository interface { DeleteWebhookRequestsOlderThan(ctx context.Context, retention time.Duration) (int64, error) + GetCountRequests(ctx context.Context) (int64, error) } type Serve struct { @@ -42,3 +46,33 @@ func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) ( } } } + +func (s *Serve) Run(ctx context.Context, opts domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error { + s.StartRetentionCleaner(ctx, opts, onRetentionError) + return start(ctx) +} + +func (s *Serve) StartRetentionCleaner(ctx context.Context, opts domain.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..599b797 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -1,6 +1,7 @@ package domain import ( + "context" "fmt" "time" ) @@ -55,6 +56,13 @@ type UpsertHookResponseParams struct { Body []byte } +type ServeRunOptions struct { + Retention time.Duration + ReadOnly bool +} + +type ServeStartFunc func(context.Context) error + func (p UpsertHookResponseParams) Validate() error { if p.StatusCode < 100 || p.StatusCode > 599 { return fmt.Errorf("statusCode must be between 100 and 599") diff --git a/internal/domain/version.go b/internal/domain/version.go new file mode 100644 index 0000000..93944d6 --- /dev/null +++ b/internal/domain/version.go @@ -0,0 +1,8 @@ +package domain + +type VersionInfo 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"` +} diff --git a/internal/repos/serve.go b/internal/repos/serve.go index be74f5c..96da35d 100644 --- a/internal/repos/serve.go +++ b/internal/repos/serve.go @@ -30,3 +30,12 @@ func (r *Serve) DeleteWebhookRequestsOlderThan(ctx context.Context, retention ti 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/store/query/hooks.sql b/internal/store/query/hooks.sql index a4c3880..e56e26c 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, diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index ecf6a75..3898907 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -100,6 +100,18 @@ func (q *Queries) DeleteWebhookRequestsOlderThan(ctx context.Context, datetime i 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 From 958684d3014b043eb08d6f1a773b3cc25e19b7fa Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 16:47:04 +0300 Subject: [PATCH 10/17] feat(server): add read-only serve mode --- internal/app/app.go | 14 -------------- internal/app/http.go | 1 + internal/cli/serve/flags.go | 10 ++++++++-- internal/server/errors.go | 6 ++++++ internal/server/handler.go | 22 ++++++++++++++++++++++ 5 files changed, 37 insertions(+), 16 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index 7b80e0d..f725b3e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -87,17 +87,3 @@ func (a *App) Shutdown(ctx context.Context) error { return nil } - -type ServeOptions struct { - Retention time.Duration -} - -func (a *App) RunServe(ctx context.Context, opts ServeOptions) error { - go func() { - if _, err := a.services.serve.RetentionCleaner(ctx, opts.Retention); err != nil { - slog.Error("retention cleaner", "err", err) - } - }() - - return a.Start(ctx) -} diff --git a/internal/app/http.go b/internal/app/http.go index 052e45a..cd7745c 100644 --- a/internal/app/http.go +++ b/internal/app/http.go @@ -40,6 +40,7 @@ func registerWebhookRoutes( server.HookHandlerOptions{ BaseURL: cfg.BaseURL, MaxBodySize: cfg.MaxBodySize, + ReadOnly: cfg.ReadOnly, }, ) diff --git a/internal/cli/serve/flags.go b/internal/cli/serve/flags.go index f46e010..c7125c1 100644 --- a/internal/cli/serve/flags.go +++ b/internal/cli/serve/flags.go @@ -16,7 +16,9 @@ const ( flagMaxBodySize = "max-body-size" flagTrustedProxies = "trusted-proxies" - flagRetention = "retention" + flagRetention = "retention" + flagMaxRequests = "max-requests" + flagReadonly = "readonly" ) func RegisterFlags(cmd *cobra.Command, cfg *config.Config, opt *Options) { @@ -28,8 +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.DurationVarP(&opt.Retention, flagRetention, "re", opt.Retention, "TODO") + flags.DurationVar(&opt.Retention, flagRetention, opt.Retention, "TODO") } 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..b23cd18 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -26,6 +26,7 @@ type HookService interface { type HookHandlerOptions struct { BaseURL string MaxBodySize int64 + ReadOnly bool } type HookHandler struct { @@ -59,6 +60,10 @@ func (h *HookHandler) RegisterRoutes() { } func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + contract, err := DecodeContract[CreateEndpointRequestContract](r) if err != nil { slog.Error("create endpoint", "err", err) @@ -89,6 +94,10 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { } func (h *HookHandler) ReceiveWebhook(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + token := r.PathValue("token") headersJSON, err := json.Marshal(r.Header) @@ -250,6 +259,10 @@ func (h *HookHandler) GetResponse(w http.ResponseWriter, r *http.Request) { } func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { + if h.readOnly(w) { + return + } + token := r.PathValue("token") contract, err := DecodeContract[SetHookResponseRequestContract](r) @@ -290,3 +303,12 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } + +func (h *HookHandler) readOnly(w http.ResponseWriter) bool { + if !h.opts.ReadOnly { + return false + } + + SendError(w, http.StatusForbidden, ErrReadOnly) + return true +} From d4f7a51ec81c5fbf1ca634da3d066928cf1495a3 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 16:58:42 +0300 Subject: [PATCH 11/17] fix(cli): mark yaml as direct dependency --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ) From 2bb6ead51e5f6ed6ff2fa716bbd4ae0176eb9c98 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 17:04:36 +0300 Subject: [PATCH 12/17] fix(cli): format version output switch --- internal/cli/version/command.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go index 03af0fb..9659422 100644 --- a/internal/cli/version/command.go +++ b/internal/cli/version/command.go @@ -40,12 +40,12 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { case outputJSON: encoder := json.NewEncoder(w) return encoder.Encode(info) - + case outputYAML: encoder := yaml.NewEncoder(w) defer encoder.Close() return encoder.Encode(info) - + default: _, err := fmt.Fprintf( w, From 17e8a0f5bd8fb5b31359e098c342c480e9789975 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 17:08:51 +0300 Subject: [PATCH 13/17] fix(cli): handle linted errors --- internal/cli/version/command.go | 6 ++++-- internal/core/serve_core.go | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go index 9659422..b711193 100644 --- a/internal/cli/version/command.go +++ b/internal/cli/version/command.go @@ -43,8 +43,10 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { case outputYAML: encoder := yaml.NewEncoder(w) - defer encoder.Close() - return encoder.Encode(info) + if err := encoder.Encode(info); err != nil { + return err + } + return encoder.Close() default: _, err := fmt.Fprintf( diff --git a/internal/core/serve_core.go b/internal/core/serve_core.go index 044eea4..7f2c7d1 100644 --- a/internal/core/serve_core.go +++ b/internal/core/serve_core.go @@ -39,7 +39,9 @@ func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) ( for { select { case <-ticker.C: - cleanup() + if _, err := cleanup(); err != nil { + return 0, err + } case <-ctx.Done(): return 0, ctx.Err() From 3fe8794488a6f4e6f31ac1d939f6e094bff71898 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 20:46:32 +0300 Subject: [PATCH 14/17] fix(sql): clean up old webhook requests instead of hooks --- internal/store/query/hooks.sql | 24 +++++++++++--- internal/store/sqlc/hooks.sql.go | 57 ++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index e56e26c..5672d69 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -34,13 +34,27 @@ WHERE hook_id = ? ORDER BY received_at DESC, id DESC; -- name: ListWebhookRequestsByTime :many -SELECT id, token, name, created_at, updated_at -FROM hooks -WHERE created_at <= datetime('now', ?); +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 hooks -WHERE created_at < datetime('now', ?); +DELETE FROM webhook_requests +WHERE received_at < datetime('now', ?); -- name: UpsertHookResponse :one INSERT INTO hook_responses (hook_id, status_code, headers, body) diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 3898907..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 @@ -92,8 +93,8 @@ func (q *Queries) CreateWebhookRequest(ctx context.Context, arg CreateWebhookReq } const deleteWebhookRequestsOlderThan = `-- name: DeleteWebhookRequestsOlderThan :execresult -DELETE FROM hooks -WHERE created_at < datetime('now', ?) +DELETE FROM webhook_requests +WHERE received_at < datetime('now', ?) ` func (q *Queries) DeleteWebhookRequestsOlderThan(ctx context.Context, datetime interface{}) (sql.Result, error) { @@ -195,26 +196,62 @@ func (q *Queries) ListWebhookRequestsByHookID(ctx context.Context, hookID int64) } const listWebhookRequestsByTime = `-- name: ListWebhookRequestsByTime :many -SELECT id, token, name, created_at, updated_at -FROM hooks -WHERE created_at <= datetime('now', ?) +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 ` -func (q *Queries) ListWebhookRequestsByTime(ctx context.Context, datetime interface{}) ([]Hook, error) { +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 []Hook + var items []ListWebhookRequestsByTimeRow for rows.Next() { - var i Hook + var i ListWebhookRequestsByTimeRow if err := rows.Scan( &i.ID, + &i.HookID, &i.Token, &i.Name, - &i.CreatedAt, - &i.UpdatedAt, + &i.Method, + &i.Path, + &i.Query, + &i.Headers, + &i.RemoteAddr, + &i.ContentType, + &i.BodySize, + &i.ReceivedAt, ); err != nil { return nil, err } From 1b43fe097525d5e202e127d1090ade293318ec41 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 22:35:07 +0300 Subject: [PATCH 15/17] refactor(app): restructure application initialization and error handling --- cmd/webhix/main.go | 10 +++- internal/app/app.go | 34 ++++------- internal/app/deps.go | 117 ++++++++++++++++++++++++++++++------- internal/app/errors.go | 12 ++++ internal/app/http.go | 109 ---------------------------------- internal/app/services.go | 41 ------------- internal/core/hook_core.go | 16 ++--- internal/repos/hook.go | 18 +++--- internal/server/handler.go | 82 +++++++++++++------------- 9 files changed, 185 insertions(+), 254 deletions(-) create mode 100644 internal/app/errors.go delete mode 100644 internal/app/http.go delete mode 100644 internal/app/services.go diff --git a/cmd/webhix/main.go b/cmd/webhix/main.go index 21433b5..3e7ea30 100644 --- a/cmd/webhix/main.go +++ b/cmd/webhix/main.go @@ -26,8 +26,14 @@ func main() { os.Exit(1) } - if err := app.Start(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/internal/app/app.go b/internal/app/app.go index f725b3e..ec37c94 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,17 +7,15 @@ import ( "time" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/hub" ) const shutdownTimeout = 10 * time.Second type App struct { - server *http.Server - config *config.Config - deps *dependencies - events *hub.Hub - services *services + server *http.Server + + config *config.Config + deps *dependencies } func New(ctx context.Context, cfg *config.Config) (*App, error) { @@ -26,25 +24,15 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { return nil, err } - services := newServices(deps.repositories) - events := hub.New() - - mux, err := newMux(cfg, services, events) - if err != nil { - return nil, err - } - - handler, err := newHTTPHandler(cfg, mux) - if err != nil { - return nil, err + server := &http.Server{ + Addr: cfg.Addr, + Handler: deps.mux, } return &App{ - server: newHTTPServer(cfg, handler), - config: cfg, - deps: deps, - events: events, - services: services, + server: server, + config: cfg, + deps: deps, }, nil } @@ -68,7 +56,7 @@ func (a *App) Start(ctx context.Context) error { func (a *App) Shutdown(ctx context.Context) error { slog.Info("shutting down") - a.events.Close() + a.deps.infra.hub.Close() shutdownCtx, cancel := context.WithTimeout(ctx, shutdownTimeout) defer cancel() diff --git a/internal/app/deps.go b/internal/app/deps.go index b9bf0d0..d4e47fa 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -4,56 +4,131 @@ 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" ) type dependencies struct { - db *store.Database - repositories *repositories + mux *http.ServeMux + cfg *config.Config + + infra *infrastructure + repos *repositories + services *services } func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { - db, err := store.New(ctx, cfg.DBPath) + var deps dependencies + + mux := http.NewServeMux() + + infra, err := newInfrastructure(ctx, cfg) if err != nil { - return nil, fmt.Errorf("open database: %w", err) + return nil, err } - if err := db.Migrate(); err != nil { - if closeErr := db.Close(); closeErr != nil { - return nil, errors.Join( - fmt.Errorf("migrate database: %w", err), - fmt.Errorf("close database after migration failure: %w", closeErr), - ) - } + repos := newRepositories(infra.db) + services := newServices(repos) - return nil, fmt.Errorf("migrate database: %w", err) - } + deps.mux = mux + deps.cfg = cfg - return &dependencies{ - db: db, - repositories: newRepositories(db), - }, nil + deps.infra = infra + deps.repos = repos + deps.services = services + + return &deps, nil +} + +type services struct { + hook *core.Hook + serve *core.Serve + version *core.Version +} + +func newServices(repos *repositories) *services { + hook := core.NewHook(repos.hook) + serve := core.NewServe(repos.serve) + version := core.NewVersion() + + return &services{ + hook: hook, + serve: serve, + version: version, + } } type repositories struct { - hook *repos.HookRepository + hook *repos.Hook serve *repos.Serve } func newRepositories(db *store.Database) *repositories { return &repositories{ - hook: repos.NewHookRepository(db.DB), + hook: repos.NewHook(db.DB), serve: repos.NewServe(db.DB), } } func (d *dependencies) close() error { - if d.db != nil { - return d.db.Close() + if d.infra.db != nil { + return d.infra.db.Close() } return nil } + +type infrastructure struct { + db *store.Database + hub *hub.Hub +} + +func newInfrastructure(ctx context.Context, cfg *config.Config) (*infrastructure, error) { + db, err := store.New(ctx, cfg.DBPath) + if err != nil { + return nil, 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) + } + + 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, + }, + }), + } +} 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/app/http.go b/internal/app/http.go deleted file mode 100644 index cd7745c..0000000 --- a/internal/app/http.go +++ /dev/null @@ -1,109 +0,0 @@ -package app - -import ( - "fmt" - "io/fs" - "net/http" - "time" - - "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/hub" - "github.com/GaIsBAX/Webhix/internal/server" - "github.com/GaIsBAX/Webhix/internal/server/middleware" - "github.com/GaIsBAX/Webhix/internal/web" -) - -const readHeaderTimeout = 5 * time.Second - -func newMux(cfg *config.Config, services *services, events *hub.Hub) (*http.ServeMux, error) { - mux := http.NewServeMux() - - registerWebhookRoutes(mux, cfg, services.hook, events) - - if err := registerStaticRoutes(mux); err != nil { - return nil, err - } - - return mux, nil -} - -func registerWebhookRoutes( - mux *http.ServeMux, - cfg *config.Config, - hookService server.HookService, - events *hub.Hub, -) { - handler := server.NewHookHandler( - mux, - hookService, - events, - server.HookHandlerOptions{ - BaseURL: cfg.BaseURL, - MaxBodySize: cfg.MaxBodySize, - ReadOnly: cfg.ReadOnly, - }, - ) - - handler.RegisterRoutes() -} - -func registerStaticRoutes(mux *http.ServeMux) error { - staticSub, err := fs.Sub(web.Static, "static") - if err != nil { - return err - } - - staticHandler := http.FileServer(http.FS(staticSub)) - - mux.Handle("/ui/", http.StripPrefix("/ui/", staticHandler)) - mux.Handle("/", staticHandler) - - return nil -} - -func newHTTPHandler(cfg *config.Config, mux *http.ServeMux) (http.Handler, error) { - handler := http.Handler(mux) - - auth, err := newAuthMiddleware(cfg) - if err != nil { - return nil, err - } - - handler = auth.Protect(handler) - - if len(cfg.TrustedProxies) > 0 { - trustedProxies := middleware.NewTrustedProxies(cfg.TrustedProxies) - if trustedProxies == nil { - return nil, fmt.Errorf("invalid trusted proxies") - } - - handler = trustedProxies.BehindProxy(handler) - } - - return handler, nil -} - -func newAuthMiddleware(cfg *config.Config) (*middleware.Auth, error) { - password, secretKey, err := authCredentials(cfg) - if err != nil { - return nil, fmt.Errorf("auth setup: %w", err) - } - - return middleware.NewAuth(password, secretKey), nil -} - -func newHTTPServer(cfg *config.Config, handler http.Handler) *http.Server { - return &http.Server{ - Addr: cfg.Addr, - Handler: handler, - ReadHeaderTimeout: readHeaderTimeout, - } -} - -func authCredentials(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") - } - - return cfg.Password, cfg.SecretKey, nil -} diff --git a/internal/app/services.go b/internal/app/services.go deleted file mode 100644 index 055b595..0000000 --- a/internal/app/services.go +++ /dev/null @@ -1,41 +0,0 @@ -package app - -import ( - "context" - - "github.com/GaIsBAX/Webhix/internal/cli" - "github.com/GaIsBAX/Webhix/internal/cli/serve" - "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/core" - "github.com/GaIsBAX/Webhix/internal/domain" -) - -type services struct { - hook *core.HookService - serve *core.Serve -} - -func newServices(repositories *repositories) *services { - return &services{ - hook: core.NewHookService(repositories.hook), - serve: core.NewServe(repositories.serve), - } -} - -func NewVersionService() *core.Version { - return core.NewVersion() -} - -func Start(ctx context.Context, cfg *config.Config, args []string) error { - versionService := NewVersionService() - serveFactory := serve.ServiceFactoryFunc(func(ctx context.Context, cfg *config.Config) (serve.Service, domain.ServeStartFunc, error) { - application, err := New(ctx, cfg) - if err != nil { - return nil, nil, err - } - - return application.services.serve, application.Start, nil - }) - - return cli.Run(ctx, cfg, args, versionService, serveFactory) -} diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index e539ab9..5563ce1 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -16,17 +16,17 @@ type HookRepository interface { UpsertHookResponse(ctx context.Context, hookID int64, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookService struct { +type Hook struct { repo HookRepository } -func NewHookService(repo HookRepository) *HookService { - return &HookService{ +func NewHook(repo HookRepository) *Hook { + return &Hook{ repo: repo, } } -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") } @@ -34,7 +34,7 @@ func (s *HookService) CreateHook(ctx context.Context, token string) (domain.Hook 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 @@ -55,7 +55,7 @@ func (s *HookService) ReceiveWebhook(ctx context.Context, token string, params d 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,7 +64,7 @@ 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 @@ -73,7 +73,7 @@ func (s *HookService) GetHookResponse(ctx context.Context, token string) (domain return s.repo.GetHookResponse(ctx, hook.ID) } -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 diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 6e9b1de..389991f 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -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,7 +78,7 @@ 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) { @@ -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/server/handler.go b/internal/server/handler.go index b23cd18..7b5766b 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -23,43 +23,41 @@ type HookService interface { SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } -type HookHandlerOptions struct { +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 *hub.Hub + 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) { +func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { if h.readOnly(w) { return } @@ -71,7 +69,7 @@ func (h *HookHandler) CreateEndpoint(w http.ResponseWriter, r *http.Request) { 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) @@ -82,7 +80,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) @@ -93,7 +91,7 @@ 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 } @@ -107,14 +105,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 } @@ -126,7 +124,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, @@ -153,7 +151,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 { @@ -171,10 +169,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) @@ -200,7 +198,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") @@ -213,7 +211,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) @@ -222,8 +220,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 @@ -235,10 +235,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) @@ -258,7 +258,7 @@ 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 } @@ -284,7 +284,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) @@ -304,8 +304,8 @@ func (h *HookHandler) SetResponse(w http.ResponseWriter, r *http.Request) { SendSuccess(w, http.StatusOK, data) } -func (h *HookHandler) readOnly(w http.ResponseWriter) bool { - if !h.opts.ReadOnly { +func (h *Hook) readOnly(w http.ResponseWriter) bool { + if !h.deps.Opts.ReadOnly { return false } From b85ffd762846ac61bf85fd141797246cbb1be234 Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 22:58:29 +0300 Subject: [PATCH 16/17] refactor(app): enhance application structure with new HTTP handler and middleware integration --- internal/app/app.go | 32 ++++++++++++++++++- internal/app/deps.go | 16 ++++++++-- internal/cli/serve/command.go | 10 +++--- internal/cli/serve/runner.go | 6 ++-- internal/cli/version/command.go | 20 ++++++++++-- internal/core/hook_core.go | 40 ++++++++++++++++++++---- internal/core/serve_core.go | 13 +++++--- internal/domain/hook.go | 54 ++++++++++++++------------------- internal/domain/version.go | 8 ++--- internal/repos/hook.go | 2 +- internal/server/handler.go | 13 +++++--- internal/server/helpers.go | 2 +- 12 files changed, 151 insertions(+), 65 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index ec37c94..d97668b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -2,11 +2,14 @@ package app import ( "context" + "errors" "log/slog" "net/http" "time" "github.com/GaIsBAX/Webhix/internal/config" + "github.com/GaIsBAX/Webhix/internal/server" + "github.com/GaIsBAX/Webhix/internal/server/middleware" ) const shutdownTimeout = 10 * time.Second @@ -24,9 +27,17 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { return nil, err } + handler, err := newHTTPHandler(deps.mux, cfg) + if err != nil { + if closeErr := deps.close(); closeErr != nil { + return nil, errors.Join(err, closeErr) + } + return nil, err + } + server := &http.Server{ Addr: cfg.Addr, - Handler: deps.mux, + Handler: handler, } return &App{ @@ -36,6 +47,25 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { }, 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, ErrInvalidTrustedProxies + } + middlewares = append(middlewares, trustedProxies.BehindProxy) + } + + if cfg.Password != "" || cfg.SecretKey != "" { + auth := middleware.NewAuth(cfg.Password, cfg.SecretKey) + middlewares = append(middlewares, auth.Protect) + } + + return server.Chain(mux, middlewares...), nil +} + func (a *App) Start(ctx context.Context) error { serverErr := make(chan error, 1) go func() { diff --git a/internal/app/deps.go b/internal/app/deps.go index d4e47fa..67e2b82 100644 --- a/internal/app/deps.go +++ b/internal/app/deps.go @@ -12,6 +12,7 @@ import ( "github.com/GaIsBAX/Webhix/internal/repos" "github.com/GaIsBAX/Webhix/internal/server" "github.com/GaIsBAX/Webhix/internal/store" + "github.com/GaIsBAX/Webhix/pkg" ) type dependencies struct { @@ -21,6 +22,7 @@ type dependencies struct { infra *infrastructure repos *repositories services *services + handlers *handlers } func newDependencies(ctx context.Context, cfg *config.Config) (*dependencies, error) { @@ -42,6 +44,8 @@ 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.registerRoutes() return &deps, nil } @@ -53,7 +57,9 @@ type services struct { } func newServices(repos *repositories) *services { - hook := core.NewHook(repos.hook) + hook := core.NewHook(repos.hook, func() string { + return pkg.GeneratePrefixedString("ho") + }) serve := core.NewServe(repos.serve) version := core.NewVersion() @@ -115,12 +121,12 @@ func newInfrastructure(ctx context.Context, cfg *config.Config) (*infrastructure } type handlers struct { - hook server.Hook + hook *server.Hook } func newHandlers(deps *dependencies) *handlers { return &handlers{ - hook: *server.NewHook(&server.HookDeps{ + hook: server.NewHook(&server.HookDeps{ Mux: deps.mux, Service: deps.services.hook, Hub: deps.infra.hub, @@ -132,3 +138,7 @@ func newHandlers(deps *dependencies) *handlers { }), } } + +func (h *handlers) registerRoutes() { + h.hook.RegisterRoutes() +} diff --git a/internal/cli/serve/command.go b/internal/cli/serve/command.go index cfd16c0..d3b04ea 100644 --- a/internal/cli/serve/command.go +++ b/internal/cli/serve/command.go @@ -5,7 +5,7 @@ import ( "log/slog" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/core" "github.com/spf13/cobra" ) @@ -15,16 +15,16 @@ const ( ) type Service interface { - Run(ctx context.Context, opts domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error + 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, domain.ServeStartFunc, error) + New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) } -type ServiceFactoryFunc func(ctx context.Context, cfg *config.Config) (Service, domain.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, domain.ServeStartFunc, error) { +func (f ServiceFactoryFunc) New(ctx context.Context, cfg *config.Config) (Service, core.ServeStartFunc, error) { return f(ctx, cfg) } diff --git a/internal/cli/serve/runner.go b/internal/cli/serve/runner.go index 32f77f2..da04e0d 100644 --- a/internal/cli/serve/runner.go +++ b/internal/cli/serve/runner.go @@ -5,11 +5,11 @@ import ( "log/slog" "github.com/GaIsBAX/Webhix/internal/config" - "github.com/GaIsBAX/Webhix/internal/domain" + "github.com/GaIsBAX/Webhix/internal/core" ) -func run(ctx context.Context, service Service, start domain.ServeStartFunc, cfg *config.Config, opts Options) error { - return service.Run(ctx, domain.ServeRunOptions{ +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) { diff --git a/internal/cli/version/command.go b/internal/cli/version/command.go index b711193..76fcc30 100644 --- a/internal/cli/version/command.go +++ b/internal/cli/version/command.go @@ -15,6 +15,13 @@ 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() @@ -39,11 +46,11 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { switch output { case outputJSON: encoder := json.NewEncoder(w) - return encoder.Encode(info) + return encoder.Encode(toContract(info)) case outputYAML: encoder := yaml.NewEncoder(w) - if err := encoder.Encode(info); err != nil { + if err := encoder.Encode(toContract(info)); err != nil { return err } return encoder.Close() @@ -60,3 +67,12 @@ func print(w io.Writer, info domain.VersionInfo, output string) error { 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/core/hook_core.go b/internal/core/hook_core.go index 5563ce1..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) @@ -17,18 +21,24 @@ type HookRepository interface { } type Hook struct { - repo HookRepository + repo HookRepository + generateToken TokenGenerator } -func NewHook(repo HookRepository) *Hook { +func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { + if generateToken == nil { + generateToken = func() string { return "" } + } + return &Hook{ - repo: repo, + repo: repo, + generateToken: generateToken, } } 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) @@ -49,6 +59,9 @@ func (s *Hook) ReceiveWebhook(ctx context.Context, token string, params domain.C 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 } @@ -70,7 +83,15 @@ func (s *Hook) GetHookResponse(ctx context.Context, token string) (domain.HookRe 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 *Hook) SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) { @@ -81,3 +102,10 @@ func (s *Hook) SetHookResponse(ctx context.Context, token string, params domain. 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 index 7f2c7d1..a4f397b 100644 --- a/internal/core/serve_core.go +++ b/internal/core/serve_core.go @@ -4,10 +4,15 @@ import ( "context" "errors" "time" - - "github.com/GaIsBAX/Webhix/internal/domain" ) +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) @@ -49,12 +54,12 @@ func (s *Serve) RetentionCleaner(ctx context.Context, retention time.Duration) ( } } -func (s *Serve) Run(ctx context.Context, opts domain.ServeRunOptions, start domain.ServeStartFunc, onRetentionError func(error)) error { +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 domain.ServeRunOptions, onError func(error)) { +func (s *Serve) StartRetentionCleaner(ctx context.Context, opts ServeRunOptions, onError func(error)) { if opts.Retention <= 0 || opts.ReadOnly { return } diff --git a/internal/domain/hook.go b/internal/domain/hook.go index 599b797..98911e2 100644 --- a/internal/domain/hook.go +++ b/internal/domain/hook.go @@ -1,41 +1,40 @@ package domain import ( - "context" "fmt" "time" ) 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 { @@ -56,13 +55,6 @@ type UpsertHookResponseParams struct { Body []byte } -type ServeRunOptions struct { - Retention time.Duration - ReadOnly bool -} - -type ServeStartFunc func(context.Context) error - func (p UpsertHookResponseParams) Validate() error { if p.StatusCode < 100 || p.StatusCode > 599 { return fmt.Errorf("statusCode must be between 100 and 599") diff --git a/internal/domain/version.go b/internal/domain/version.go index 93944d6..d44961d 100644 --- a/internal/domain/version.go +++ b/internal/domain/version.go @@ -1,8 +1,8 @@ package domain type VersionInfo 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"` + Version string + Commit string + Built string + Go string } diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 389991f..5cf2973 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -82,7 +82,7 @@ func (r *Hook) GetHookResponse(ctx context.Context, hookID int64) (domain.HookRe 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 } diff --git a/internal/server/handler.go b/internal/server/handler.go index 7b5766b..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,6 +22,12 @@ type HookService interface { SetHookResponse(ctx context.Context, token string, params domain.UpsertHookResponseParams) (domain.HookResponse, error) } +type EventBroker interface { + Done() <-chan struct{} + Subscribe(token string) (<-chan []byte, func()) + Publish(token string, data []byte) +} + type HookOptions struct { BaseURL string MaxBodySize int64 @@ -32,7 +37,7 @@ type HookOptions struct { type HookDeps struct { Mux *http.ServeMux Service HookService - Hub *hub.Hub + Hub EventBroker Opts HookOptions } @@ -62,7 +67,7 @@ func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { return } - contract, err := DecodeContract[CreateEndpointRequestContract](r) + contract, err := DecodeRequest[CreateEndpointRequestContract](r) if err != nil { slog.Error("create endpoint", "err", err) SendError(w, http.StatusInternalServerError, ErrInternal) @@ -265,7 +270,7 @@ func (h *Hook) SetResponse(w http.ResponseWriter, r *http.Request) { token := r.PathValue("token") - contract, err := DecodeContract[SetHookResponseRequestContract](r) + contract, err := DecodeRequest[SetHookResponseRequestContract](r) if err != nil { SendError(w, http.StatusBadRequest, ErrBadRequest) return 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 { From 2a7faae41ab4889c2af9dbb517fe1ddb006fce9c Mon Sep 17 00:00:00 2001 From: Denis Date: Mon, 18 May 2026 23:09:41 +0300 Subject: [PATCH 17/17] fix(app): configure HTTP read header timeout --- internal/app/app.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/app/app.go b/internal/app/app.go index d97668b..2672e38 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,7 +12,10 @@ import ( "github.com/GaIsBAX/Webhix/internal/server/middleware" ) -const shutdownTimeout = 10 * time.Second +const ( + readHeaderTimeout = 5 * time.Second + shutdownTimeout = 10 * time.Second +) type App struct { server *http.Server @@ -36,8 +39,9 @@ func New(ctx context.Context, cfg *config.Config) (*App, error) { } server := &http.Server{ - Addr: cfg.Addr, - Handler: handler, + Addr: cfg.Addr, + Handler: handler, + ReadHeaderTimeout: readHeaderTimeout, } return &App{