Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
32 changes: 30 additions & 2 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
Expand Down
76 changes: 76 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
1 change: 1 addition & 0 deletions internal/api/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading