diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 18fb162d..2050a0cf 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -18,6 +18,17 @@ type Config struct { Gateway GatewayConfig `mapstructure:"gateway"` TokenRefresh TokenRefreshConfig `mapstructure:"token_refresh"` Timezone string `mapstructure:"timezone"` // e.g. "Asia/Shanghai", "UTC" + Gemini GeminiConfig `mapstructure:"gemini"` +} + +type GeminiConfig struct { + OAuth GeminiOAuthConfig `mapstructure:"oauth"` +} + +type GeminiOAuthConfig struct { + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + Scopes string `mapstructure:"scopes"` } // TokenRefreshConfig OAuth token自动刷新配置 @@ -214,6 +225,11 @@ func setDefaults() { viper.SetDefault("token_refresh.refresh_before_expiry_hours", 1.5) // 提前1.5小时刷新 viper.SetDefault("token_refresh.max_retries", 3) // 最多重试3次 viper.SetDefault("token_refresh.retry_backoff_seconds", 2) // 重试退避基础2秒 + + // Gemini (optional) + viper.SetDefault("gemini.oauth.client_id", "") + viper.SetDefault("gemini.oauth.client_secret", "") + viper.SetDefault("gemini.oauth.scopes", "") } func (c *Config) Validate() error { diff --git a/backend/internal/pkg/geminicli/codeassist_types.go b/backend/internal/pkg/geminicli/codeassist_types.go new file mode 100644 index 00000000..59d3ef78 --- /dev/null +++ b/backend/internal/pkg/geminicli/codeassist_types.go @@ -0,0 +1,38 @@ +package geminicli + +// LoadCodeAssistRequest matches done-hub's internal Code Assist call. +type LoadCodeAssistRequest struct { + Metadata LoadCodeAssistMetadata `json:"metadata"` +} + +type LoadCodeAssistMetadata struct { + IDEType string `json:"ideType"` + Platform string `json:"platform"` + PluginType string `json:"pluginType"` +} + +type LoadCodeAssistResponse struct { + CurrentTier string `json:"currentTier,omitempty"` + CloudAICompanionProject string `json:"cloudaicompanionProject,omitempty"` + AllowedTiers []AllowedTier `json:"allowedTiers,omitempty"` +} + +type AllowedTier struct { + ID string `json:"id"` + IsDefault bool `json:"isDefault,omitempty"` +} + +type OnboardUserRequest struct { + TierID string `json:"tierId"` + Metadata LoadCodeAssistMetadata `json:"metadata"` +} + +type OnboardUserResponse struct { + Done bool `json:"done"` + Response *OnboardUserResultData `json:"response,omitempty"` + Name string `json:"name,omitempty"` +} + +type OnboardUserResultData struct { + CloudAICompanionProject any `json:"cloudaicompanionProject,omitempty"` +} diff --git a/backend/internal/pkg/geminicli/constants.go b/backend/internal/pkg/geminicli/constants.go new file mode 100644 index 00000000..7ad33d75 --- /dev/null +++ b/backend/internal/pkg/geminicli/constants.go @@ -0,0 +1,20 @@ +package geminicli + +import "time" + +const ( + AIStudioBaseURL = "https://generativelanguage.googleapis.com" + GeminiCliBaseURL = "https://cloudcode-pa.googleapis.com" + + AuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" + TokenURL = "https://oauth2.googleapis.com/token" + + // DefaultScopes is the minimal scope set for GeminiCli/CodeAssist usage. + // Keep this conservative and expand only when we have a clear requirement. + DefaultScopes = "https://www.googleapis.com/auth/cloud-platform" + + SessionTTL = 30 * time.Minute + + // GeminiCLIUserAgent mimics Gemini CLI to maximize compatibility with internal endpoints. + GeminiCLIUserAgent = "GeminiCLI/0.1.5 (Windows; AMD64)" +) diff --git a/backend/internal/pkg/geminicli/oauth.go b/backend/internal/pkg/geminicli/oauth.go new file mode 100644 index 00000000..2b6cf714 --- /dev/null +++ b/backend/internal/pkg/geminicli/oauth.go @@ -0,0 +1,167 @@ +package geminicli + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/url" + "strings" + "sync" + "time" +) + +type OAuthConfig struct { + ClientID string + ClientSecret string + Scopes string +} + +type OAuthSession struct { + State string `json:"state"` + CodeVerifier string `json:"code_verifier"` + ProxyURL string `json:"proxy_url,omitempty"` + RedirectURI string `json:"redirect_uri"` + CreatedAt time.Time `json:"created_at"` +} + +type SessionStore struct { + mu sync.RWMutex + sessions map[string]*OAuthSession + stopCh chan struct{} +} + +func NewSessionStore() *SessionStore { + store := &SessionStore{ + sessions: make(map[string]*OAuthSession), + stopCh: make(chan struct{}), + } + go store.cleanup() + return store +} + +func (s *SessionStore) Set(sessionID string, session *OAuthSession) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[sessionID] = session +} + +func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.sessions[sessionID] + if !ok { + return nil, false + } + if time.Since(session.CreatedAt) > SessionTTL { + return nil, false + } + return session, true +} + +func (s *SessionStore) Delete(sessionID string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, sessionID) +} + +func (s *SessionStore) Stop() { + select { + case <-s.stopCh: + return + default: + close(s.stopCh) + } +} + +func (s *SessionStore) cleanup() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-s.stopCh: + return + case <-ticker.C: + s.mu.Lock() + for id, session := range s.sessions { + if time.Since(session.CreatedAt) > SessionTTL { + delete(s.sessions, id) + } + } + s.mu.Unlock() + } + } +} + +func GenerateRandomBytes(n int) ([]byte, error) { + b := make([]byte, n) + _, err := rand.Read(b) + if err != nil { + return nil, err + } + return b, nil +} + +func GenerateState() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateSessionID() (string, error) { + bytes, err := GenerateRandomBytes(16) + if err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// GenerateCodeVerifier returns an RFC 7636 compatible code verifier (43+ chars). +func GenerateCodeVerifier() (string, error) { + bytes, err := GenerateRandomBytes(32) + if err != nil { + return "", err + } + return base64URLEncode(bytes), nil +} + +func GenerateCodeChallenge(verifier string) string { + hash := sha256.Sum256([]byte(verifier)) + return base64URLEncode(hash[:]) +} + +func base64URLEncode(data []byte) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=") +} + +func BuildAuthorizationURL(cfg OAuthConfig, state, codeChallenge, redirectURI string) (string, error) { + if strings.TrimSpace(cfg.ClientID) == "" { + return "", fmt.Errorf("gemini oauth client_id is empty") + } + redirectURI = strings.TrimSpace(redirectURI) + if redirectURI == "" { + return "", fmt.Errorf("redirect_uri is required") + } + + scopes := strings.TrimSpace(cfg.Scopes) + if scopes == "" { + scopes = DefaultScopes + } + + params := url.Values{} + params.Set("response_type", "code") + params.Set("client_id", cfg.ClientID) + params.Set("redirect_uri", redirectURI) + params.Set("scope", scopes) + params.Set("state", state) + params.Set("code_challenge", codeChallenge) + params.Set("code_challenge_method", "S256") + params.Set("access_type", "offline") + params.Set("prompt", "consent") + params.Set("include_granted_scopes", "true") + + return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()), nil +} diff --git a/backend/internal/pkg/geminicli/sanitize.go b/backend/internal/pkg/geminicli/sanitize.go new file mode 100644 index 00000000..f5c407e4 --- /dev/null +++ b/backend/internal/pkg/geminicli/sanitize.go @@ -0,0 +1,46 @@ +package geminicli + +import "strings" + +const maxLogBodyLen = 2048 + +func SanitizeBodyForLogs(body string) string { + body = truncateBase64InMessage(body) + if len(body) > maxLogBodyLen { + body = body[:maxLogBodyLen] + "...[truncated]" + } + return body +} + +func truncateBase64InMessage(message string) string { + const maxBase64Length = 50 + + result := message + offset := 0 + for { + idx := strings.Index(result[offset:], ";base64,") + if idx == -1 { + break + } + actualIdx := offset + idx + start := actualIdx + len(";base64,") + + end := start + for end < len(result) && isBase64Char(result[end]) { + end++ + } + + if end-start > maxBase64Length { + result = result[:start+maxBase64Length] + "...[truncated]" + result[end:] + offset = start + maxBase64Length + len("...[truncated]") + continue + } + offset = end + } + + return result +} + +func isBase64Char(c byte) bool { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '+' || c == '/' || c == '=' +} diff --git a/backend/internal/pkg/geminicli/token_types.go b/backend/internal/pkg/geminicli/token_types.go new file mode 100644 index 00000000..f3cfbaed --- /dev/null +++ b/backend/internal/pkg/geminicli/token_types.go @@ -0,0 +1,9 @@ +package geminicli + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token,omitempty"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Scope string `json:"scope,omitempty"` +} diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 1e466244..400ebabb 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -87,3 +87,14 @@ pricing: update_interval_hours: 24 # Hash check interval in minutes hash_check_interval_minutes: 10 + +# ============================================================================= +# Gemini (Optional) +# ============================================================================= +gemini: + oauth: + # Google OAuth Client ID / Secret (for GeminiCli / Code Assist internal API) + client_id: "" + client_secret: "" + # Optional scopes (space-separated). Leave empty to use default cloud-platform scope. + scopes: ""