Head of Product & Engineering 12c547d215
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: address remaining golangci-lint warnings
- internal/ghl/oauth.go: acknowledge fmt.Fprint return (errcheck)
- internal/ghl/api.go: handle io.ReadAll error instead of discarding (errcheck)
- internal/cast/client.go: replace defer-in-loop with explicit Body.Close
  after ReadAll (gocritic defer-in-loop)
- internal/phone/normalize.go: move inline regexp.MustCompile to package-level
  var e164Pattern (gocritic / performance)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-05 21:33:03 +02:00

201 lines
6.0 KiB
Go

package ghl
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.sds.dev/CAST/cast-ghl-plugin/internal/store"
)
const ghlTokenURL = "https://services.leadconnectorhq.com/oauth/token"
// TokenStore is the interface OAuthHandler uses for token persistence.
type TokenStore interface {
SaveToken(ctx context.Context, record *store.TokenRecord) error
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
DeleteToken(ctx context.Context, locationID string) error
}
type OAuthHandler struct {
clientID string
clientSecret string
baseURL string
providerID string
store TokenStore
httpClient *http.Client
tokenURL string
}
func NewOAuthHandler(clientID, clientSecret, baseURL, providerID string, s TokenStore) *OAuthHandler {
return &OAuthHandler{
clientID: clientID,
clientSecret: clientSecret,
baseURL: baseURL,
providerID: providerID,
store: s,
httpClient: &http.Client{Timeout: 30 * time.Second},
tokenURL: ghlTokenURL,
}
}
func (h *OAuthHandler) HandleInstall(w http.ResponseWriter, r *http.Request) {
redirectURI := h.baseURL + "/oauth-callback"
scopes := strings.Join([]string{
"conversations/message.write",
"conversations/message.readonly",
"conversations.write",
"conversations.readonly",
"contacts.readonly",
"contacts.write",
}, " ")
authURL := fmt.Sprintf(
"https://marketplace.gohighlevel.com/oauth/chooselocation?response_type=code&redirect_uri=%s&client_id=%s&scope=%s",
url.QueryEscape(redirectURI),
url.QueryEscape(h.clientID),
url.QueryEscape(scopes),
)
slog.Info("ghl oauth install initiated", "redirect_uri", redirectURI)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (h *OAuthHandler) HandleCallback(w http.ResponseWriter, r *http.Request) {
if errParam := r.URL.Query().Get("error"); errParam != "" {
slog.Warn("ghl oauth denied by user", "error", errParam)
http.Error(w, "authorization denied: "+errParam, http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "missing authorization code", http.StatusBadRequest)
return
}
ctx := r.Context()
tokenResp, err := h.exchangeCode(ctx, code)
if err != nil {
slog.Error("ghl oauth code exchange failed", "err", err)
http.Error(w, "token exchange failed: "+err.Error(), http.StatusInternalServerError)
return
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
record := &store.TokenRecord{
LocationID: tokenResp.LocationID,
CompanyID: tokenResp.CompanyID,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ExpiresAt: expiresAt,
InstalledAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := h.store.SaveToken(ctx, record); err != nil {
slog.Error("ghl oauth token save failed", "location_id", tokenResp.LocationID, "err", err)
http.Error(w, "failed to save token", http.StatusInternalServerError)
return
}
slog.Info("ghl oauth install complete", "location_id", tokenResp.LocationID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `<!DOCTYPE html><html><body><h2>Cast SMS installed successfully!</h2><p>You can close this tab.</p></body></html>`)
}
func (h *OAuthHandler) RefreshToken(ctx context.Context, locationID string) (*store.TokenRecord, error) {
record, err := h.store.GetToken(ctx, locationID)
if err != nil {
return nil, err
}
if record == nil {
return nil, errors.New("no token for location: " + locationID)
}
data := url.Values{}
data.Set("client_id", h.clientID)
data.Set("client_secret", h.clientSecret)
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", record.RefreshToken)
tokenResp, err := h.postToken(ctx, data)
if err != nil {
return nil, fmt.Errorf("refresh token failed: %w", err)
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
if err := h.store.UpdateToken(ctx, locationID, tokenResp.AccessToken, tokenResp.RefreshToken, expiresAt); err != nil {
return nil, fmt.Errorf("failed to update token in store: %w", err)
}
record.AccessToken = tokenResp.AccessToken
record.RefreshToken = tokenResp.RefreshToken
record.ExpiresAt = expiresAt
slog.Info("ghl token refreshed", "location_id", locationID)
return record, nil
}
func (h *OAuthHandler) GetValidToken(ctx context.Context, locationID string) (string, error) {
record, err := h.store.GetToken(ctx, locationID)
if err != nil {
return "", err
}
if record == nil {
return "", errors.New("no token for location: " + locationID)
}
if time.Until(record.ExpiresAt) < 5*time.Minute {
record, err = h.RefreshToken(ctx, locationID)
if err != nil {
return "", err
}
}
return record.AccessToken, nil
}
func (h *OAuthHandler) exchangeCode(ctx context.Context, code string) (*TokenResponse, error) {
data := url.Values{}
data.Set("client_id", h.clientID)
data.Set("client_secret", h.clientSecret)
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", h.baseURL+"/oauth-callback")
return h.postToken(ctx, data)
}
func (h *OAuthHandler) postToken(ctx context.Context, data url.Values) (*TokenResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.tokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(body))
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
return &tokenResp, nil
}