From 24cc9a3d24c2a56e47dd1d77f48eaf7b17921461 Mon Sep 17 00:00:00 2001 From: EOEboh Date: Wed, 15 Jul 2026 18:40:01 +0100 Subject: [PATCH 1/4] cli setup --- .github/workflows/ci.yml | 29 ++++ .github/workflows/release.yml | 30 ++++ .gitignore | 3 + cli/.goreleaser.yaml | 57 +++++++ cli/README.md | 59 +++++++ cli/cmd/endpoints.go | 42 +++++ cli/cmd/listen.go | 155 +++++++++++++++++ cli/cmd/login.go | 182 ++++++++++++++++++++ cli/cmd/logout.go | 36 ++++ cli/cmd/root.go | 52 ++++++ cli/cmd/whoami.go | 43 +++++ cli/go.mod | 14 ++ cli/go.sum | 14 ++ cli/internal/api/client.go | 89 ++++++++++ cli/internal/api/types.go | 55 ++++++ cli/internal/config/config.go | 129 ++++++++++++++ cli/internal/forward/forwarder.go | 131 +++++++++++++++ cli/internal/forward/forwarder_test.go | 108 ++++++++++++ cli/internal/output/color.go | 58 +++++++ cli/internal/output/printer.go | 119 +++++++++++++ cli/internal/output/vt_other.go | 5 + cli/internal/output/vt_windows.go | 23 +++ cli/internal/sseclient/client.go | 135 +++++++++++++++ cli/main.go | 21 +++ internal/auth/apitoken.go | 44 +++++ internal/handler/me.go | 51 ++++++ internal/handler/replay.go | 9 + internal/handler/requests.go | 14 +- internal/handler/session.go | 3 +- internal/handler/sse.go | 12 +- internal/handler/tokens.go | 135 +++++++++++++++ internal/middleware/auth.go | 44 ++++- internal/models/request.go | 15 ++ internal/session/session.go | 3 +- internal/store/api_tokens.go | 96 +++++++++++ internal/store/store.go | 55 +++++- internal/store/store_test.go | 180 ++++++++++++++++++++ main.go | 8 +- scripts/install.sh | 70 ++++++++ ui/src/App.tsx | 44 ++++- ui/src/api/client.ts | 30 +++- ui/src/components/layout/Sidebar.tsx | 6 + ui/src/pages/AuthCallbackPage.tsx | 12 +- ui/src/pages/CliAuthPage.tsx | 130 +++++++++++++++ ui/src/pages/SettingsTokensPage.tsx | 222 +++++++++++++++++++++++++ ui/src/types/index.ts | 21 ++- 46 files changed, 2769 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 cli/.goreleaser.yaml create mode 100644 cli/README.md create mode 100644 cli/cmd/endpoints.go create mode 100644 cli/cmd/listen.go create mode 100644 cli/cmd/login.go create mode 100644 cli/cmd/logout.go create mode 100644 cli/cmd/root.go create mode 100644 cli/cmd/whoami.go create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/internal/api/client.go create mode 100644 cli/internal/api/types.go create mode 100644 cli/internal/config/config.go create mode 100644 cli/internal/forward/forwarder.go create mode 100644 cli/internal/forward/forwarder_test.go create mode 100644 cli/internal/output/color.go create mode 100644 cli/internal/output/printer.go create mode 100644 cli/internal/output/vt_other.go create mode 100644 cli/internal/output/vt_windows.go create mode 100644 cli/internal/sseclient/client.go create mode 100644 cli/main.go create mode 100644 internal/auth/apitoken.go create mode 100644 internal/handler/me.go create mode 100644 internal/handler/tokens.go create mode 100644 internal/store/api_tokens.go create mode 100644 internal/store/store_test.go create mode 100755 scripts/install.sh create mode 100644 ui/src/pages/CliAuthPage.tsx create mode 100644 ui/src/pages/SettingsTokensPage.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..887c39f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: ci + +on: + pull_request: + push: + branches: [main] + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + - run: go build ./... && go vet ./... && go test ./... + + cli: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache-dependency-path: cli/go.sum + - working-directory: cli + env: + CGO_ENABLED: "0" + run: go build ./... && go vet ./... && go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..53747e4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,30 @@ +name: release + +on: + push: + tags: ["v*"] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + cache-dependency-path: cli/go.sum + + - uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + workdir: cli + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 6412cb4..1f51869 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ ui/.env .env.local .env + +# CLI build artifacts +cli/dist/ diff --git a/cli/.goreleaser.yaml b/cli/.goreleaser.yaml new file mode 100644 index 0000000..7d2e4b2 --- /dev/null +++ b/cli/.goreleaser.yaml @@ -0,0 +1,57 @@ +# goreleaser config for the hookdrop CLI. +# Released on v* tags via .github/workflows/release.yml (workdir: cli). +# The backend is deployed separately by scripts/deploy.sh and never tagged. +version: 2 + +project_name: hookdrop + +builds: + - main: . + binary: hookdrop + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.ShortCommit}} + - -X main.date={{.Date}} + +archives: + - formats: [tar.gz] + name_template: "hookdrop_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format_overrides: + - goos: windows + formats: [zip] + +checksum: + name_template: checksums.txt + +changelog: + filters: + exclude: + - "^docs:" + - "^test:" + +# goreleaser deprecates `brews` in favor of macOS-only `homebrew_casks`; +# we keep the formula so `brew install` also works on Homebrew-for-Linux. +brews: + - name: hookdrop + repository: + owner: EOEboh + name: homebrew-hookdrop + token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" + directory: Formula + homepage: https://hookdrop.app + description: Stream and forward hookdrop webhooks from your terminal + test: | + system "#{bin}/hookdrop --version" diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..52f1d03 --- /dev/null +++ b/cli/README.md @@ -0,0 +1,59 @@ +# hookdrop CLI + +Stream webhooks captured by [hookdrop](https://hookdrop.app) into your +terminal, and forward each one to a local server the hosted backend can't +reach. + +## Install + +```sh +# Homebrew (macOS / Linux) +brew install EOEboh/hookdrop/hookdrop + +# curl +curl -fsSL https://raw.githubusercontent.com/EOEboh/hookdrop/main/scripts/install.sh | sh +``` + +Windows binaries are on [GitHub Releases](https://github.com/EOEboh/hookdrop/releases). +(Note: replacing `hookdrop.exe` while a `listen` session is running will fail +on Windows — stop it first. On macOS/Linux, upgrading while running is fine; +the active session keeps the old binary until it exits.) + +## Use + +```sh +hookdrop login # browser-based; --token or --no-browser for headless +hookdrop whoami # account, plan, limits +hookdrop endpoints # list your named endpoints +hookdrop listen --endpoint my-slug # stream webhooks live +hookdrop listen --endpoint my-slug \ + --forward http://localhost:3000/webhook # …and re-send each one locally +hookdrop logout +``` + +Forwarded requests carry `X-Hookdrop-Forwarded: true` and +`X-Hookdrop-Original-Id` headers; hop-by-hop headers (`Host`, +`Content-Length`, …) are regenerated, everything else — including provider +signature headers — is preserved byte-for-byte. + +## Config + +Stored at the OS config dir (`~/.config/hookdrop/config.json` on Linux, +`~/Library/Application Support/hookdrop/config.json` on macOS, +`%AppData%\hookdrop\config.json` on Windows), file mode 0600. + +Environment overrides: `HOOKDROP_TOKEN`, `HOOKDROP_API_URL`, +`HOOKDROP_FRONTEND_URL`. `NO_COLOR` disables ANSI colors. + +## Development + +```sh +cd cli +CGO_ENABLED=0 go build -o hookdrop . +HOOKDROP_API_URL=http://localhost:8080 ./hookdrop login +``` + +Releases: push a `v*` tag — `.github/workflows/release.yml` runs goreleaser, +publishes GitHub Releases for darwin/linux (amd64+arm64) and windows/amd64, +and updates the Homebrew tap `EOEboh/homebrew-hookdrop` (requires the +`HOMEBREW_TAP_GITHUB_TOKEN` repo secret). diff --git a/cli/cmd/endpoints.go b/cli/cmd/endpoints.go new file mode 100644 index 0000000..f372363 --- /dev/null +++ b/cli/cmd/endpoints.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" +) + +var endpointsCmd = &cobra.Command{ + Use: "endpoints", + Short: "List your named endpoints (use a slug with 'hookdrop listen --endpoint')", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, cfg, err := loadClient() + if err != nil { + return err + } + endpoints, err := client.Endpoints(cmd.Context()) + if err != nil { + return friendlyAuthErr(err) + } + + if len(endpoints) == 0 { + fmt.Printf("No named endpoints yet. Create one at %s\n", strings.TrimRight(cfg.FrontendURL, "/")) + return nil + } + + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(w, "SLUG\tNAME\tINBOX URL") + for _, ep := range endpoints { + fmt.Fprintf(w, "%s\t%s\t%s/i/%s\n", ep.Slug, ep.Name, strings.TrimRight(cfg.APIURL, "/"), ep.Slug) + } + return w.Flush() + }, +} + +func init() { + rootCmd.AddCommand(endpointsCmd) +} diff --git a/cli/cmd/listen.go b/cli/cmd/listen.go new file mode 100644 index 0000000..710d210 --- /dev/null +++ b/cli/cmd/listen.go @@ -0,0 +1,155 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math/rand" + "net/url" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" + "github.com/EOEboh/hookdrop/cli/internal/forward" + "github.com/EOEboh/hookdrop/cli/internal/output" + "github.com/EOEboh/hookdrop/cli/internal/sseclient" + "github.com/spf13/cobra" +) + +const ( + reconnectBaseDelay = time.Second + reconnectMaxDelay = 30 * time.Second + // a connection that survives this long resets the backoff + stableConnection = time.Minute +) + +var ( + listenEndpoint string + listenForward string +) + +var listenCmd = &cobra.Command{ + Use: "listen --endpoint ", + Short: "Stream webhooks live into your terminal", + Long: `Streams every webhook hitting your hookdrop endpoint into the terminal, +one line per event. With --forward, each webhook is also re-sent to a local +URL — the same way the web UI's replay works — so your local dev server +receives traffic the hosted backend can't deliver directly.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if listenEndpoint == "" { + return errors.New("--endpoint is required. Run 'hookdrop endpoints' to list yours") + } + if listenForward != "" { + u, err := url.Parse(listenForward) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("--forward must be an http(s) URL, got %q", listenForward) + } + } + + client, cfg, err := loadClient() + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + return runListen(ctx, cfg.APIURL, client.Token) + }, +} + +func init() { + listenCmd.Flags().StringVar(&listenEndpoint, "endpoint", "", "named endpoint slug or temporary session ID") + listenCmd.Flags().StringVar(&listenForward, "forward", "", "forward each webhook to this local URL (e.g. http://localhost:3000/webhook)") + rootCmd.AddCommand(listenCmd) +} + +func runListen(ctx context.Context, apiURL, token string) error { + printer := output.NewPrinter() + defer printer.Close() + + forwarding := listenForward != "" + var fwd *forward.Forwarder + if forwarding { + fwd = forward.New(listenForward, func(res forward.Result) { + printer.Line(printer.ForwardResult(res)) + }) + fwd.Start(ctx) + } + + events := make(chan sseclient.Event, 256) + + // Renderer: the only consumer of the events channel + go func() { + for ev := range events { + switch ev.Name { + case "connected": + inbox := strings.TrimRight(apiURL, "/") + "/i/" + listenEndpoint + msg := "Listening on " + inbox + if forwarding { + msg += " → forwarding to " + listenForward + } + printer.Line(printer.Status(msg)) + case "request": + var req api.CapturedRequest + if err := json.Unmarshal(ev.Data, &req); err != nil { + printer.Line(printer.Status("skipped an event the CLI couldn't parse: " + err.Error())) + continue + } + printer.Line(printer.Event(&req, forwarding)) + if forwarding && !fwd.Enqueue(&req) { + printer.Line(printer.Status("⚠ forward queue full — dropped " + output.ShortID(req.ID) + " (still visible in the dashboard)")) + } + } + } + }() + defer close(events) + + // Connection loop: reconnect forever on transient failures, stop cleanly + // on auth/ownership errors or ctrl-c. + sse := sseclient.New(apiURL, token) + delay := reconnectBaseDelay + attempt := 0 + + for { + connectedAt := time.Now() + err := sse.Stream(ctx, listenEndpoint, events) + + switch { + case ctx.Err() != nil: + printer.Line(printer.Status("stopped")) + return nil + case errors.Is(err, api.ErrUnauthorized): + return errors.New("your session is no longer valid — the token may have been revoked. Run 'hookdrop login' again") + case errors.Is(err, api.ErrNotFound): + return fmt.Errorf("endpoint %q not found on your account (it may have expired if it was a temporary session). Run 'hookdrop endpoints' to see yours", listenEndpoint) + case errors.Is(err, api.ErrPaymentRequired): + return friendlyAuthErr(err) + } + + if time.Since(connectedAt) > stableConnection { + delay = reconnectBaseDelay + attempt = 0 + } + attempt++ + jittered := delay + time.Duration(rand.Int63n(int64(delay/2+1))) + printer.Line(printer.Status(fmt.Sprintf("⟳ connection lost (%v) — reconnecting in %s (attempt %d)", err, jittered.Round(time.Second), attempt))) + + select { + case <-time.After(jittered): + case <-ctx.Done(): + printer.Line(printer.Status("stopped")) + return nil + } + + delay *= 2 + if delay > reconnectMaxDelay { + delay = reconnectMaxDelay + } + } +} diff --git a/cli/cmd/login.go b/cli/cmd/login.go new file mode 100644 index 0000000..d2bf47f --- /dev/null +++ b/cli/cmd/login.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" + "github.com/EOEboh/hookdrop/cli/internal/config" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +const browserLoginTimeout = 3 * time.Minute + +var ( + loginToken string + loginNoBrowser bool +) + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate the CLI with your hookdrop account", + Long: `Opens your browser to authorize the CLI and stores an API token locally. + +Use --token to paste a token created at Settings → API tokens instead, +or --no-browser on headless machines.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + token := strings.TrimSpace(loginToken) + if token == "" && !loginNoBrowser { + token, err = browserLogin(cmd.Context(), cfg.FrontendURL) + if err != nil { + fmt.Fprintf(os.Stderr, "Browser login didn't complete (%v) — falling back to manual entry.\n", err) + } + } + if token == "" { + token, err = promptForToken(cfg.FrontendURL) + if err != nil { + return err + } + } + + if !strings.HasPrefix(token, "hkdp_") { + return errors.New("that doesn't look like a hookdrop API token (expected it to start with hkdp_)") + } + + // Verify before saving so a bad paste fails loudly + me, err := api.New(cfg.APIURL, token).Me(cmd.Context()) + if err != nil { + if errors.Is(err, api.ErrUnauthorized) { + return errors.New("that token is invalid, expired, or revoked") + } + return err + } + + cfg.Token = token + if err := config.Save(cfg); err != nil { + return err + } + + fmt.Printf("✓ Logged in as %s (%s plan)\n", me.User.Email, me.Plan) + return nil + }, +} + +func init() { + loginCmd.Flags().StringVar(&loginToken, "token", "", "API token (skips the browser flow)") + loginCmd.Flags().BoolVar(&loginNoBrowser, "no-browser", false, "don't open a browser; prompt for a token instead") + rootCmd.AddCommand(loginCmd) +} + +// browserLogin runs a loopback listener, sends the user's browser to the +// web app's /cli-auth page, and waits for it to deliver a freshly minted +// token back to us. The random state ties the callback to this attempt. +func browserLogin(ctx context.Context, frontendURL string) (string, error) { + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + return "", err + } + state := hex.EncodeToString(stateBytes) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", fmt.Errorf("start local listener: %w", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + + tokenCh := make(chan string, 1) + server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/callback" || r.URL.Query().Get("state") != state { + http.NotFound(w, r) + return + } + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, "missing token", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, ` +

✓ You're logged in

Return to your terminal — you can close this tab.

+`) + // Flush before signaling: the main goroutine closes the server as + // soon as it has the token, which would race the buffered response. + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + select { + case tokenCh <- token: + default: + } + })} + go server.Serve(listener) + defer server.Close() + + authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", + strings.TrimRight(frontendURL, "/"), port, url.QueryEscape(state)) + + fmt.Printf("Opening your browser to authorize the CLI…\n %s\n", authURL) + if err := openBrowser(authURL); err != nil { + fmt.Fprintln(os.Stderr, "Couldn't open a browser automatically — open the URL above manually.") + } + fmt.Println("Waiting for authorization…") + + select { + case token := <-tokenCh: + return token, nil + case <-time.After(browserLoginTimeout): + return "", errors.New("timed out waiting for the browser") + case <-ctx.Done(): + return "", ctx.Err() + } +} + +func promptForToken(frontendURL string) (string, error) { + fmt.Printf("Create a token at %s/settings/tokens\n", strings.TrimRight(frontendURL, "/")) + fmt.Print("Paste your API token: ") + + if term.IsTerminal(int(os.Stdin.Fd())) { + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil + } + + // Piped stdin (CI, scripts) + var token string + if _, err := fmt.Fscanln(os.Stdin, &token); err != nil { + return "", errors.New("no token provided") + } + return strings.TrimSpace(token), nil +} + +func openBrowser(u string) error { + switch runtime.GOOS { + case "darwin": + return exec.Command("open", u).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", u).Start() + default: + return exec.Command("xdg-open", u).Start() + } +} diff --git a/cli/cmd/logout.go b/cli/cmd/logout.go new file mode 100644 index 0000000..622be21 --- /dev/null +++ b/cli/cmd/logout.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/EOEboh/hookdrop/cli/internal/config" + "github.com/spf13/cobra" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove the stored API token from this machine", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.Token == "" { + fmt.Println("You're not logged in.") + return nil + } + cfg.Token = "" + if err := config.Save(cfg); err != nil { + return err + } + fmt.Printf("Logged out locally. To also revoke the token, visit %s/settings/tokens\n", + strings.TrimRight(cfg.FrontendURL, "/")) + return nil + }, +} + +func init() { + rootCmd.AddCommand(logoutCmd) +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 0000000..c1348ed --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,52 @@ +// Package cmd defines the hookdrop CLI commands. +package cmd + +import ( + "errors" + "fmt" + + "github.com/EOEboh/hookdrop/cli/internal/api" + "github.com/EOEboh/hookdrop/cli/internal/config" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "hookdrop", + Short: "Stream and forward your hookdrop webhooks from the terminal", + Long: "hookdrop streams webhooks captured by hookdrop.app into your terminal\nand can forward each one to a local server that the hosted backend can't reach.", + SilenceUsage: true, + SilenceErrors: false, +} + +func SetVersion(version, commit, date string) { + rootCmd.Version = fmt.Sprintf("%s (commit %s, built %s)", version, commit, date) +} + +func Execute() error { + return rootCmd.Execute() +} + +// loadClient builds an authenticated API client, or fails with the standard +// not-logged-in message. +func loadClient() (*api.Client, *config.Config, error) { + cfg, err := config.Load() + if err != nil { + return nil, nil, err + } + if cfg.Token == "" { + return nil, nil, errors.New("you're not logged in. Run 'hookdrop login' first") + } + return api.New(cfg.APIURL, cfg.Token), cfg, nil +} + +// friendlyAuthErr rewrites sentinel API errors into actionable messages. +func friendlyAuthErr(err error) error { + switch { + case errors.Is(err, api.ErrUnauthorized): + return errors.New("your token is invalid or was revoked. Run 'hookdrop login' again") + case errors.Is(err, api.ErrPaymentRequired): + return errors.New("your plan doesn't include this. Run 'hookdrop whoami' to see limits, or upgrade at " + config.DefaultFrontendURL + "/settings/billing") + default: + return err + } +} diff --git a/cli/cmd/whoami.go b/cli/cmd/whoami.go new file mode 100644 index 0000000..b84124f --- /dev/null +++ b/cli/cmd/whoami.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var whoamiCmd = &cobra.Command{ + Use: "whoami", + Short: "Show the logged-in account, plan, and limits", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := loadClient() + if err != nil { + return err + } + me, err := client.Me(cmd.Context()) + if err != nil { + return friendlyAuthErr(err) + } + + fmt.Println(me.User.Email) + fmt.Printf("Plan: %s\n", me.Plan) + fmt.Printf("Limits: %s named endpoints · %d requests/month · %d days history\n", + formatLimit(me.Limits.MaxNamedEndpoints), + me.Limits.MaxRequestsPerMonth, + me.Limits.HistoryDays, + ) + return nil + }, +} + +func formatLimit(n int) string { + if n < 0 { + return "unlimited" + } + return fmt.Sprintf("%d", n) +} + +func init() { + rootCmd.AddCommand(whoamiCmd) +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..546e203 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,14 @@ +module github.com/EOEboh/hookdrop/cli + +go 1.25.4 + +require ( + github.com/spf13/cobra v1.10.2 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 0000000..d73aa50 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,14 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go new file mode 100644 index 0000000..73b0ee5 --- /dev/null +++ b/cli/internal/api/client.go @@ -0,0 +1,89 @@ +// Package api is the HTTP client for the hookdrop backend. +package api + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Sentinel errors commands translate into friendly messages. +var ( + ErrUnauthorized = errors.New("unauthorized") + ErrNotFound = errors.New("not found") + ErrPaymentRequired = errors.New("plan upgrade required") +) + +type Client struct { + BaseURL string + Token string + HTTP *http.Client +} + +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + HTTP: &http.Client{Timeout: 15 * time.Second}, + } +} + +func (c *Client) get(ctx context.Context, path string, out interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + + resp, err := c.HTTP.Do(req) + if err != nil { + return fmt.Errorf("request %s: %w", path, err) + } + defer resp.Body.Close() + + if err := CheckStatus(resp); err != nil { + return err + } + return json.NewDecoder(resp.Body).Decode(out) +} + +// CheckStatus maps error statuses to sentinel errors. Shared with the SSE +// client so 401/404/402 behave identically everywhere. +func CheckStatus(resp *http.Response) error { + switch { + case resp.StatusCode < 400: + return nil + case resp.StatusCode == http.StatusUnauthorized: + return ErrUnauthorized + case resp.StatusCode == http.StatusNotFound: + return ErrNotFound + case resp.StatusCode == http.StatusPaymentRequired: + return ErrPaymentRequired + default: + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("server returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } +} + +// Me validates the token and returns the account's identity, plan, and limits. +func (c *Client) Me(ctx context.Context) (*Me, error) { + var me Me + if err := c.get(ctx, "/me", &me); err != nil { + return nil, err + } + return &me, nil +} + +// Endpoints lists the user's named endpoints. +func (c *Client) Endpoints(ctx context.Context) ([]Endpoint, error) { + var eps []Endpoint + if err := c.get(ctx, "/endpoints", &eps); err != nil { + return nil, err + } + return eps, nil +} diff --git a/cli/internal/api/types.go b/cli/internal/api/types.go new file mode 100644 index 0000000..8de891e --- /dev/null +++ b/cli/internal/api/types.go @@ -0,0 +1,55 @@ +package api + +import "time" + +// Wire types mirroring the backend's JSON responses. Source of truth: +// internal/models/request.go in the backend module — keep field names and +// JSON tags in sync (internal/ packages can't be imported across modules). + +// CapturedRequest is one webhook as delivered over SSE and /requests. +// Body arrives base64-encoded on the wire; encoding/json decodes it into +// raw bytes automatically. +type CapturedRequest struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Body []byte `json:"body"` + BodySize int `json:"body_size"` + RemoteIP string `json:"remote_ip"` + ReceivedAt time.Time `json:"received_at"` + Verified string `json:"verified"` // "verified", "failed", "unverified" + Provider string `json:"provider"` +} + +type Endpoint struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type User struct { + ID string `json:"id"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +// Limits uses the backend's default (un-tagged) Go field serialization. +type Limits struct { + MaxNamedEndpoints int `json:"MaxNamedEndpoints"` // -1 = unlimited + MaxRequestsPerMonth int `json:"MaxRequestsPerMonth"` + HistoryDays int `json:"HistoryDays"` + MaxSecrets int `json:"MaxSecrets"` + HasFiltering bool `json:"HasFiltering"` +} + +// Me is the GET /me response. +type Me struct { + User User `json:"user"` + Plan string `json:"plan"` + AuthMethod string `json:"auth_method"` + Limits Limits `json:"limits"` +} diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go new file mode 100644 index 0000000..e4741e3 --- /dev/null +++ b/cli/internal/config/config.go @@ -0,0 +1,129 @@ +// Package config manages the CLI's local configuration file, which holds +// the API token — permissions are locked down and writes are atomic. +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" +) + +const ( + // DefaultAPIURL is the hosted hookdrop backend. + DefaultAPIURL = "https://api.hookdrop.app" + // DefaultFrontendURL is the hosted web app, used for browser login. + DefaultFrontendURL = "https://hookdrop.app" +) + +type Config struct { + APIURL string `json:"api_url,omitempty"` + FrontendURL string `json:"frontend_url,omitempty"` + Token string `json:"token,omitempty"` +} + +// Path returns the config file location: +// macOS ~/Library/Application Support/hookdrop, Linux ~/.config/hookdrop, +// Windows %AppData%\hookdrop. +func Path() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", fmt.Errorf("locate config dir: %w", err) + } + return filepath.Join(dir, "hookdrop", "config.json"), nil +} + +// Load reads the config file, applying defaults and environment overrides +// (HOOKDROP_TOKEN, HOOKDROP_API_URL, HOOKDROP_FRONTEND_URL). A missing file +// is not an error; a corrupt one is, with a message pointing at the path. +// Loose file permissions produce a warning on stderr but do not fail. +func Load() (*Config, error) { + cfg := &Config{} + + path, err := Path() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + switch { + case errors.Is(err, os.ErrNotExist): + // fresh install — fall through to defaults + case err != nil: + return nil, fmt.Errorf("read config %s: %w", path, err) + default: + if jsonErr := json.Unmarshal(data, cfg); jsonErr != nil { + return nil, fmt.Errorf("config file %s is corrupt (%v) — run 'hookdrop login' to recreate it", path, jsonErr) + } + warnLoosePermissions(path) + } + + if v := os.Getenv("HOOKDROP_TOKEN"); v != "" { + cfg.Token = v + } + if v := os.Getenv("HOOKDROP_API_URL"); v != "" { + cfg.APIURL = v + } + if v := os.Getenv("HOOKDROP_FRONTEND_URL"); v != "" { + cfg.FrontendURL = v + } + if cfg.APIURL == "" { + cfg.APIURL = DefaultAPIURL + } + if cfg.FrontendURL == "" { + cfg.FrontendURL = DefaultFrontendURL + } + return cfg, nil +} + +// Save writes the config atomically (temp file + rename) with 0600 perms. +func Save(cfg *Config) error { + path, err := Path() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create config dir: %w", err) + } + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + + tmp, err := os.CreateTemp(filepath.Dir(path), "config-*.json") + if err != nil { + return fmt.Errorf("write config: %w", err) + } + defer os.Remove(tmp.Name()) + + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return fmt.Errorf("set config permissions: %w", err) + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + return fmt.Errorf("write config: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write config: %w", err) + } + return os.Rename(tmp.Name(), path) +} + +func warnLoosePermissions(path string) { + if runtime.GOOS == "windows" { + return // unix permission bits are meaningless there + } + info, err := os.Stat(path) + if err != nil { + return + } + if info.Mode().Perm()&0o077 != 0 { + fmt.Fprintf(os.Stderr, + "warning: %s is readable by other users (mode %o) — consider: chmod 600 %s\n", + path, info.Mode().Perm(), path) + } +} diff --git a/cli/internal/forward/forwarder.go b/cli/internal/forward/forwarder.go new file mode 100644 index 0000000..12d889a --- /dev/null +++ b/cli/internal/forward/forwarder.go @@ -0,0 +1,131 @@ +// Package forward re-issues captured webhooks against a local target, +// mirroring the server-side replay engine's header semantics. +package forward + +import ( + "bytes" + "context" + "io" + "net/http" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" +) + +// forwardTimeout is deliberately shorter than the server replay engine's +// 30s: a local dev server that takes longer is effectively down, and a +// short timeout keeps the queue draining during bursts. +const forwardTimeout = 10 * time.Second + +// queueSize bounds in-flight webhooks. The worker is sequential, so this is +// also the burst ceiling before we start dropping (visibly) instead of +// hammering the local server or stalling the SSE reader. +const queueSize = 100 + +type Result struct { + Request *api.CapturedRequest + Status int + Latency time.Duration + Err error +} + +type Forwarder struct { + target string + client *http.Client + queue chan *api.CapturedRequest + onResult func(Result) +} + +// New creates a forwarder targeting url. onResult is called from the worker +// goroutine for every attempted delivery. +func New(target string, onResult func(Result)) *Forwarder { + return &Forwarder{ + target: target, + client: &http.Client{ + Timeout: forwardTimeout, + // Match the replay engine: hand redirects back as-is so the + // developer sees exactly what their server responded. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + }, + queue: make(chan *api.CapturedRequest, queueSize), + onResult: onResult, + } +} + +// Start runs the single sequential delivery worker until ctx is cancelled. +func (f *Forwarder) Start(ctx context.Context) { + go func() { + for { + select { + case req := <-f.queue: + f.onResult(f.deliver(ctx, req)) + case <-ctx.Done(): + return + } + } + }() +} + +// Enqueue adds a webhook to the delivery queue. Returns false when the +// queue is full — callers should surface the drop, never block the SSE +// reader on a slow local server. +func (f *Forwarder) Enqueue(req *api.CapturedRequest) bool { + select { + case f.queue <- req: + return true + default: + return false + } +} + +func (f *Forwarder) deliver(ctx context.Context, original *api.CapturedRequest) Result { + outbound, err := http.NewRequestWithContext(ctx, original.Method, f.target, bytes.NewReader(original.Body)) + if err != nil { + return Result{Request: original, Err: err} + } + + for key, val := range original.Headers { + if shouldSkipHeader(key) { + continue + } + outbound.Header.Set(key, val) + } + + // Distinct from the replay engine's X-Hookdrop-Replay so dev servers can + // tell live CLI forwards from dashboard replays. + outbound.Header.Set("X-Hookdrop-Forwarded", "true") + outbound.Header.Set("X-Hookdrop-Original-Id", original.ID) + + start := time.Now() + resp, err := f.client.Do(outbound) + latency := time.Since(start) + if err != nil { + return Result{Request: original, Latency: latency, Err: err} + } + defer resp.Body.Close() + + // Drain (bounded to the same 1 MB cap the server replay engine uses) + // so keep-alive connections can be reused + io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + + return Result{Request: original, Status: resp.StatusCode, Latency: latency} +} + +// shouldSkipHeader filters headers that break or are meaningless when +// forwarded. Parity port of shouldSkipHeader in the backend's +// internal/replay/engine.go — keep the two lists identical so behavior +// matches whether a request is replayed from the web UI or forwarded live. +func shouldSkipHeader(key string) bool { + skip := map[string]bool{ + "Host": true, // rewritten by Go's HTTP client automatically + "Content-Length": true, // recalculated by Go's HTTP client based on body size + "Transfer-Encoding": true, + "Connection": true, + "Te": true, + "Trailers": true, + "Upgrade": true, + } + return skip[http.CanonicalHeaderKey(key)] +} diff --git a/cli/internal/forward/forwarder_test.go b/cli/internal/forward/forwarder_test.go new file mode 100644 index 0000000..a2c5866 --- /dev/null +++ b/cli/internal/forward/forwarder_test.go @@ -0,0 +1,108 @@ +package forward + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" +) + +// TestSkipListParity pins the exact header skip list ported from the +// backend's internal/replay/engine.go. If this test needs changing, change +// the server list too. +func TestSkipListParity(t *testing.T) { + skipped := []string{"Host", "Content-Length", "Transfer-Encoding", "Connection", "Te", "Trailers", "Upgrade"} + for _, h := range skipped { + if !shouldSkipHeader(h) { + t.Errorf("%s should be skipped", h) + } + } + // case-insensitive via canonicalization + if !shouldSkipHeader("content-length") || !shouldSkipHeader("HOST") { + t.Error("skip list must be case-insensitive") + } + for _, h := range []string{"Content-Type", "X-Signature", "Stripe-Signature", "User-Agent", "Authorization"} { + if shouldSkipHeader(h) { + t.Errorf("%s should be forwarded", h) + } + } +} + +func TestDeliverForwardsHeadersAndBody(t *testing.T) { + var got *http.Request + var gotBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r.Clone(context.Background()) + buf := make([]byte, r.ContentLength) + r.Body.Read(buf) + gotBody = buf + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + f := New(srv.URL, func(Result) {}) + res := f.deliver(context.Background(), &api.CapturedRequest{ + ID: "req-1", + Method: "POST", + Headers: map[string]string{ + "Content-Type": "application/json", + "Stripe-Signature": "sig123", + "Host": "should-not-forward.example", + "Content-Length": "999", + }, + Body: []byte(`{"a":1}`), + }) + + if res.Err != nil { + t.Fatalf("deliver: %v", res.Err) + } + if res.Status != http.StatusAccepted { + t.Fatalf("status = %d, want 202", res.Status) + } + if string(gotBody) != `{"a":1}` { + t.Fatalf("body = %q", gotBody) + } + if got.Header.Get("Stripe-Signature") != "sig123" || got.Header.Get("Content-Type") != "application/json" { + t.Fatal("expected original headers forwarded") + } + if got.Host == "should-not-forward.example" { + t.Fatal("Host header must be rewritten, not forwarded") + } + if got.Header.Get("X-Hookdrop-Forwarded") != "true" || got.Header.Get("X-Hookdrop-Original-Id") != "req-1" { + t.Fatal("expected forward marker headers") + } + if got.ContentLength != 7 { + t.Fatalf("Content-Length = %d, want recalculated 7", got.ContentLength) + } +} + +func TestDeliverUnreachableTarget(t *testing.T) { + f := New("http://127.0.0.1:1", func(Result) {}) // port 1: nothing listens + res := f.deliver(context.Background(), &api.CapturedRequest{ID: "x", Method: "POST"}) + if res.Err == nil { + t.Fatal("expected connection error") + } +} + +func TestEnqueueOverflowDropsInsteadOfBlocking(t *testing.T) { + f := New("http://127.0.0.1:1", func(Result) {}) + // worker not started — queue just fills + for i := 0; i < queueSize; i++ { + if !f.Enqueue(&api.CapturedRequest{}) { + t.Fatalf("enqueue %d should succeed", i) + } + } + done := make(chan bool, 1) + go func() { done <- f.Enqueue(&api.CapturedRequest{}) }() + select { + case ok := <-done: + if ok { + t.Fatal("expected overflow enqueue to report a drop") + } + case <-time.After(time.Second): + t.Fatal("Enqueue blocked on a full queue") + } +} diff --git a/cli/internal/output/color.go b/cli/internal/output/color.go new file mode 100644 index 0000000..6804969 --- /dev/null +++ b/cli/internal/output/color.go @@ -0,0 +1,58 @@ +// Package output renders events to the terminal: ANSI colors with automatic +// downgrade, and a single-writer printer that keeps concurrent updates from +// interleaving. +package output + +import ( + "os" + + "golang.org/x/term" +) + +type Style string + +const ( + Reset Style = "\x1b[0m" + Bold Style = "\x1b[1m" + Dim Style = "\x1b[2m" + Red Style = "\x1b[31m" + Green Style = "\x1b[32m" + Yellow Style = "\x1b[33m" + Cyan Style = "\x1b[36m" +) + +// ColorsEnabled reports whether stdout should receive ANSI codes: +// disabled when piped, when NO_COLOR is set, or on TERM=dumb. +func ColorsEnabled() bool { + if os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb" { + return false + } + if !term.IsTerminal(int(os.Stdout.Fd())) { + return false + } + return enableVT() // no-op except legacy Windows consoles +} + +// Colorize wraps s in the style when enabled is true. +func Colorize(enabled bool, style Style, s string) string { + if !enabled || style == "" { + return s + } + return string(style) + s + string(Reset) +} + +// MethodStyle color-codes HTTP methods. +func MethodStyle(method string) Style { + switch method { + case "GET": + return Cyan + case "POST": + return Green + case "PUT", "PATCH": + return Yellow + case "DELETE": + return Red + default: + return "" + } +} diff --git a/cli/internal/output/printer.go b/cli/internal/output/printer.go new file mode 100644 index 0000000..52025ec --- /dev/null +++ b/cli/internal/output/printer.go @@ -0,0 +1,119 @@ +package output + +import ( + "fmt" + "os" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" + "github.com/EOEboh/hookdrop/cli/internal/forward" +) + +// Printer serializes all terminal writes through one goroutine so event +// lines, forward results, and status messages never interleave, no matter +// how fast webhooks arrive. +type Printer struct { + lines chan string + done chan struct{} + Colors bool +} + +func NewPrinter() *Printer { + p := &Printer{ + lines: make(chan string, 256), + done: make(chan struct{}), + Colors: ColorsEnabled(), + } + go func() { + defer close(p.done) + for line := range p.lines { + fmt.Fprintln(os.Stdout, line) + } + }() + return p +} + +// Line queues a line for printing. +func (p *Printer) Line(s string) { + p.lines <- s +} + +// Close flushes queued lines and stops the writer. +func (p *Printer) Close() { + close(p.lines) + <-p.done +} + +// Event renders the one-line summary of a captured webhook: +// +// 12:04:31 POST ✓ stripe 1.2 KB +// +// showID appends a short request id used to correlate forward results. +func (p *Printer) Event(req *api.CapturedRequest, showID bool) string { + ts := Colorize(p.Colors, Dim, req.ReceivedAt.Local().Format("15:04:05")) + method := Colorize(p.Colors, MethodStyle(req.Method), fmt.Sprintf("%-6s", req.Method)) + + var verify string + switch req.Verified { + case "verified": + verify = Colorize(p.Colors, Green, "✓") + case "failed": + verify = Colorize(p.Colors, Red, "✗") + default: + verify = Colorize(p.Colors, Dim, "–") + } + if req.Provider != "" { + verify += " " + Colorize(p.Colors, Dim, req.Provider) + } + + line := fmt.Sprintf("%s %s %s %s", ts, method, verify, FormatSize(req.BodySize)) + if showID { + line += " " + Colorize(p.Colors, Dim, "("+ShortID(req.ID)+")") + } + return line +} + +// ForwardResult renders the delivery outcome, indented under its event: +// +// ↳ (4dbb48bd) 200 in 45ms +func (p *Printer) ForwardResult(res forward.Result) string { + id := Colorize(p.Colors, Dim, "("+ShortID(res.Request.ID)+")") + if res.Err != nil { + return fmt.Sprintf(" ↳ %s %s", id, Colorize(p.Colors, Red, "forward failed: "+res.Err.Error())) + } + + style := Green + if res.Status >= 500 { + style = Red + } else if res.Status >= 400 { + style = Yellow + } + return fmt.Sprintf(" ↳ %s %s in %s", + id, + Colorize(p.Colors, style, fmt.Sprintf("%d", res.Status)), + res.Latency.Round(time.Millisecond), + ) +} + +// Status renders dim informational lines (connecting, reconnecting…). +func (p *Printer) Status(s string) string { + return Colorize(p.Colors, Dim, s) +} + +func ShortID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +func FormatSize(n int) string { + switch { + case n >= 1<<20: + return fmt.Sprintf("%.1f MB", float64(n)/(1<<20)) + case n >= 1<<10: + return fmt.Sprintf("%.1f KB", float64(n)/(1<<10)) + default: + return fmt.Sprintf("%d B", n) + } +} diff --git a/cli/internal/output/vt_other.go b/cli/internal/output/vt_other.go new file mode 100644 index 0000000..7dd3fec --- /dev/null +++ b/cli/internal/output/vt_other.go @@ -0,0 +1,5 @@ +//go:build !windows + +package output + +func enableVT() bool { return true } diff --git a/cli/internal/output/vt_windows.go b/cli/internal/output/vt_windows.go new file mode 100644 index 0000000..6155216 --- /dev/null +++ b/cli/internal/output/vt_windows.go @@ -0,0 +1,23 @@ +//go:build windows + +package output + +import ( + "os" + + "golang.org/x/sys/windows" +) + +// enableVT switches the console to virtual-terminal (ANSI) mode. +// Windows Terminal has it on already; legacy conhost needs the flag. +func enableVT() bool { + handle := windows.Handle(os.Stdout.Fd()) + var mode uint32 + if err := windows.GetConsoleMode(handle, &mode); err != nil { + return false + } + if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0 { + return true + } + return windows.SetConsoleMode(handle, mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) == nil +} diff --git a/cli/internal/sseclient/client.go b/cli/internal/sseclient/client.go new file mode 100644 index 0000000..53bde46 --- /dev/null +++ b/cli/internal/sseclient/client.go @@ -0,0 +1,135 @@ +// Package sseclient is a minimal Server-Sent Events client for the hookdrop +// /events stream. The format is three line types: "event:", "data:", and +// ":" comments (keepalives) — no library needed. +package sseclient + +import ( + "bufio" + "bytes" + "context" + "fmt" + "net" + "net/http" + "strings" + "sync/atomic" + "time" + + "github.com/EOEboh/hookdrop/cli/internal/api" +) + +// watchdogTimeout kills connections that go silent. The server sends a +// keepalive comment every 20s, so 60s = three missed beats. This is what +// detects laptop-sleep and NAT-drop zombies that never error. +const watchdogTimeout = 60 * time.Second + +// maxLineSize caps a single SSE line so a pathological payload errors the +// stream (and triggers a reconnect) instead of exhausting memory. +const maxLineSize = 16 << 20 // 16 MB + +type Event struct { + Name string + Data []byte +} + +type Client struct { + BaseURL string + Token string + http *http.Client +} + +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + Token: token, + // No overall client timeout — the stream is long-lived. Individual + // phases are bounded instead; mid-stream silence is the watchdog's job. + http: &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + }, + }, + } +} + +// Stream connects to /events/{identifier} and delivers events until the +// connection ends. It always returns a non-nil error describing why the +// stream stopped (including ctx cancellation); auth/ownership problems +// surface as api.ErrUnauthorized / api.ErrNotFound so callers can stop +// retrying. +func (c *Client) Stream(ctx context.Context, identifier string, events chan<- Event) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/events/"+identifier, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.Token) + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Cache-Control", "no-cache") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("connect: %w", err) + } + defer resp.Body.Close() + + if err := api.CheckStatus(resp); err != nil { + return err + } + + var watchdogFired atomic.Bool + watchdog := time.AfterFunc(watchdogTimeout, func() { + watchdogFired.Store(true) + cancel() + }) + defer watchdog.Stop() + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64<<10), maxLineSize) + + var eventName string + var data bytes.Buffer + + for scanner.Scan() { + watchdog.Reset(watchdogTimeout) + line := scanner.Bytes() + + switch { + case len(line) == 0: + // blank line = dispatch + if eventName != "" || data.Len() > 0 { + ev := Event{Name: eventName, Data: append([]byte(nil), data.Bytes()...)} + eventName = "" + data.Reset() + select { + case events <- ev: + case <-ctx.Done(): + return ctx.Err() + } + } + case line[0] == ':': + // comment/keepalive — watchdog already reset + case bytes.HasPrefix(line, []byte("event:")): + eventName = string(bytes.TrimSpace(line[len("event:"):])) + case bytes.HasPrefix(line, []byte("data:")): + if data.Len() > 0 { + data.WriteByte('\n') + } + data.Write(bytes.TrimSpace(line[len("data:"):])) + } + } + + if watchdogFired.Load() { + return fmt.Errorf("no data for %s — connection stale", watchdogTimeout) + } + if ctx.Err() != nil { + return ctx.Err() + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("stream read: %w", err) + } + return fmt.Errorf("server closed the stream") +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..fb5c06c --- /dev/null +++ b/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/EOEboh/hookdrop/cli/cmd" +) + +// Set via goreleaser ldflags +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + cmd.SetVersion(version, commit, date) + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/internal/auth/apitoken.go b/internal/auth/apitoken.go new file mode 100644 index 0000000..b2b112e --- /dev/null +++ b/internal/auth/apitoken.go @@ -0,0 +1,44 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "math/big" +) + +// APITokenPrefix makes tokens greppable and lets the auth middleware route +// them without touching the JWT path. Full format: hkdp_[A-Za-z0-9]{40}. +const APITokenPrefix = "hkdp_" + +const ( + apiTokenAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + apiTokenLength = 40 // ~238 bits of entropy + // APITokenDisplayLength is how much of the token is stored and shown for + // identification (prefix + 7 chars). + APITokenDisplayLength = 12 +) + +// GenerateAPIToken returns the full secret token (shown to the user exactly +// once), the hex SHA-256 hash to persist, and the display prefix. +func GenerateAPIToken() (token, hash, prefix string, err error) { + buf := make([]byte, 0, len(APITokenPrefix)+apiTokenLength) + buf = append(buf, APITokenPrefix...) + max := big.NewInt(int64(len(apiTokenAlphabet))) + for i := 0; i < apiTokenLength; i++ { + n, err := rand.Int(rand.Reader, max) + if err != nil { + return "", "", "", fmt.Errorf("generate token: %w", err) + } + buf = append(buf, apiTokenAlphabet[n.Int64()]) + } + token = string(buf) + return token, HashAPIToken(token), token[:APITokenDisplayLength], nil +} + +// HashAPIToken is the canonical hash used both at creation and lookup. +func HashAPIToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/handler/me.go b/internal/handler/me.go new file mode 100644 index 0000000..dafab2e --- /dev/null +++ b/internal/handler/me.go @@ -0,0 +1,51 @@ +package handler + +import ( + "encoding/json" + "net/http" + + "github.com/EOEboh/hookdrop/internal/billing" + "github.com/EOEboh/hookdrop/internal/middleware" + "github.com/EOEboh/hookdrop/internal/store" +) + +// MeHandler returns the authenticated user's identity, plan, and limits. +// Works with both JWTs and API tokens — the CLI uses it for `hookdrop whoami` +// and to validate a token at login. +type MeHandler struct { + Store *store.Store +} + +func (h *MeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + userCtx := middleware.GetUser(r) + user, err := h.Store.GetUserByID(userCtx.ID) + if err != nil || user == nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + + sub, err := h.Store.GetSubscription(userCtx.ID) + if err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + + // Inactive subscriptions fall back to free limits + plan := sub.Plan + if !billing.IsActive(sub.Status, sub.CurrentPeriodEnd) { + plan = string(billing.PlanFree) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "user": user, + "plan": plan, + "auth_method": userCtx.AuthMethod, + "limits": billing.GetLimits(plan), + }) +} diff --git a/internal/handler/replay.go b/internal/handler/replay.go index 1e0030b..eb747ee 100644 --- a/internal/handler/replay.go +++ b/internal/handler/replay.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "github.com/EOEboh/hookdrop/internal/middleware" "github.com/EOEboh/hookdrop/internal/models" "github.com/EOEboh/hookdrop/internal/replay" "github.com/EOEboh/hookdrop/internal/store" @@ -43,6 +44,14 @@ func (h *ReplayHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Ownership check on the captured request's session/endpoint — foreign + // requests return the same 404 as missing ones. + user := middleware.GetUser(r) + if _, ok := h.Store.ResolveIdentifierForUser(original.SessionID, user.ID); !ok { + http.Error(w, "request not found", http.StatusNotFound) + return + } + // 3. Fire the replay result, err := h.Engine.Replay(r.Context(), original, &req) if err != nil { diff --git a/internal/handler/requests.go b/internal/handler/requests.go index bfa45b0..eb6f0d2 100644 --- a/internal/handler/requests.go +++ b/internal/handler/requests.go @@ -5,6 +5,7 @@ import ( "net/http" "strings" + "github.com/EOEboh/hookdrop/internal/middleware" "github.com/EOEboh/hookdrop/internal/models" "github.com/EOEboh/hookdrop/internal/store" ) @@ -14,15 +15,20 @@ type RequestsHandler struct { } func (h *RequestsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - sessionID := strings.TrimPrefix(r.URL.Path, "/requests/") - sessionID = strings.Trim(sessionID, "/") + identifier := strings.TrimPrefix(r.URL.Path, "/requests/") + identifier = strings.Trim(identifier, "/") - if sessionID == "" { + if identifier == "" { http.Error(w, "missing session id", http.StatusBadRequest) return } - if !h.Store.IdentifierExists(sessionID) { + // Resolve slug/endpoint/session to the canonical ID requests are stored + // under, and enforce ownership — foreign resources return the same 404 as + // missing ones. + user := middleware.GetUser(r) + sessionID, ok := h.Store.ResolveIdentifierForUser(identifier, user.ID) + if !ok { http.Error(w, "session not found", http.StatusNotFound) return } diff --git a/internal/handler/session.go b/internal/handler/session.go index 33ba3fc..fedb2bd 100644 --- a/internal/handler/session.go +++ b/internal/handler/session.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" + "github.com/EOEboh/hookdrop/internal/middleware" "github.com/EOEboh/hookdrop/internal/session" ) @@ -17,7 +18,7 @@ func (h *SessionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - sess, err := h.Manager.CreateSession() + sess, err := h.Manager.CreateSession(middleware.GetUser(r).ID) if err != nil { http.Error(w, "failed to create session", http.StatusInternalServerError) return diff --git a/internal/handler/sse.go b/internal/handler/sse.go index 0318be8..b0b7599 100644 --- a/internal/handler/sse.go +++ b/internal/handler/sse.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/EOEboh/hookdrop/internal/middleware" "github.com/EOEboh/hookdrop/internal/sse" "github.com/EOEboh/hookdrop/internal/store" ) @@ -18,10 +19,15 @@ type SSEHandler struct { func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - sessionID := strings.TrimPrefix(r.URL.Path, "/events/") - sessionID = strings.Trim(sessionID, "/") + identifier := strings.TrimPrefix(r.URL.Path, "/events/") + identifier = strings.Trim(identifier, "/") - if sessionID == "" || !h.Store.IdentifierExists(sessionID) { + // Resolve slug/endpoint/session to the canonical ID the broadcaster keys + // by, and enforce ownership — foreign resources look identical to missing + // ones (404, no existence leak). + user := middleware.GetUser(r) + sessionID, ok := h.Store.ResolveIdentifierForUser(identifier, user.ID) + if identifier == "" || !ok { http.Error(w, "session not found", http.StatusNotFound) return } diff --git a/internal/handler/tokens.go b/internal/handler/tokens.go new file mode 100644 index 0000000..37e7331 --- /dev/null +++ b/internal/handler/tokens.go @@ -0,0 +1,135 @@ +package handler + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/EOEboh/hookdrop/internal/auth" + "github.com/EOEboh/hookdrop/internal/middleware" + "github.com/EOEboh/hookdrop/internal/models" + "github.com/EOEboh/hookdrop/internal/store" + "github.com/google/uuid" +) + +// TokensHandler manages long-lived API tokens (used by the CLI). +// All routes are JWT-only: a leaked API token must not be able to mint +// replacements or revoke the user's other tokens. +type TokensHandler struct { + Store *store.Store +} + +func (h *TokensHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + user := middleware.GetUser(r) + if user.AuthMethod != middleware.AuthMethodJWT { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "error": "api_token_cannot_manage_tokens", + }) + return + } + + id := strings.Trim(strings.TrimPrefix(r.URL.Path, "/tokens"), "/") + + switch { + case id == "" && r.Method == http.MethodGet: + h.list(w, user.ID) + case id == "" && r.Method == http.MethodPost: + h.create(w, r, user.ID) + case id == "" && r.Method == http.MethodDelete: + h.revokeAll(w, user.ID) + case id != "" && r.Method == http.MethodDelete: + h.revoke(w, id, user.ID) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *TokensHandler) create(w http.ResponseWriter, r *http.Request, userID string) { + var body struct { + Name string `json:"name"` + ExpiresInDays int `json:"expires_in_days"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + body.Name = strings.TrimSpace(body.Name) + if body.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if body.ExpiresInDays < 0 || body.ExpiresInDays > 3650 { + http.Error(w, "expires_in_days must be between 0 and 3650", http.StatusBadRequest) + return + } + + token, hash, prefix, err := auth.GenerateAPIToken() + if err != nil { + http.Error(w, "failed to generate token", http.StatusInternalServerError) + return + } + + var expiresAt *time.Time + if body.ExpiresInDays > 0 { + t := time.Now().UTC().AddDate(0, 0, body.ExpiresInDays) + expiresAt = &t + } + + apiToken := &models.APIToken{ + ID: uuid.NewString(), + UserID: userID, + Name: body.Name, + TokenHash: hash, + TokenPrefix: prefix, + CreatedAt: time.Now().UTC(), + ExpiresAt: expiresAt, + } + if err := h.Store.CreateAPIToken(apiToken); err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + + // The only response that ever contains the full token + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": apiToken.ID, + "name": apiToken.Name, + "token": token, + "token_prefix": apiToken.TokenPrefix, + "created_at": apiToken.CreatedAt, + "expires_at": apiToken.ExpiresAt, + }) +} + +func (h *TokensHandler) list(w http.ResponseWriter, userID string) { + tokens, err := h.Store.ListAPITokens(userID) + if err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + if tokens == nil { + tokens = []*models.APIToken{} + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(tokens) +} + +func (h *TokensHandler) revoke(w http.ResponseWriter, id, userID string) { + if err := h.Store.RevokeAPIToken(id, userID); err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *TokensHandler) revokeAll(w http.ResponseWriter, userID string) { + if err := h.Store.RevokeAllAPITokens(userID); err != nil { + http.Error(w, "store error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index ea80458..d9bc5df 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,20 +4,30 @@ import ( "context" "net/http" "strings" + "time" "github.com/EOEboh/hookdrop/internal/auth" + "github.com/EOEboh/hookdrop/internal/store" ) type contextKey string const UserContextKey contextKey = "user" +// Auth methods — token-management routes are JWT-only so a leaked API token +// cannot mint or revoke tokens. +const ( + AuthMethodJWT = "jwt" + AuthMethodAPIToken = "api_token" +) + type UserContext struct { - ID string - Email string + ID string + Email string + AuthMethod string // AuthMethodJWT or AuthMethodAPIToken } -func Auth(jwtSecret string) func(http.Handler) http.Handler { +func Auth(jwtSecret string, st *store.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tokenStr := "" @@ -38,6 +48,29 @@ func Auth(jwtSecret string) func(http.Handler) http.Handler { return } + // API tokens (hkdp_...) are looked up by hash; anything else is a JWT + if strings.HasPrefix(tokenStr, auth.APITokenPrefix) { + apiToken, err := st.GetAPITokenByHash(auth.HashAPIToken(tokenStr)) + if err != nil || apiToken == nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + user, err := st.GetUserByID(apiToken.UserID) + if err != nil || user == nil { + http.Error(w, "invalid token", http.StatusUnauthorized) + return + } + go st.TouchAPIToken(apiToken.ID, time.Now().UTC()) + + ctx := context.WithValue(r.Context(), UserContextKey, &UserContext{ + ID: user.ID, + Email: user.Email, + AuthMethod: AuthMethodAPIToken, + }) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + claims, err := auth.ValidateToken(tokenStr, jwtSecret) if err != nil { http.Error(w, "invalid token", http.StatusUnauthorized) @@ -45,8 +78,9 @@ func Auth(jwtSecret string) func(http.Handler) http.Handler { } ctx := context.WithValue(r.Context(), UserContextKey, &UserContext{ - ID: claims.UserID, - Email: claims.Email, + ID: claims.UserID, + Email: claims.Email, + AuthMethod: AuthMethodJWT, }) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/models/request.go b/internal/models/request.go index 8aa82f0..704a81b 100644 --- a/internal/models/request.go +++ b/internal/models/request.go @@ -26,6 +26,7 @@ type Endpoint struct { type Session struct { ID string `json:"id"` + UserID string `json:"user_id,omitempty"` // empty for legacy anonymous sessions CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at"` } @@ -90,6 +91,20 @@ type Subscription struct { UpdatedAt time.Time `json:"updated_at"` } +// APIToken is a long-lived, revocable credential for the CLI and other +// non-browser clients. Only the SHA-256 hash of the token is persisted. +type APIToken struct { + ID string `json:"id"` + UserID string `json:"user_id"` + Name string `json:"name"` + TokenHash string `json:"-"` + TokenPrefix string `json:"token_prefix"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at"` + ExpiresAt *time.Time `json:"expires_at"` + RevokedAt *time.Time `json:"revoked_at"` +} + type PlanLimit struct { MaxNamedEndpoints int MaxRequestsPerMonth int diff --git a/internal/session/session.go b/internal/session/session.go index 369019b..137f306 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -19,9 +19,10 @@ func NewManager(s *store.Store) *Manager { return &Manager{Store: s} } -func (m *Manager) CreateSession() (*models.Session, error) { +func (m *Manager) CreateSession(userID string) (*models.Session, error) { session := &models.Session{ ID: uuid.NewString()[:8], // short ID — easier to read in URLs + UserID: userID, CreatedAt: time.Now().UTC(), ExpiresAt: time.Now().UTC().Add(DefaultTTL), } diff --git a/internal/store/api_tokens.go b/internal/store/api_tokens.go new file mode 100644 index 0000000..689741f --- /dev/null +++ b/internal/store/api_tokens.go @@ -0,0 +1,96 @@ +package store + +import ( + "database/sql" + "time" + + "github.com/EOEboh/hookdrop/internal/models" +) + +func (s *Store) CreateAPIToken(t *models.APIToken) error { + _, err := s.db.Exec( + `INSERT INTO api_tokens (id, user_id, name, token_hash, token_prefix, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + t.ID, t.UserID, t.Name, t.TokenHash, t.TokenPrefix, t.CreatedAt, t.ExpiresAt, + ) + return err +} + +// GetAPITokenByHash returns the token only if it is active — revoked or +// expired tokens are filtered out in SQL so callers can't misuse them. +func (s *Store) GetAPITokenByHash(hash string) (*models.APIToken, error) { + t := &models.APIToken{} + err := s.db.QueryRow( + `SELECT id, user_id, name, token_hash, token_prefix, created_at, last_used_at, expires_at, revoked_at + FROM api_tokens + WHERE token_hash = ? + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?)`, + hash, time.Now().UTC(), + ).Scan( + &t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.TokenPrefix, + &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt, &t.RevokedAt, + ) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return t, nil +} + +func (s *Store) ListAPITokens(userID string) ([]*models.APIToken, error) { + rows, err := s.db.Query( + `SELECT id, user_id, name, token_hash, token_prefix, created_at, last_used_at, expires_at, revoked_at + FROM api_tokens WHERE user_id = ? + ORDER BY created_at DESC`, userID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var results []*models.APIToken + for rows.Next() { + t := &models.APIToken{} + err := rows.Scan( + &t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.TokenPrefix, + &t.CreatedAt, &t.LastUsedAt, &t.ExpiresAt, &t.RevokedAt, + ) + if err != nil { + return nil, err + } + results = append(results, t) + } + return results, rows.Err() +} + +func (s *Store) RevokeAPIToken(id, userID string) error { + _, err := s.db.Exec( + `UPDATE api_tokens SET revoked_at = ? + WHERE id = ? AND user_id = ? AND revoked_at IS NULL`, + time.Now().UTC(), id, userID, + ) + return err +} + +func (s *Store) RevokeAllAPITokens(userID string) error { + _, err := s.db.Exec( + `UPDATE api_tokens SET revoked_at = ? + WHERE user_id = ? AND revoked_at IS NULL`, + time.Now().UTC(), userID, + ) + return err +} + +// TouchAPIToken records usage, throttled to one write per 5 minutes per +// token via the WHERE clause — no in-memory state needed. +func (s *Store) TouchAPIToken(id string, now time.Time) error { + _, err := s.db.Exec( + `UPDATE api_tokens SET last_used_at = ? + WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)`, + now, id, now.Add(-5*time.Minute), + ) + return err +} diff --git a/internal/store/store.go b/internal/store/store.go index 3926c5d..854b875 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -185,6 +185,22 @@ func (s *Store) migrate() error { CREATE INDEX IF NOT EXISTS idx_subscriptions_provider_sub ON subscriptions(provider_sub_id); + + CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + token_hash TEXT UNIQUE NOT NULL, -- hex(sha256(full token)); plaintext never stored + token_prefix TEXT NOT NULL, -- first chars only, for display + created_at DATETIME NOT NULL, + last_used_at DATETIME, + expires_at DATETIME, -- NULL = never expires + revoked_at DATETIME, -- NULL = active + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_api_tokens_user + ON api_tokens(user_id); ` if _, err := s.db.Exec(schema); err != nil { @@ -215,9 +231,14 @@ func (s *Store) migrate() error { } func (s *Store) CreateSession(session *models.Session) error { + // user_id stays NULL for legacy callers; owned sessions record their creator + var userID interface{} + if session.UserID != "" { + userID = session.UserID + } _, err := s.db.Exec( - `INSERT INTO sessions (id, created_at, expires_at) VALUES (?, ?, ?)`, - session.ID, session.CreatedAt, session.ExpiresAt, + `INSERT INTO sessions (id, created_at, expires_at, user_id) VALUES (?, ?, ?, ?)`, + session.ID, session.CreatedAt, session.ExpiresAt, userID, ) return err } @@ -535,6 +556,36 @@ func (s *Store) IdentifierExists(id string) bool { return s.SessionExists(id) || s.EndpointIDExists(id) } +// ResolveIdentifierForUser resolves a slug, endpoint ID, or session ID to the +// canonical identifier requests are stored/broadcast under, and checks the +// user may access it. ok=false covers both "not found" and "not yours" — +// callers must respond 404 so foreign resources are indistinguishable from +// missing ones (same convention as the secrets handler). +func (s *Store) ResolveIdentifierForUser(identifier, userID string) (string, bool) { + if ep, err := s.GetEndpointBySlug(identifier); err == nil && ep != nil { + return ep.ID, ep.UserID == userID + } + + if s.EndpointIDExists(identifier) { + return identifier, s.EndpointBelongsToUser(identifier, userID) + } + + var expiresAt time.Time + var sessionUserID sql.NullString + err := s.db.QueryRow( + `SELECT expires_at, user_id FROM sessions WHERE id = ?`, identifier, + ).Scan(&expiresAt, &sessionUserID) + if err != nil || !time.Now().Before(expiresAt) { + return "", false + } + // Legacy sessions (NULL user_id) are open to any authenticated user; + // owned sessions only to their creator. + if sessionUserID.Valid && sessionUserID.String != "" && sessionUserID.String != userID { + return "", false + } + return identifier, true +} + func (s *Store) SaveWebhookSecret(secret *models.WebhookSecret) error { _, err := s.db.Exec(` INSERT INTO webhook_secrets (id, endpoint_id, provider, secret, created_at) diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..59c014a --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,180 @@ +package store + +import ( + "path/filepath" + "testing" + "time" + + "github.com/EOEboh/hookdrop/internal/models" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + s, err := New(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("new store: %v", err) + } + return s +} + +func TestAPITokenLifecycle(t *testing.T) { + s := newTestStore(t) + + user, err := s.GetOrCreateUser("cli@example.com") + if err != nil { + t.Fatalf("create user: %v", err) + } + + tok := &models.APIToken{ + ID: "tok-1", + UserID: user.ID, + Name: "CLI on test", + TokenHash: "hash-1", + TokenPrefix: "hkdp_abc1234", + CreatedAt: time.Now().UTC(), + } + if err := s.CreateAPIToken(tok); err != nil { + t.Fatalf("create token: %v", err) + } + + got, err := s.GetAPITokenByHash("hash-1") + if err != nil || got == nil { + t.Fatalf("lookup active token: got %v, err %v", got, err) + } + if got.UserID != user.ID { + t.Fatalf("token user = %q, want %q", got.UserID, user.ID) + } + + if got, _ := s.GetAPITokenByHash("wrong-hash"); got != nil { + t.Fatal("unknown hash should return nil") + } + + // Expired tokens are filtered in SQL + past := time.Now().UTC().Add(-time.Hour) + expired := &models.APIToken{ + ID: "tok-2", UserID: user.ID, Name: "expired", + TokenHash: "hash-2", TokenPrefix: "hkdp_def5678", + CreatedAt: time.Now().UTC(), ExpiresAt: &past, + } + if err := s.CreateAPIToken(expired); err != nil { + t.Fatalf("create expired token: %v", err) + } + if got, _ := s.GetAPITokenByHash("hash-2"); got != nil { + t.Fatal("expired token should not resolve") + } + + // Revocation + if err := s.RevokeAPIToken("tok-1", user.ID); err != nil { + t.Fatalf("revoke: %v", err) + } + if got, _ := s.GetAPITokenByHash("hash-1"); got != nil { + t.Fatal("revoked token should not resolve") + } + + tokens, err := s.ListAPITokens(user.ID) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(tokens) != 2 { + t.Fatalf("list returned %d tokens, want 2", len(tokens)) + } + + // Revoke-all covers remaining active tokens + tok3 := &models.APIToken{ + ID: "tok-3", UserID: user.ID, Name: "third", + TokenHash: "hash-3", TokenPrefix: "hkdp_ghi9012", + CreatedAt: time.Now().UTC(), + } + if err := s.CreateAPIToken(tok3); err != nil { + t.Fatalf("create third token: %v", err) + } + if err := s.RevokeAllAPITokens(user.ID); err != nil { + t.Fatalf("revoke all: %v", err) + } + if got, _ := s.GetAPITokenByHash("hash-3"); got != nil { + t.Fatal("token should be revoked after revoke-all") + } +} + +func TestResolveIdentifierForUser(t *testing.T) { + s := newTestStore(t) + + owner, err := s.GetOrCreateUser("owner@example.com") + if err != nil { + t.Fatalf("create owner: %v", err) + } + other, err := s.GetOrCreateUser("other@example.com") + if err != nil { + t.Fatalf("create other: %v", err) + } + + ep := &models.Endpoint{ + ID: "ep-uuid-1", + UserID: owner.ID, + Slug: "my-slug", + Name: "My endpoint", + CreatedAt: time.Now().UTC(), + } + if err := s.CreateEndpoint(ep); err != nil { + t.Fatalf("create endpoint: %v", err) + } + + ownedSession := &models.Session{ + ID: "sess1234", + UserID: owner.ID, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := s.CreateSession(ownedSession); err != nil { + t.Fatalf("create owned session: %v", err) + } + + legacySession := &models.Session{ + ID: "sess5678", + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + } + if err := s.CreateSession(legacySession); err != nil { + t.Fatalf("create legacy session: %v", err) + } + + expiredSession := &models.Session{ + ID: "sess9999", + UserID: owner.ID, + CreatedAt: time.Now().UTC().Add(-25 * time.Hour), + ExpiresAt: time.Now().UTC().Add(-time.Hour), + } + if err := s.CreateSession(expiredSession); err != nil { + t.Fatalf("create expired session: %v", err) + } + + tests := []struct { + name string + identifier string + userID string + wantID string + wantOK bool + }{ + {"slug resolves to endpoint ID for owner", "my-slug", owner.ID, ep.ID, true}, + {"slug denied for other user", "my-slug", other.ID, "", false}, + {"endpoint UUID allowed for owner", ep.ID, owner.ID, ep.ID, true}, + {"endpoint UUID denied for other user", ep.ID, other.ID, "", false}, + {"owned session allowed for owner", "sess1234", owner.ID, "sess1234", true}, + {"owned session denied for other user", "sess1234", other.ID, "", false}, + {"legacy NULL-user session open to anyone", "sess5678", other.ID, "sess5678", true}, + {"expired session denied even for owner", "sess9999", owner.ID, "", false}, + {"unknown identifier denied", "nope", owner.ID, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, gotOK := s.ResolveIdentifierForUser(tt.identifier, tt.userID) + if gotOK != tt.wantOK { + t.Fatalf("ok = %v, want %v", gotOK, tt.wantOK) + } + if tt.wantOK && gotID != tt.wantID { + t.Fatalf("canonical ID = %q, want %q", gotID, tt.wantID) + } + }) + } +} diff --git a/main.go b/main.go index a569aea..a629bb1 100644 --- a/main.go +++ b/main.go @@ -132,7 +132,7 @@ func main() { Version: getEnv("APP_VERSION", "dev"), } secretsHandler := &handler.SecretsHandler{Store: st} - requireAuth := middleware.Auth(jwtSecret) + requireAuth := middleware.Auth(jwtSecret, st) inboxLimiter := middleware.InboxRateLimit(rateLimiter) // ── Routes @@ -162,6 +162,12 @@ func main() { mux.Handle("/billing/cancel", requireAuth(http.HandlerFunc(billingHandler.CancelSubscription))) + // Account + API tokens — authenticated (token management is JWT-only, + // enforced inside TokensHandler) + mux.Handle("/me", requireAuth(&handler.MeHandler{Store: st})) + mux.Handle("/tokens", requireAuth(&handler.TokensHandler{Store: st})) + mux.Handle("/tokens/", requireAuth(&handler.TokensHandler{Store: st})) + // Core — authenticated mux.Handle("/sessions", requireAuth(&handler.SessionHandler{Manager: mgr})) mux.Handle("/requests/", requireAuth(&handler.RequestsHandler{Store: st})) diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..96c3657 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# hookdrop CLI installer +# curl -fsSL https://raw.githubusercontent.com/EOEboh/hookdrop/main/scripts/install.sh | sh +# +# Downloads the latest release for this OS/arch from GitHub Releases, +# verifies its sha256 against checksums.txt, and installs to +# /usr/local/bin (if writable) or ~/.local/bin. +set -eu + +REPO="EOEboh/hookdrop" +BINARY="hookdrop" + +os=$(uname -s) +case "$os" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + *) echo "Unsupported OS: $os (use GitHub Releases directly: https://github.com/$REPO/releases)"; exit 1 ;; +esac + +arch=$(uname -m) +case "$arch" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) echo "Unsupported architecture: $arch"; exit 1 ;; +esac + +echo "Finding the latest release…" +tag=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" \ + | grep '"tag_name"' | head -1 | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') +[ -n "$tag" ] || { echo "Could not determine the latest release."; exit 1; } +version=${tag#v} + +archive="${BINARY}_${version}_${os}_${arch}.tar.gz" +base="https://github.com/$REPO/releases/download/$tag" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading $archive ($tag)…" +curl -fsSL -o "$tmp/$archive" "$base/$archive" +curl -fsSL -o "$tmp/checksums.txt" "$base/checksums.txt" + +echo "Verifying checksum…" +expected=$(grep " $archive\$" "$tmp/checksums.txt" | awk '{print $1}') +[ -n "$expected" ] || { echo "No checksum found for $archive"; exit 1; } +if command -v sha256sum >/dev/null 2>&1; then + actual=$(sha256sum "$tmp/$archive" | awk '{print $1}') +else + actual=$(shasum -a 256 "$tmp/$archive" | awk '{print $1}') +fi +[ "$expected" = "$actual" ] || { echo "Checksum mismatch — aborting."; exit 1; } + +tar -xzf "$tmp/$archive" -C "$tmp" "$BINARY" + +if [ -w /usr/local/bin ]; then + dest="/usr/local/bin" +else + dest="$HOME/.local/bin" + mkdir -p "$dest" +fi + +install -m 0755 "$tmp/$BINARY" "$dest/$BINARY" +echo "Installed $BINARY $tag to $dest/$BINARY" + +case ":$PATH:" in + *":$dest:"*) ;; + *) echo "Note: $dest is not on your PATH — add it to your shell profile." ;; +esac + +echo "Run '$BINARY login' to get started." diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4151397..36a6501 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -15,6 +15,8 @@ import { usePostHog } from '@posthog/react' import { PrivacyPage } from './pages/PrivacyPage' import { TermsPage } from './pages/TermsPage' import { TourProvider } from './components/onboarding/TourProvider' +import { SettingsTokensPage } from './pages/SettingsTokensPage' +import { CliAuthPage, stashCliAuthParams } from './pages/CliAuthPage' export default function App() { if (window.location.pathname === '/privacy') return @@ -39,13 +41,25 @@ export default function App() { } const urlError = new URLSearchParams(window.location.search).get('error') - if (!user) return + if (!user) { + // CLI browser login: remember port+state across the magic-link round-trip + if (window.location.pathname === '/cli-auth') stashCliAuthParams() + return + } - // Authenticated routes + // Authenticated routes if (window.location.pathname === '/settings/billing') { return } + if (window.location.pathname === '/settings/tokens') { + return + } + + if (window.location.pathname === '/cli-auth') { + return + } + return } @@ -79,6 +93,32 @@ function BillingPageShell({ onLogout }: { onLogout: () => void }) { ) } +// Same chrome as BillingPageShell, wrapping the API tokens page +function TokensPageShell({ onLogout }: { onLogout: () => void }) { + return ( +
+
+
+ + +
+ +
+ +
+ ) +} + function AuthenticatedApp({ onLogout }: { onLogout: () => void }) { const posthog = usePostHog() const { session, loading, error, resetSession } = useSession() diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index cba88ca..42e9c14 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,5 +1,5 @@ import type { - CapturedRequest, Endpoint, PlanLimits, ReplayRequest, + APIToken, CapturedRequest, CreatedAPIToken, Endpoint, PlanLimits, ReplayRequest, ReplayResponse, RequestFilters, Session, Subscription, WebhookSecret @@ -193,4 +193,32 @@ cancelSubscription(): Promise<{ headers: { ...authHeaders() }, }).then(handle<{ cancelled: boolean; cancel_at_period_end: boolean; access_until: string | null }>) }, + +listTokens(): Promise { + return fetch(`${BASE_URL}/tokens`, { + headers: { ...authHeaders() }, + }).then(handle) +}, + +createToken(data: { name: string; expires_in_days?: number }): Promise { + return fetch(`${BASE_URL}/tokens`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(data), + }).then(handle) +}, + +revokeToken(id: string): Promise { + return fetch(`${BASE_URL}/tokens/${id}`, { + method: 'DELETE', + headers: { ...authHeaders() }, + }).then(res => { if (!res.ok) throw new Error(`${res.status}`) }) +}, + +revokeAllTokens(): Promise { + return fetch(`${BASE_URL}/tokens`, { + method: 'DELETE', + headers: { ...authHeaders() }, + }).then(res => { if (!res.ok) throw new Error(`${res.status}`) }) +}, } \ No newline at end of file diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 236db55..9d80814 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -75,6 +75,12 @@ export function Sidebar({ > {isPro ? '⚡ Pro' : 'Upgrade'} + + API tokens + 65535 || !state) return null + return { port, state } +} + +export function stashCliAuthParams(): void { + const qs = new URLSearchParams(window.location.search) + const port = qs.get('port') + const state = qs.get('state') + if (port && state) { + sessionStorage.setItem(CLI_AUTH_KEY, JSON.stringify({ port, state })) + } +} + +export function hasPendingCliAuth(): boolean { + return sessionStorage.getItem(CLI_AUTH_KEY) !== null +} + +export function CliAuthPage() { + const { user } = useAuth() + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const params = getCliAuthParams() + + if (!params) { + return ( + +

+ Invalid CLI authorization link. Run hookdrop login again. +

+
+ ) + } + + async function handleAuthorize() { + if (!params) return + setBusy(true) + setError(null) + try { + const hostname = window.navigator.platform || 'device' + const created = await api.createToken({ + name: `CLI (browser login, ${hostname})`, + }) + sessionStorage.removeItem(CLI_AUTH_KEY) + // Top-level navigation to the loopback listener the CLI runs — + // navigation (unlike fetch) is exempt from mixed-content blocking. + window.location.href = + `http://127.0.0.1:${params.port}/callback` + + `?token=${encodeURIComponent(created.token)}&state=${encodeURIComponent(params.state)}` + } catch { + setError('Failed to create a token. Try again, or create one manually under Settings → API tokens.') + setBusy(false) + } + } + + function handleCancel() { + sessionStorage.removeItem(CLI_AUTH_KEY) + window.location.href = '/' + } + + return ( + +
+

Authorize hookdrop CLI

+

+ The hookdrop CLI on your machine is asking for an API token for{' '} + {user?.email}. The token lets it + stream and forward your webhooks until you revoke it. +

+ {error &&

{error}

} +
+ + +
+

+ Only continue if you just ran hookdrop login in your terminal. +

+
+
+ ) +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+
+
+ {children} +
+
+
+ ) +} diff --git a/ui/src/pages/SettingsTokensPage.tsx b/ui/src/pages/SettingsTokensPage.tsx new file mode 100644 index 0000000..5352859 --- /dev/null +++ b/ui/src/pages/SettingsTokensPage.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState } from 'react' +import { api } from '../api/client' +import { CopyButton } from '../components/ui/CopyButton' +import { Spinner } from '../components/ui/Spinner' +import type { APIToken, CreatedAPIToken } from '../types' + +function formatDate(iso: string | null): string { + if (!iso) return '—' + return new Date(iso).toLocaleDateString('en-GB', { + day: 'numeric', month: 'short', year: 'numeric', + }) +} + +export function SettingsTokensPage() { + const [tokens, setTokens] = useState(null) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [newToken, setNewToken] = useState(null) + const [name, setName] = useState('') + const [expiresDays, setExpiresDays] = useState(0) + const [busy, setBusy] = useState(false) + + async function refresh() { + try { + setTokens(await api.listTokens()) + } catch { + setError('Failed to load tokens') + } + } + + useEffect(() => { refresh() }, []) + + async function handleCreate() { + if (!name.trim()) return + setBusy(true) + setError(null) + try { + const created = await api.createToken({ + name: name.trim(), + ...(expiresDays > 0 ? { expires_in_days: expiresDays } : {}), + }) + setNewToken(created) + setCreating(false) + setName('') + setExpiresDays(0) + await refresh() + } catch { + setError('Failed to create token') + } finally { + setBusy(false) + } + } + + async function handleRevoke(id: string) { + setError(null) + try { + await api.revokeToken(id) + await refresh() + } catch { + setError('Failed to revoke token') + } + } + + async function handleRevokeAll() { + if (!window.confirm('Revoke ALL API tokens? Every CLI session using them will stop working.')) return + setError(null) + try { + await api.revokeAllTokens() + setNewToken(null) + await refresh() + } catch { + setError('Failed to revoke tokens') + } + } + + const activeTokens = tokens?.filter(t => !t.revoked_at) ?? [] + const revokedTokens = tokens?.filter(t => t.revoked_at) ?? [] + + return ( +
+
+
+

API tokens

+

+ Long-lived tokens for the hookdrop CLI. Run hookdrop login to + create one automatically, or create one here and paste it. +

+
+
+ {activeTokens.length > 0 && ( + + )} + +
+
+ + {error &&

{error}

} + + {/* One-time token reveal */} + {newToken && ( +
+

+ Token created — copy it now, you won't see it again +

+
+ + {newToken.token} + + +
+
+ )} + + {/* Create form */} + {creating && ( +
+
+ + setName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCreate() }} + placeholder="e.g. CLI on my laptop" + className="bg-surface border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-indigo-500" + /> +
+
+ + +
+
+ + +
+
+ )} + + {/* Token list */} + {tokens === null ? ( +
+ ) : activeTokens.length === 0 && !creating ? ( +

+ No active tokens yet. +

+ ) : ( +
+ {activeTokens.map(t => ( +
+
+

{t.name}

+

{t.token_prefix}…

+
+
+ {formatDate(t.created_at)} + + {t.last_used_at ? `used ${formatDate(t.last_used_at)}` : 'never used'} + + + {t.expires_at ? `expires ${formatDate(t.expires_at)}` : 'no expiry'} + + +
+
+ ))} +
+ )} + + {revokedTokens.length > 0 && ( +
+ Revoked tokens ({revokedTokens.length}) +
+ {revokedTokens.map(t => ( +
+
+

{t.name}

+

{t.token_prefix}…

+
+ revoked {formatDate(t.revoked_at)} +
+ ))} +
+
+ )} +
+ ) +} diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 31cb994..3a9c5e6 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -103,4 +103,23 @@ export interface BillingState { export type VerificationStatus = 'verified' | 'failed' | 'unverified' -export type ConnectionStatus = 'connecting' | 'live' | 'disconnected' \ No newline at end of file +export type ConnectionStatus = 'connecting' | 'live' | 'disconnected' +export interface APIToken { + id: string + user_id: string + name: string + token_prefix: string + created_at: string + last_used_at: string | null + expires_at: string | null + revoked_at: string | null +} + +export interface CreatedAPIToken { + id: string + name: string + token: string // full secret — only ever present in the create response + token_prefix: string + created_at: string + expires_at: string | null +} From 4c7f59716486160d7fa02041ece824b326aed8c7 Mon Sep 17 00:00:00 2001 From: EOEboh Date: Thu, 16 Jul 2026 12:15:30 +0100 Subject: [PATCH 2/4] feat(api): rate-limit API token creation to 10/user/hour A stolen 30-day web JWT could otherwise be burst-replayed into many long-lived hkdp_ tokens that outlive the JWT. Cap POST /tokens per user using the existing string-keyed sliding-hour limiter (keyed by user ID), returning 429 with Retry-After. List/revoke stay unthrottled. --- internal/handler/tokens.go | 10 +++++ internal/handler/tokens_test.go | 78 +++++++++++++++++++++++++++++++++ main.go | 5 ++- 3 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 internal/handler/tokens_test.go diff --git a/internal/handler/tokens.go b/internal/handler/tokens.go index 37e7331..551da52 100644 --- a/internal/handler/tokens.go +++ b/internal/handler/tokens.go @@ -18,6 +18,10 @@ import ( // replacements or revoke the user's other tokens. type TokensHandler struct { Store *store.Store + // MintLimiter caps token creation per user/hour so a stolen web JWT + // can't be burst-replayed into many long-lived tokens. It's the + // generic string-keyed sliding-hour limiter, keyed here by user ID. + MintLimiter *middleware.EmailRateLimiter } func (h *TokensHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -48,6 +52,12 @@ func (h *TokensHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *TokensHandler) create(w http.ResponseWriter, r *http.Request, userID string) { + if h.MintLimiter != nil && !h.MintLimiter.Allow(userID) { + w.Header().Set("Retry-After", "3600") + http.Error(w, "too many tokens created — try again later", http.StatusTooManyRequests) + return + } + var body struct { Name string `json:"name"` ExpiresInDays int `json:"expires_in_days"` diff --git a/internal/handler/tokens_test.go b/internal/handler/tokens_test.go new file mode 100644 index 0000000..80dc134 --- /dev/null +++ b/internal/handler/tokens_test.go @@ -0,0 +1,78 @@ +package handler + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/EOEboh/hookdrop/internal/middleware" + "github.com/EOEboh/hookdrop/internal/store" +) + +func newTokenTestHandler(t *testing.T, perHour int) (*TokensHandler, string) { + t.Helper() + st, err := store.New(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("new store: %v", err) + } + user, err := st.GetOrCreateUser("mint@example.com") + if err != nil { + t.Fatalf("create user: %v", err) + } + return &TokensHandler{Store: st, MintLimiter: middleware.NewEmailRateLimiter(perHour)}, user.ID +} + +// jwtContext simulates the auth middleware placing a JWT-authenticated user +// on the request (token management is JWT-only). +func jwtRequest(method, target, body, userID string) *http.Request { + req := httptest.NewRequest(method, target, strings.NewReader(body)) + ctx := context.WithValue(req.Context(), middleware.UserContextKey, &middleware.UserContext{ + ID: userID, + Email: "mint@example.com", + AuthMethod: middleware.AuthMethodJWT, + }) + return req.WithContext(ctx) +} + +func TestTokenMintRateLimited(t *testing.T) { + h, userID := newTokenTestHandler(t, 10) + + for i := 0; i < 10; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, jwtRequest(http.MethodPost, "/tokens", `{"name":"cli"}`, userID)) + if rec.Code != http.StatusCreated { + t.Fatalf("mint %d: got %d, want 201", i+1, rec.Code) + } + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, jwtRequest(http.MethodPost, "/tokens", `{"name":"cli"}`, userID)) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("11th mint: got %d, want 429", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("expected Retry-After header on 429") + } +} + +func TestApiTokenCannotManageTokens(t *testing.T) { + h, userID := newTokenTestHandler(t, 10) + + req := httptest.NewRequest(http.MethodPost, "/tokens", strings.NewReader(`{"name":"x"}`)) + ctx := context.WithValue(req.Context(), middleware.UserContextKey, &middleware.UserContext{ + ID: userID, + AuthMethod: middleware.AuthMethodAPIToken, + }) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req.WithContext(ctx)) + + if rec.Code != http.StatusForbidden { + t.Fatalf("api-token mint: got %d, want 403", rec.Code) + } + if !strings.Contains(rec.Body.String(), "api_token_cannot_manage_tokens") { + t.Errorf("unexpected body: %s", rec.Body.String()) + } +} diff --git a/main.go b/main.go index a629bb1..ec67b18 100644 --- a/main.go +++ b/main.go @@ -108,6 +108,7 @@ func main() { emailLimiter := middleware.NewEmailRateLimiter(5) // 5 per email per hour authIPLimiter := middleware.NewRateLimiter(10, 10.0/600) // 10 capacity, refills to 10 every 10 min + tokenMintLimiter := middleware.NewEmailRateLimiter(10) // 10 API tokens per user per hour // ── Handlers authHandler := &handler.AuthHandler{ @@ -165,8 +166,8 @@ func main() { // Account + API tokens — authenticated (token management is JWT-only, // enforced inside TokensHandler) mux.Handle("/me", requireAuth(&handler.MeHandler{Store: st})) - mux.Handle("/tokens", requireAuth(&handler.TokensHandler{Store: st})) - mux.Handle("/tokens/", requireAuth(&handler.TokensHandler{Store: st})) + mux.Handle("/tokens", requireAuth(&handler.TokensHandler{Store: st, MintLimiter: tokenMintLimiter})) + mux.Handle("/tokens/", requireAuth(&handler.TokensHandler{Store: st, MintLimiter: tokenMintLimiter})) // Core — authenticated mux.Handle("/sessions", requireAuth(&handler.SessionHandler{Manager: mgr})) From 86006894183fef8f1f14f13a69dc4db90e25b399 Mon Sep 17 00:00:00 2001 From: EOEboh Date: Thu, 16 Jul 2026 12:15:30 +0100 Subject: [PATCH 3/4] refactor(cli): extract waitForCallback for testable browser login Split the loopback callback server out of browserLogin into waitForCallback so the state check, token delivery, and timeout can be tested without opening a browser. Adds tests covering correct/wrong-state, missing token, honored timeout, and distinct ports for concurrent logins (the port-0 bind cannot collide). --- cli/cmd/login.go | 33 +++++--- cli/cmd/login_test.go | 187 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 cli/cmd/login_test.go diff --git a/cli/cmd/login.go b/cli/cmd/login.go index d2bf47f..a86e11b 100644 --- a/cli/cmd/login.go +++ b/cli/cmd/login.go @@ -95,6 +95,9 @@ func browserLogin(ctx context.Context, frontendURL string) (string, error) { } state := hex.EncodeToString(stateBytes) + // Port 0 => the OS assigns a free ephemeral port, so there is never a + // fixed-port collision. A bind failure (no loopback) propagates up and + // the caller falls back to the paste prompt. listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { return "", fmt.Errorf("start local listener: %w", err) @@ -102,6 +105,23 @@ func browserLogin(ctx context.Context, frontendURL string) (string, error) { defer listener.Close() port := listener.Addr().(*net.TCPAddr).Port + authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", + strings.TrimRight(frontendURL, "/"), port, url.QueryEscape(state)) + + fmt.Printf("Opening your browser to authorize the CLI…\n %s\n", authURL) + if err := openBrowser(authURL); err != nil { + fmt.Fprintln(os.Stderr, "Couldn't open a browser automatically — open the URL above manually.") + } + fmt.Println("Waiting for authorization…") + + return waitForCallback(ctx, listener, state, browserLoginTimeout) +} + +// waitForCallback serves a single /callback on listener and returns the +// token once the browser delivers it with the matching state. It returns +// an error on timeout or context cancellation. Split out from browserLogin +// so the callback mechanics are testable without opening a browser. +func waitForCallback(ctx context.Context, listener net.Listener, state string, timeout time.Duration) (string, error) { tokenCh := make(chan string, 1) server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/callback" || r.URL.Query().Get("state") != state { @@ -117,7 +137,7 @@ func browserLogin(ctx context.Context, frontendURL string) (string, error) { fmt.Fprint(w, `

✓ You're logged in

Return to your terminal — you can close this tab.

`) - // Flush before signaling: the main goroutine closes the server as + // Flush before signaling: the wait returns and closes the server as // soon as it has the token, which would race the buffered response. if f, ok := w.(http.Flusher); ok { f.Flush() @@ -130,19 +150,10 @@ func browserLogin(ctx context.Context, frontendURL string) (string, error) { go server.Serve(listener) defer server.Close() - authURL := fmt.Sprintf("%s/cli-auth?port=%d&state=%s", - strings.TrimRight(frontendURL, "/"), port, url.QueryEscape(state)) - - fmt.Printf("Opening your browser to authorize the CLI…\n %s\n", authURL) - if err := openBrowser(authURL); err != nil { - fmt.Fprintln(os.Stderr, "Couldn't open a browser automatically — open the URL above manually.") - } - fmt.Println("Waiting for authorization…") - select { case token := <-tokenCh: return token, nil - case <-time.After(browserLoginTimeout): + case <-time.After(timeout): return "", errors.New("timed out waiting for the browser") case <-ctx.Done(): return "", ctx.Err() diff --git a/cli/cmd/login_test.go b/cli/cmd/login_test.go new file mode 100644 index 0000000..44b60d7 --- /dev/null +++ b/cli/cmd/login_test.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "context" + "fmt" + "net" + "net/http" + "testing" + "time" +) + +// listen binds a fresh loopback listener the way browserLogin does (port 0). +func listen(t *testing.T) net.Listener { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + return l +} + +func callbackURL(l net.Listener, query string) string { + return fmt.Sprintf("http://%s/callback%s", l.Addr().String(), query) +} + +func TestWaitForCallbackDeliversToken(t *testing.T) { + l := listen(t) + result := make(chan string, 1) + go func() { + tok, err := waitForCallback(context.Background(), l, "s3cret", 5*time.Second) + if err != nil { + t.Errorf("waitForCallback: %v", err) + } + result <- tok + }() + + // give the server a moment to start + var resp *http.Response + var err error + for i := 0; i < 50; i++ { + resp, err = http.Get(callbackURL(l, "?state=s3cret&token=hkdp_abc")) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("callback GET: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("callback status = %d, want 200", resp.StatusCode) + } + + select { + case tok := <-result: + if tok != "hkdp_abc" { + t.Fatalf("token = %q, want hkdp_abc", tok) + } + case <-time.After(2 * time.Second): + t.Fatal("waitForCallback did not return the token") + } +} + +func TestWaitForCallbackRejectsWrongState(t *testing.T) { + l := listen(t) + done := make(chan struct{}) + go func() { + // short timeout: the wrong-state hit must NOT resolve, so this ends + // via timeout, proving the mismatched callback was ignored. + _, err := waitForCallback(context.Background(), l, "correct", 400*time.Millisecond) + if err == nil { + t.Error("expected timeout error, got nil (wrong state resolved the wait)") + } + close(done) + }() + + var resp *http.Response + var err error + for i := 0; i < 50; i++ { + resp, err = http.Get(callbackURL(l, "?state=wrong&token=hkdp_x")) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("callback GET: %v", err) + } + if resp.StatusCode != http.StatusNotFound { + t.Fatalf("wrong-state status = %d, want 404", resp.StatusCode) + } + resp.Body.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("waitForCallback should have timed out") + } +} + +func TestWaitForCallbackMissingToken(t *testing.T) { + l := listen(t) + go waitForCallback(context.Background(), l, "s", 400*time.Millisecond) + + var resp *http.Response + var err error + for i := 0; i < 50; i++ { + resp, err = http.Get(callbackURL(l, "?state=s")) + if err == nil { + break + } + time.Sleep(10 * time.Millisecond) + } + if err != nil { + t.Fatalf("callback GET: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("missing-token status = %d, want 400", resp.StatusCode) + } +} + +func TestWaitForCallbackTimesOut(t *testing.T) { + l := listen(t) + start := time.Now() + _, err := waitForCallback(context.Background(), l, "s", 200*time.Millisecond) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed := time.Since(start); elapsed < 150*time.Millisecond { + t.Fatalf("returned too early (%v) — timeout not honored", elapsed) + } +} + +// Two listeners bound back-to-back get distinct OS-assigned ports and each +// delivers independently — demonstrating the port-0 design has no +// fixed-port collision even with concurrent logins. +func TestConcurrentCallbacksDistinctPorts(t *testing.T) { + l1, l2 := listen(t), listen(t) + if l1.Addr().String() == l2.Addr().String() { + t.Fatal("expected distinct ports") + } + + res := make(chan string, 2) + for _, pair := range []struct { + l net.Listener + state string + tok string + }{{l1, "s1", "hkdp_one"}, {l2, "s2", "hkdp_two"}} { + p := pair + go func() { + tok, err := waitForCallback(context.Background(), p.l, p.state, 5*time.Second) + if err != nil { + t.Errorf("waitForCallback: %v", err) + } + res <- tok + }() + } + + deliver := func(l net.Listener, state, tok string) { + for i := 0; i < 50; i++ { + resp, err := http.Get(callbackURL(l, "?state="+state+"&token="+tok)) + if err == nil { + resp.Body.Close() + return + } + time.Sleep(10 * time.Millisecond) + } + t.Errorf("could not reach callback on %s", l.Addr()) + } + deliver(l1, "s1", "hkdp_one") + deliver(l2, "s2", "hkdp_two") + + got := map[string]bool{} + for i := 0; i < 2; i++ { + select { + case tok := <-res: + got[tok] = true + case <-time.After(2 * time.Second): + t.Fatal("a callback did not return") + } + } + if !got["hkdp_one"] || !got["hkdp_two"] { + t.Fatalf("expected both tokens, got %v", got) + } +} From 3398c407f096955e724c4a121a49ecb709a5cea9 Mon Sep 17 00:00:00 2001 From: EOEboh Date: Thu, 16 Jul 2026 12:32:42 +0100 Subject: [PATCH 4/4] ci: drop GitHub Actions workflows, release locally with goreleaser The account can't run Actions, so the CI checks block PRs and the tag-triggered release would fail the same way. Remove both workflows and document running goreleaser locally with free PATs instead. --- .github/workflows/ci.yml | 29 ----------------------------- .github/workflows/release.yml | 30 ------------------------------ cli/README.md | 21 +++++++++++++++++---- 3 files changed, 17 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 887c39f..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: ci - -on: - pull_request: - push: - branches: [main] - -jobs: - backend: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.23" - - run: go build ./... && go vet ./... && go test ./... - - cli: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 - with: - go-version: "1.23" - cache-dependency-path: cli/go.sum - - working-directory: cli - env: - CGO_ENABLED: "0" - run: go build ./... && go vet ./... && go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 53747e4..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: release - -on: - push: - tags: ["v*"] - -permissions: - contents: write - -jobs: - goreleaser: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v5 - with: - go-version: "1.23" - cache-dependency-path: cli/go.sum - - - uses: goreleaser/goreleaser-action@v6 - with: - version: "~> v2" - args: release --clean - workdir: cli - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} diff --git a/cli/README.md b/cli/README.md index 52f1d03..b71d4e0 100644 --- a/cli/README.md +++ b/cli/README.md @@ -53,7 +53,20 @@ CGO_ENABLED=0 go build -o hookdrop . HOOKDROP_API_URL=http://localhost:8080 ./hookdrop login ``` -Releases: push a `v*` tag — `.github/workflows/release.yml` runs goreleaser, -publishes GitHub Releases for darwin/linux (amd64+arm64) and windows/amd64, -and updates the Homebrew tap `EOEboh/homebrew-hookdrop` (requires the -`HOMEBREW_TAP_GITHUB_TOKEN` repo secret). +Releases are run locally with goreleaser (no GitHub Actions required). +Tag the release, then run goreleaser from `cli/` with two free personal +access tokens in the environment: + +```sh +git tag v0.1.0 +git push origin v0.1.0 + +cd cli +export GITHUB_TOKEN= +export HOMEBREW_TAP_GITHUB_TOKEN= +go run github.com/goreleaser/goreleaser/v2@latest release --clean +``` + +This publishes the GitHub Release for darwin/linux (amd64+arm64) and +windows/amd64, uploads `checksums.txt`, and updates the Homebrew tap +`EOEboh/homebrew-hookdrop`. `--snapshot --skip=publish` does a dry run.