package esi import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" "time" ) const ( EsiBaseURL = "https://esi.evetech.net/latest" UserAgent = "Haul (Hyperspace Asset Unloading and Loading) - Go Port" ErrorLimitThresh = 50 ) type AuthData struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresAt float64 `json:"expires_at"` } type EsiClient struct { HttpClient *http.Client AuthFilePath string TokenMutex sync.Mutex ClientID string SecretKey string } func NewEsiClient(authFilePath string) *EsiClient { clientID := os.Getenv("CLIENT_ID") secretKey := os.Getenv("SECRET_KEY") // Default to generic timeout client := &http.Client{ Timeout: 10 * time.Second, } return &EsiClient{ HttpClient: client, AuthFilePath: authFilePath, ClientID: clientID, SecretKey: secretKey, } } // GetAccessToken checks if the token is valid, refreshing if needed or forced. func (c *EsiClient) GetAccessToken(forceRefresh bool) (string, error) { c.TokenMutex.Lock() defer c.TokenMutex.Unlock() data, err := os.ReadFile(c.AuthFilePath) if err != nil { return "", fmt.Errorf("failed to read auth file: %w", err) } var auth AuthData if err := json.Unmarshal(data, &auth); err != nil { return "", fmt.Errorf("failed to parse auth file: %w", err) } now := float64(time.Now().Unix()) // Refresh if expired or soon to expire (within 20 mins) if forceRefresh || auth.ExpiresAt < now+1200 { return c.refreshAccessToken(auth.RefreshToken) } return auth.AccessToken, nil } func (c *EsiClient) refreshAccessToken(refreshToken string) (string, error) { if c.ClientID == "" || c.SecretKey == "" { return "", errors.New("CLIENT_ID and SECRET_KEY environment variables not set") } tokenURL := "https://login.eveonline.com/v2/oauth/token" data := url.Values{} data.Set("grant_type", "refresh_token") data.Set("refresh_token", refreshToken) req, err := http.NewRequest("POST", tokenURL, strings.NewReader(data.Encode())) if err != nil { return "", err } authStr := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", c.ClientID, c.SecretKey))) req.Header.Add("Authorization", "Basic "+authStr) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Host", "login.eveonline.com") resp, err := c.HttpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("failed to refresh token, status %d: %s", resp.StatusCode, string(bodyBytes)) } var respData struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int64 `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil { return "", err } // Update auth file newAuth := AuthData{ AccessToken: respData.AccessToken, RefreshToken: respData.RefreshToken, ExpiresAt: float64(time.Now().Unix() + respData.ExpiresIn), } // Ensure cache dir exists os.MkdirAll(filepath.Dir(c.AuthFilePath), 0755) outBytes, err := json.MarshalIndent(newAuth, "", " ") if err != nil { return "", err } if err := os.WriteFile(c.AuthFilePath, outBytes, 0644); err != nil { return "", err } return newAuth.AccessToken, nil } func (c *EsiClient) doRequest(method, endpoint string, body []byte, allowRetry bool) (*http.Response, error) { url := fmt.Sprintf("%s%s", EsiBaseURL, endpoint) for attempt := 1; attempt <= 3; attempt++ { token, err := c.GetAccessToken(false) if err != nil { return nil, err } var reqBody io.Reader if body != nil { reqBody = bytes.NewReader(body) } req, err := http.NewRequest(method, url, reqBody) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") req.Header.Set("Cache-Control", "no-cache") req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", UserAgent) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := c.HttpClient.Do(req) if err != nil { return nil, err } // Check ESI Error Limit if remainStr := resp.Header.Get("x-esi-error-limit-remain"); remainStr != "" { if remain, err := strconv.Atoi(remainStr); err == nil && remain < ErrorLimitThresh { resp.Body.Close() return nil, fmt.Errorf("ESI error limit threshold reached: %d remaining", remain) } } if resp.StatusCode == 401 && allowRetry && attempt == 1 { resp.Body.Close() _, _ = c.GetAccessToken(true) // Force refresh continue } if resp.StatusCode == 429 && allowRetry { resp.Body.Close() time.Sleep(time.Duration(2*attempt) * time.Second) continue } if resp.StatusCode == 403 { resp.Body.Close() return nil, errors.New("permission denied (403). Check ESI scopes") } return resp, nil } return nil, errors.New("max retries exceeded") }