From 67ac9cea5dc45dabd89bdb7b85fecf8a3a0d0524 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Tue, 23 Jun 2026 19:34:53 +0300 Subject: [PATCH 1/6] Add opt-in Xquik tweet backend --- src/cli/help/help.go | 3 +- src/cli/tweet/post.go | 51 ++++++++------- src/cli/tweet/xquik.go | 120 ++++++++++++++++++++++++++++++++++ src/cli/tweet/xquik_test.go | 126 ++++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 src/cli/tweet/xquik.go create mode 100644 src/cli/tweet/xquik_test.go diff --git a/src/cli/help/help.go b/src/cli/help/help.go index 33f6aa8..3aba1a8 100644 --- a/src/cli/help/help.go +++ b/src/cli/help/help.go @@ -19,7 +19,8 @@ func Help() { fmt.Println(" -t \"text\" post a tweet") fmt.Println(" -v show version") fmt.Println(" -c clear authorized account") + fmt.Println(" X_BACKEND=xquik uses XQUIK_API_KEY and XQUIK_ACCOUNT") fmt.Println() fmt.Println("LEARN MORE") fmt.Println(" Cheack source code at: https://github.com/devhindo/x") -} \ No newline at end of file +} diff --git a/src/cli/tweet/post.go b/src/cli/tweet/post.go index ff5a473..53903a3 100644 --- a/src/cli/tweet/post.go +++ b/src/cli/tweet/post.go @@ -17,6 +17,13 @@ type Tweet struct { } func POST_tweet(t string) { + if useXquikBackend() { + if err := postTweetWithXquik(t); err != nil { + fmt.Println(err) + os.Exit(1) + } + return + } license, err := lock.ReadLicenseKeyFromFile() @@ -30,28 +37,28 @@ func POST_tweet(t string) { License: license, Tweet: t, } - + postT(url, tweet) } type response struct { - Message string `json:"message"` + Message string `json:"message"` } func postT(url string, t Tweet) { - jsonBytes, err := json.Marshal(t) - if err != nil { - panic(err) - } + jsonBytes, err := json.Marshal(t) + if err != nil { + panic(err) + } - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) - if err != nil { - fmt.Println("can't reach server to post a tweet") + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) + if err != nil { + fmt.Println("can't reach server to post a tweet") os.Exit(0) - } + } - defer resp.Body.Close() + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { @@ -59,16 +66,14 @@ func postT(url string, t Tweet) { panic(err) } + var r response + err = json.Unmarshal(body, &r) + if err != nil { + fmt.Printf("rate limit reached thanks Elon! Try again tomorrow when it resets, sorry.") + //Failed to unmarshal response. + return + } - var r response - err = json.Unmarshal(body, &r) - if err != nil { - fmt.Printf("rate limit reached thanks Elon! Try again tomorrow when it resets, sorry.") - //Failed to unmarshal response. - return - } - - - //Convert bytes to String and print - fmt.Println(r.Message) -} \ No newline at end of file + //Convert bytes to String and print + fmt.Println(r.Message) +} diff --git a/src/cli/tweet/xquik.go b/src/cli/tweet/xquik.go new file mode 100644 index 0000000..ce2a4f2 --- /dev/null +++ b/src/cli/tweet/xquik.go @@ -0,0 +1,120 @@ +package tweet + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" +) + +const defaultXquikBaseURL = "https://xquik.com" + +type xquikConfig struct { + APIKey string + Account string + BaseURL string +} + +type xquikTweetRequest struct { + Account string `json:"account"` + Text string `json:"text"` +} + +type xquikTweetResponse struct { + TweetID string `json:"tweetId"` + Success bool `json:"success"` + Status string `json:"status"` + WriteActionID string `json:"writeActionId"` + Message string `json:"message"` + Error string `json:"error"` +} + +func useXquikBackend() bool { + backend := os.Getenv("X_BACKEND") + if backend == "" { + backend = os.Getenv("TWITTER_BACKEND") + } + return strings.EqualFold(backend, "xquik") +} + +func xquikConfigFromEnv() (xquikConfig, error) { + cfg := xquikConfig{ + APIKey: strings.TrimSpace(os.Getenv("XQUIK_API_KEY")), + Account: strings.TrimSpace(os.Getenv("XQUIK_ACCOUNT")), + BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("XQUIK_API_BASE_URL")), "/"), + } + + if cfg.APIKey == "" { + return cfg, fmt.Errorf("XQUIK_API_KEY is required when X_BACKEND=xquik") + } + if cfg.Account == "" { + return cfg, fmt.Errorf("XQUIK_ACCOUNT is required when X_BACKEND=xquik") + } + if cfg.BaseURL == "" { + cfg.BaseURL = defaultXquikBaseURL + } + return cfg, nil +} + +func postTweetWithXquik(text string) error { + cfg, err := xquikConfigFromEnv() + if err != nil { + return err + } + return postTweetWithXquikClient(http.DefaultClient, cfg, text, os.Stdout) +} + +func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, out io.Writer) error { + payload := xquikTweetRequest{ + Account: cfg.Account, + Text: text, + } + jsonBytes, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequest("POST", cfg.BaseURL+"/api/v1/x/tweets", bytes.NewBuffer(jsonBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-API-Key", cfg.APIKey) + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("can't reach Xquik to post a tweet") + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("can't read Xquik response") + } + + var result xquikTweetResponse + if len(body) > 0 { + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("can't parse Xquik response") + } + } + + if resp.StatusCode == http.StatusOK && result.TweetID != "" { + fmt.Fprintf(out, "tweet posted with Xquik: %s\n", result.TweetID) + return nil + } + if resp.StatusCode == http.StatusAccepted && result.WriteActionID != "" { + fmt.Fprintf(out, "tweet accepted by Xquik; confirmation pending: %s\n", result.WriteActionID) + return nil + } + if result.Message != "" { + return fmt.Errorf("Xquik request failed: %s", result.Message) + } + if result.Error != "" { + return fmt.Errorf("Xquik request failed: %s", result.Error) + } + return fmt.Errorf("Xquik request failed with status %d", resp.StatusCode) +} diff --git a/src/cli/tweet/xquik_test.go b/src/cli/tweet/xquik_test.go new file mode 100644 index 0000000..5ad9956 --- /dev/null +++ b/src/cli/tweet/xquik_test.go @@ -0,0 +1,126 @@ +package tweet + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestUseXquikBackend(t *testing.T) { + t.Setenv("X_BACKEND", "xquik") + t.Setenv("TWITTER_BACKEND", "") + + if !useXquikBackend() { + t.Fatal("expected X_BACKEND=xquik to enable the Xquik backend") + } +} + +func TestXquikConfigFromEnvRequiresCredentials(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "") + t.Setenv("XQUIK_ACCOUNT", "") + t.Setenv("XQUIK_API_BASE_URL", "") + + _, err := xquikConfigFromEnv() + if err == nil || !strings.Contains(err.Error(), "XQUIK_API_KEY") { + t.Fatalf("expected missing API key error, got %v", err) + } +} + +func TestPostTweetWithXquikClientPostsTweet(t *testing.T) { + var gotRequest xquikTweetRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/api/v1/x/tweets" { + t.Fatalf("path = %s, want /api/v1/x/tweets", r.URL.Path) + } + if r.Header.Get("X-API-Key") != "test-key" { + t.Fatalf("X-API-Key header = %q", r.Header.Get("X-API-Key")) + } + if err := json.NewDecoder(r.Body).Decode(&gotRequest); err != nil { + t.Fatalf("decode request: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"success":true,"tweetId":"123"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err != nil { + t.Fatalf("postTweetWithXquikClient returned error: %v", err) + } + if gotRequest.Account != "@example" || gotRequest.Text != "hello" { + t.Fatalf("request = %+v", gotRequest) + } + if out.String() != "tweet posted with Xquik: 123\n" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikClientAcceptsPendingConfirmation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(`{"status":"pending_confirmation","writeActionId":"42"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err != nil { + t.Fatalf("postTweetWithXquikClient returned error: %v", err) + } + if out.String() != "tweet accepted by Xquik; confirmation pending: 42\n" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikClientReturnsAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"invalid API key"}`)) + })) + defer server.Close() + + var out bytes.Buffer + err := postTweetWithXquikClient(server.Client(), xquikConfig{ + APIKey: "test-key", + Account: "@example", + BaseURL: server.URL, + }, "hello", &out) + if err == nil || !strings.Contains(err.Error(), "invalid API key") { + t.Fatalf("expected API error, got %v", err) + } + if out.String() != "" { + t.Fatalf("output = %q", out.String()) + } +} + +func TestPostTweetWithXquikUsesDefaultBaseURL(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "test-key") + t.Setenv("XQUIK_ACCOUNT", "@example") + t.Setenv("XQUIK_API_BASE_URL", "") + + cfg, err := xquikConfigFromEnv() + if err != nil { + t.Fatalf("xquikConfigFromEnv returned error: %v", err) + } + if cfg.BaseURL != defaultXquikBaseURL { + t.Fatalf("BaseURL = %q", cfg.BaseURL) + } +} From e23e7eea47199931c9b684c3828b3feb7c7d0aa4 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Fri, 17 Jul 2026 17:56:00 +0300 Subject: [PATCH 2/6] Fix future tweet argument bounds --- src/cli/tweet/future.go | 64 +++++++++++++++++------------------- src/cli/tweet/future_test.go | 15 ++++++++- 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/src/cli/tweet/future.go b/src/cli/tweet/future.go index f39c27b..4c82711 100644 --- a/src/cli/tweet/future.go +++ b/src/cli/tweet/future.go @@ -3,14 +3,14 @@ package tweet // x t "hi" 5h6m7s import ( - "fmt" - "log" - "strconv" - "strings" "bytes" "encoding/json" + "fmt" "io" + "log" "net/http" + "strconv" + "strings" "github.com/devhindo/x/src/cli/lock" ) @@ -18,12 +18,12 @@ import ( type FutureTweet struct { License string `json:"license"` Tweet string `json:"tweet"` - Hours int `json:"hours"` - Minutes int `json:"minutes"` + Hours int `json:"hours"` + Minutes int `json:"minutes"` } func PostFutureTweet(c []string) { - + url := "http://localhost:3000/api/tweets/future" // x t "hi" 5h6m7s @@ -40,9 +40,9 @@ func PostFutureTweet(c []string) { log.SetFlags(0) log.Fatal(err) } - + license, err := lock.ReadLicenseKeyFromFile() - + if err != nil { fmt.Println("you are not authenticated | try 'x auth'") return @@ -50,8 +50,8 @@ func PostFutureTweet(c []string) { tweet := FutureTweet{ License: license, - Tweet: tweetText, - Hours: hrs, + Tweet: tweetText, + Hours: hrs, Minutes: mins, } @@ -60,36 +60,34 @@ func PostFutureTweet(c []string) { if err != nil { log.SetFlags(0) log.Fatal(err) - } + } } - func postFutureTweetToServer(url string, t FutureTweet) error { fmt.Println("unmarchalling") jsonBytes, err := json.Marshal(t) - if err != nil { - panic(err) - } + if err != nil { + panic(err) + } - resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonBytes)) fmt.Println("posting") - if err != nil { - return fmt.Errorf("can't reach server to post a tweet") - } + if err != nil { + return fmt.Errorf("can't reach server to post a tweet") + } fmt.Println("before defer") - defer resp.Body.Close() + defer resp.Body.Close() fmt.Println("after defer") _, err = io.ReadAll(resp.Body) - if err != nil { - //Failed to read response. - return fmt.Errorf("can't read server response") - } - - var r response + if err != nil { + //Failed to read response. + return fmt.Errorf("can't read server response") + } + var r response - //Convert bytes to String and print - fmt.Println(r.Message) + //Convert bytes to String and print + fmt.Println(r.Message) return nil } @@ -98,6 +96,9 @@ func handleFutureTweetArgs(c []string) (string, string, error) { if len(c) < 3 { return "", "", fmt.Errorf("no tweet given | try 'x f --help'") } + if len(c) < 4 { + return "", "", fmt.Errorf("no schedule time given | try 'x f --help'") + } if c[2] == "-h" || c[2] == "--help" { fmt.Println("post future tweets") @@ -109,9 +110,6 @@ func handleFutureTweetArgs(c []string) (string, string, error) { return c[2], c[3], nil } - if len(c) < 4 { - fmt.Println("No schedule time is given | try 'x f --help'") - } return c[2], c[3], nil } @@ -167,4 +165,4 @@ func handleTweetTime(t string) (int, int, error) { } return hrs, mins, nil -} \ No newline at end of file +} diff --git a/src/cli/tweet/future_test.go b/src/cli/tweet/future_test.go index 280487a..dc52b18 100644 --- a/src/cli/tweet/future_test.go +++ b/src/cli/tweet/future_test.go @@ -4,7 +4,6 @@ import ( "testing" ) - func TestHandleTweetTime(t *testing.T) { tests := []struct { name string @@ -103,6 +102,20 @@ func TestHandleFutureTweetArgs(t *testing.T) { wantTime: "", wantErr: true, }, + { + name: "missing schedule time", + args: []string{"x", "f", "hello world"}, + wantTweet: "", + wantTime: "", + wantErr: true, + }, + { + name: "help without schedule time", + args: []string{"x", "f", "--help"}, + wantTweet: "", + wantTime: "", + wantErr: true, + }, } for _, tt := range tests { From 28700eea52d54e5d944325f764a721da483c5c3e Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Fri, 17 Jul 2026 17:56:20 +0300 Subject: [PATCH 3/6] Fix license key vet failure --- src/cli/lock/key.go | 42 +++++++++++++++++++--------------------- src/cli/lock/key_test.go | 21 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 22 deletions(-) create mode 100644 src/cli/lock/key_test.go diff --git a/src/cli/lock/key.go b/src/cli/lock/key.go index a0415eb..df667f9 100644 --- a/src/cli/lock/key.go +++ b/src/cli/lock/key.go @@ -6,34 +6,32 @@ import ( "io" "os" "path/filepath" - + "github.com/google/uuid" ) func GenerateLicenseKey() (string, error) { - uuid := uuid.New() - uuidBytes := uuid[:] - licenseKeyBytes := append(uuidBytes) - licenseKey := base64.StdEncoding.EncodeToString(licenseKeyBytes) + id := uuid.New() + licenseKey := base64.StdEncoding.EncodeToString(id[:]) return licenseKey, nil } func WriteLicenseKeyToFile(licenseKey string) error { homeDir, err := os.UserHomeDir() if err != nil { - return fmt.Errorf("Error getting user home directory: %v", err) + return fmt.Errorf("Error getting user home directory: %v", err) } licenseFilePath := filepath.Join(homeDir, ".tempxcli") licenseFile, err := os.Create(licenseFilePath) if err != nil { - return fmt.Errorf("Error creating license file: %v", err) + return fmt.Errorf("Error creating license file: %v", err) } defer licenseFile.Close() _, err = licenseFile.WriteString(licenseKey) if err != nil { - return fmt.Errorf("Error writing license key to file: %v", err) + return fmt.Errorf("Error writing license key to file: %v", err) } return nil @@ -42,19 +40,19 @@ func WriteLicenseKeyToFile(licenseKey string) error { func ReadLicenseKeyFromFile() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { - return "", fmt.Errorf("Error getting user home directory: %v", err) + return "", fmt.Errorf("Error getting user home directory: %v", err) } licenseFilePath := filepath.Join(homeDir, ".tempxcli") licenseFile, err := os.Open(licenseFilePath) if err != nil { - return "", fmt.Errorf("Error opening license file: %v", err) + return "", fmt.Errorf("Error opening license file: %v", err) } defer licenseFile.Close() licenseFileBytes, err := io.ReadAll(licenseFile) if err != nil { - return "", fmt.Errorf("Error reading license file: %v", err) + return "", fmt.Errorf("Error reading license file: %v", err) } licenseKey := string(licenseFileBytes) @@ -62,16 +60,16 @@ func ReadLicenseKeyFromFile() (string, error) { } func ClearLicenseFile() error { - homeDir, err := os.UserHomeDir() - if err != nil { - return fmt.Errorf("Error getting user home directory: %v", err) - } + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("Error getting user home directory: %v", err) + } - licenseFilePath := filepath.Join(homeDir, ".tempxcli") - err = os.Remove(licenseFilePath) - if err != nil { - return fmt.Errorf("Error deleting license file: %v", err) - } + licenseFilePath := filepath.Join(homeDir, ".tempxcli") + err = os.Remove(licenseFilePath) + if err != nil { + return fmt.Errorf("Error deleting license file: %v", err) + } - return nil -} \ No newline at end of file + return nil +} diff --git a/src/cli/lock/key_test.go b/src/cli/lock/key_test.go new file mode 100644 index 0000000..239cd15 --- /dev/null +++ b/src/cli/lock/key_test.go @@ -0,0 +1,21 @@ +package lock + +import ( + "encoding/base64" + "testing" +) + +func TestGenerateLicenseKey(t *testing.T) { + licenseKey, err := GenerateLicenseKey() + if err != nil { + t.Fatalf("GenerateLicenseKey() error = %v", err) + } + + decoded, err := base64.StdEncoding.DecodeString(licenseKey) + if err != nil { + t.Fatalf("DecodeString() error = %v", err) + } + if len(decoded) != 16 { + t.Fatalf("decoded license key length = %d, want 16", len(decoded)) + } +} From 8b93e716498629da94211eeb6b085e9567a54231 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Fri, 17 Jul 2026 17:56:42 +0300 Subject: [PATCH 4/6] Harden and document Xquik requests --- README.md | 15 +++++++++++++ src/cli/cmd/root.go | 7 +++++- src/cli/tweet/xquik.go | 22 +++++++++++++++--- src/cli/tweet/xquik_test.go | 45 +++++++++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bd73a41..7782e10 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ - [Windows](#windows) - [Mac](#mac) - [Authentication](#authentication) +- [Xquik Backend](#xquik-backend) - [Docs](#docs) ## Usage @@ -121,6 +122,20 @@ Note: running `x auth -v` windows might flag the tool as a threat this is becaus - if anything went wrong run `x -c` to clear any settings and start over - if the problem persists, kindly open an issue [here](https://github.com/devhindo/x/issues/new) and describe the problem and I'll be happy to help! +## Xquik Backend + +Use the optional Xquik backend without changing the default authentication flow: + +```bash +export X_BACKEND=xquik +export XQUIK_API_KEY="your-key" +export XQUIK_ACCOUNT="your-connected-account" +x tweet "Hello from Xquik" +``` + +`TWITTER_BACKEND=xquik` is an alternative selector. Set +`XQUIK_API_BASE_URL` only to override the default `https://xquik.com` origin. + ## Posting tweets diff --git a/src/cli/cmd/root.go b/src/cli/cmd/root.go index 0ccf0b0..879395e 100644 --- a/src/cli/cmd/root.go +++ b/src/cli/cmd/root.go @@ -10,7 +10,12 @@ import ( var rootCmd = &cobra.Command{ Use: "x", Short: "x CLI - Post tweets from your terminal", - Long: `x is a CLI tool that allows you to post tweets to X (formerly Twitter) directly from your terminal.`, + Long: `x is a CLI tool that allows you to post tweets to X (formerly Twitter) directly from your terminal. + +To use the optional Xquik backend, set X_BACKEND=xquik, XQUIK_API_KEY, +and XQUIK_ACCOUNT. TWITTER_BACKEND can replace X_BACKEND. Set +XQUIK_API_BASE_URL only when you need to override the default API URL.`, + Example: ` X_BACKEND=xquik XQUIK_API_KEY=your-key XQUIK_ACCOUNT=your-account x tweet "Hello"`, Run: func(cmd *cobra.Command, args []string) { if len(args) > 0 { // Join args just in case, but usually it's one arg "msg" diff --git a/src/cli/tweet/xquik.go b/src/cli/tweet/xquik.go index ce2a4f2..f956de0 100644 --- a/src/cli/tweet/xquik.go +++ b/src/cli/tweet/xquik.go @@ -8,9 +8,13 @@ import ( "net/http" "os" "strings" + "time" ) -const defaultXquikBaseURL = "https://xquik.com" +const ( + defaultXquikBaseURL = "https://xquik.com" + xquikRequestTimeout = 30 * time.Second +) type xquikConfig struct { APIKey string @@ -64,7 +68,11 @@ func postTweetWithXquik(text string) error { if err != nil { return err } - return postTweetWithXquikClient(http.DefaultClient, cfg, text, os.Stdout) + return postTweetWithXquikClient(newXquikHTTPClient(), cfg, text, os.Stdout) +} + +func newXquikHTTPClient() *http.Client { + return &http.Client{Timeout: xquikRequestTimeout} } func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, out io.Writer) error { @@ -77,7 +85,7 @@ func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, return err } - req, err := http.NewRequest("POST", cfg.BaseURL+"/api/v1/x/tweets", bytes.NewBuffer(jsonBytes)) + req, err := http.NewRequest("POST", xquikTweetURL(cfg.BaseURL), bytes.NewBuffer(jsonBytes)) if err != nil { return err } @@ -118,3 +126,11 @@ func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, } return fmt.Errorf("Xquik request failed with status %d", resp.StatusCode) } + +func xquikTweetURL(baseURL string) string { + baseURL = strings.TrimRight(baseURL, "/") + if strings.HasSuffix(baseURL, "/api/v1") { + return baseURL + "/x/tweets" + } + return baseURL + "/api/v1/x/tweets" +} diff --git a/src/cli/tweet/xquik_test.go b/src/cli/tweet/xquik_test.go index 5ad9956..75ab4dd 100644 --- a/src/cli/tweet/xquik_test.go +++ b/src/cli/tweet/xquik_test.go @@ -124,3 +124,48 @@ func TestPostTweetWithXquikUsesDefaultBaseURL(t *testing.T) { t.Fatalf("BaseURL = %q", cfg.BaseURL) } } + +func TestNewXquikHTTPClientHasTimeout(t *testing.T) { + client := newXquikHTTPClient() + if client.Timeout != xquikRequestTimeout { + t.Fatalf("Timeout = %s, want %s", client.Timeout, xquikRequestTimeout) + } +} + +func TestXquikTweetURL(t *testing.T) { + tests := []struct { + name string + baseURL string + want string + }{ + { + name: "origin", + baseURL: "https://xquik.com", + want: "https://xquik.com/api/v1/x/tweets", + }, + { + name: "origin with trailing slash", + baseURL: "https://xquik.com/", + want: "https://xquik.com/api/v1/x/tweets", + }, + { + name: "API base", + baseURL: "https://xquik.com/api/v1", + want: "https://xquik.com/api/v1/x/tweets", + }, + { + name: "API base with trailing slash", + baseURL: "https://xquik.com/api/v1/", + want: "https://xquik.com/api/v1/x/tweets", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got := xquikTweetURL(test.baseURL) + if got != test.want { + t.Fatalf("xquikTweetURL(%q) = %q, want %q", test.baseURL, got, test.want) + } + }) + } +} From 5bc828701509d6c03323ab94ec16b86cf9e462db Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Fri, 17 Jul 2026 21:56:43 +0300 Subject: [PATCH 5/6] Document Xquik independence --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7782e10..c0306ff 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ x tweet "Hello from Xquik" `TWITTER_BACKEND=xquik` is an alternative selector. Set `XQUIK_API_BASE_URL` only to override the default `https://xquik.com` origin. +Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp. + ## Posting tweets From 5c92fbcf2189e7b6c2b8f995821f7f351cf54500 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sat, 18 Jul 2026 14:25:11 +0300 Subject: [PATCH 6/6] Harden Xquik endpoint configuration --- .github/workflows/go-cli.yml | 7 ++++--- README.md | 2 ++ src/cli/tweet/xquik.go | 40 ++++++++++++++++++++++++++++++++++++ src/cli/tweet/xquik_test.go | 36 ++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/.github/workflows/go-cli.yml b/.github/workflows/go-cli.yml index 059879b..e91dc24 100644 --- a/.github/workflows/go-cli.yml +++ b/.github/workflows/go-cli.yml @@ -14,12 +14,13 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.21.0' + go-version-file: 'src/cli/go.mod' + cache-dependency-path: 'src/cli/go.sum' - name: Build run: cd src/cli && go build -v ./... diff --git a/README.md b/README.md index c0306ff..b8f4bc3 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ x tweet "Hello from Xquik" `TWITTER_BACKEND=xquik` is an alternative selector. Set `XQUIK_API_BASE_URL` only to override the default `https://xquik.com` origin. +Remote overrides must use HTTPS. Plain HTTP is accepted only for loopback +development addresses. Xquik is an independent third-party service. Not affiliated with X Corp. "Twitter" and "X" are trademarks of X Corp. diff --git a/src/cli/tweet/xquik.go b/src/cli/tweet/xquik.go index f956de0..9e6a50c 100644 --- a/src/cli/tweet/xquik.go +++ b/src/cli/tweet/xquik.go @@ -5,7 +5,9 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" + "net/url" "os" "strings" "time" @@ -60,6 +62,9 @@ func xquikConfigFromEnv() (xquikConfig, error) { if cfg.BaseURL == "" { cfg.BaseURL = defaultXquikBaseURL } + if err := validateXquikBaseURL(cfg.BaseURL); err != nil { + return cfg, err + } return cfg, nil } @@ -76,6 +81,10 @@ func newXquikHTTPClient() *http.Client { } func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, out io.Writer) error { + if err := validateXquikBaseURL(cfg.BaseURL); err != nil { + return err + } + payload := xquikTweetRequest{ Account: cfg.Account, Text: text, @@ -127,6 +136,37 @@ func postTweetWithXquikClient(client *http.Client, cfg xquikConfig, text string, return fmt.Errorf("Xquik request failed with status %d", resp.StatusCode) } +func validateXquikBaseURL(baseURL string) error { + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Host == "" { + return fmt.Errorf("XQUIK_API_BASE_URL must be an absolute URL") + } + if parsed.User != nil { + return fmt.Errorf("XQUIK_API_BASE_URL must not contain credentials") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return fmt.Errorf("XQUIK_API_BASE_URL must not contain a query or fragment") + } + if parsed.Path != "" && parsed.Path != "/" && parsed.Path != "/api/v1" { + return fmt.Errorf("XQUIK_API_BASE_URL path must be empty or /api/v1") + } + if parsed.Scheme == "https" { + return nil + } + if parsed.Scheme == "http" && isLoopbackHost(parsed.Hostname()) { + return nil + } + return fmt.Errorf("XQUIK_API_BASE_URL must use HTTPS except on loopback") +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + func xquikTweetURL(baseURL string) string { baseURL = strings.TrimRight(baseURL, "/") if strings.HasSuffix(baseURL, "/api/v1") { diff --git a/src/cli/tweet/xquik_test.go b/src/cli/tweet/xquik_test.go index 75ab4dd..5b33b61 100644 --- a/src/cli/tweet/xquik_test.go +++ b/src/cli/tweet/xquik_test.go @@ -29,6 +29,42 @@ func TestXquikConfigFromEnvRequiresCredentials(t *testing.T) { } } +func TestXquikConfigRejectsInsecureRemoteBaseURL(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "test-key") + t.Setenv("XQUIK_ACCOUNT", "@example") + t.Setenv("XQUIK_API_BASE_URL", "http://api.example.com") + + _, err := xquikConfigFromEnv() + if err == nil || !strings.Contains(err.Error(), "must use HTTPS") { + t.Fatalf("expected HTTPS validation error, got %v", err) + } +} + +func TestXquikConfigRejectsEmbeddedCredentials(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "test-key") + t.Setenv("XQUIK_ACCOUNT", "@example") + t.Setenv("XQUIK_API_BASE_URL", "https://user:password@api.example.com") + + _, err := xquikConfigFromEnv() + if err == nil || !strings.Contains(err.Error(), "must not contain credentials") { + t.Fatalf("expected credential validation error, got %v", err) + } +} + +func TestXquikConfigAllowsLoopbackHTTP(t *testing.T) { + t.Setenv("XQUIK_API_KEY", "test-key") + t.Setenv("XQUIK_ACCOUNT", "@example") + t.Setenv("XQUIK_API_BASE_URL", "http://127.0.0.1:3000/api/v1") + + cfg, err := xquikConfigFromEnv() + if err != nil { + t.Fatalf("xquikConfigFromEnv returned error: %v", err) + } + if cfg.BaseURL != "http://127.0.0.1:3000/api/v1" { + t.Fatalf("BaseURL = %q", cfg.BaseURL) + } +} + func TestPostTweetWithXquikClientPostsTweet(t *testing.T) { var gotRequest xquikTweetRequest server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {