diff --git a/.goreleaser.yaml b/.goreleaser.yaml index c0eb3bd..45ea49b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -17,7 +17,7 @@ builds: goos: [linux, darwin, windows] goarch: [amd64, arm64] ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} + - -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X github.com/spoo-me/spoo-cli/internal/api.Version={{.Version}} archives: - formats: [tar.gz] diff --git a/internal/api/client.go b/internal/api/client.go index 297cb2a..e6d2ac0 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -10,12 +10,27 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" "github.com/spoo-me/spoo-cli/internal/auth" ) +// Version is the CLI release, injected by goreleaser via ldflags. +var Version = "dev" + +var versionRe = regexp.MustCompile(`^[A-Za-z0-9._-]{1,16}$`) + +// clientHeader identifies the CLI (and its version, when well-formed) to +// the backend so API traffic can be attributed per client. +func clientHeader() string { + if versionRe.MatchString(Version) { + return "cli/" + Version + } + return "cli" +} + type Client struct { base string http *http.Client @@ -24,8 +39,20 @@ type Client struct { func New(base string, store *auth.Store) *Client { return &Client{ - base: strings.TrimRight(base, "/"), - http: &http.Client{Timeout: 30 * time.Second}, + base: strings.TrimRight(base, "/"), + http: &http.Client{ + Timeout: 30 * time.Second, + // Go forwards custom headers on redirects, including + // cross-origin ones. Attribution belongs to the spoo API + // only, so drop it whenever a redirect leaves the original + // host. Go itself strips Authorization on cross-domain hops. + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if req.URL.Host != via[0].URL.Host { + req.Header.Del("X-Spoo-Client") + } + return nil + }, + }, store: store, } } @@ -96,6 +123,7 @@ func (c *Client) send(ctx context.Context, method, path string, query url.Values return nil, err } req.Header.Set("User-Agent", "spoo-cli") + req.Header.Set("X-Spoo-Client", clientHeader()) if body != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/internal/api/client_test.go b/internal/api/client_test.go index d92d2b8..8d24a4a 100644 --- a/internal/api/client_test.go +++ b/internal/api/client_test.go @@ -44,6 +44,39 @@ func TestDoSendsBearerToken(t *testing.T) { } } +func TestDoSendsClientHeader(t *testing.T) { + var gotClient string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/auth/me", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "cli/dev" { + t.Fatalf("X-Spoo-Client = %q, want cli/dev", gotClient) + } +} + +func TestClientHeaderRejectsMalformedVersion(t *testing.T) { + orig := Version + defer func() { Version = orig }() + for version, want := range map[string]string{ + "1.2.3": "cli/1.2.3", + "0.2.0-SNAPSHOT-697203b": "cli", // >16 chars + "1.0+meta": "cli", // invalid charset + "": "cli", + } { + Version = version + if got := clientHeader(); got != want { + t.Errorf("clientHeader() with Version=%q = %q, want %q", version, got, want) + } + } +} + func TestDoParsesErrorEnvelope(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusConflict) @@ -97,3 +130,46 @@ func TestDoRefreshesOn401AndRetries(t *testing.T) { t.Fatalf("store not updated after refresh: %+v", got) } } + +func TestClientHeaderStrippedOnCrossOriginRedirect(t *testing.T) { + gotClient := "unset" + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer target.Close() + + redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/final", http.StatusFound) + })) + defer redirector.Close() + + c := New(redirector.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "" { + t.Fatalf("X-Spoo-Client forwarded cross-origin = %q, want empty", gotClient) + } +} + +func TestClientHeaderKeptOnSameHostRedirect(t *testing.T) { + gotClient := "unset" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/start" { + http.Redirect(w, r, "/final", http.StatusFound) + return + } + gotClient = r.Header.Get("X-Spoo-Client") + w.Write([]byte(`{}`)) + })) + defer srv.Close() + + c := New(srv.URL, newTestStore(t, nil)) + if err := c.do(context.Background(), http.MethodGet, "/start", nil, nil, nil); err != nil { + t.Fatal(err) + } + if gotClient != "cli/dev" { + t.Fatalf("X-Spoo-Client after same-host redirect = %q, want cli/dev", gotClient) + } +} diff --git a/internal/api/inspect.go b/internal/api/inspect.go index 9486268..0be840f 100644 --- a/internal/api/inspect.go +++ b/internal/api/inspect.go @@ -28,6 +28,7 @@ func (c *Client) Inspect(ctx context.Context, shortCode string) (*InspectResult, return nil, err } req.Header.Set("User-Agent", "spoo-cli") + req.Header.Set("X-Spoo-Client", clientHeader()) resp, err := noFollow.Do(req) if err != nil { return nil, err