diff options
Diffstat (limited to 'backend-go/internal/esi/client.go')
| -rw-r--r-- | backend-go/internal/esi/client.go | 203 |
1 files changed, 203 insertions, 0 deletions
diff --git a/backend-go/internal/esi/client.go b/backend-go/internal/esi/client.go new file mode 100644 index 0000000..6a3b5fd --- /dev/null +++ b/backend-go/internal/esi/client.go @@ -0,0 +1,203 @@ +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") +}
\ No newline at end of file |
