// Package service provides business logic for the application package service import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "time" "accounting-app/internal/config" "accounting-app/internal/models" "accounting-app/internal/repository" ) // GitHub OAuth errors var ( ErrGitHubOAuthFailed = errors.New("github oauth authentication failed") ErrGitHubUserInfoFailed = errors.New("failed to get github user info") ) // GitHubUser represents GitHub user information type GitHubUser struct { ID int64 `json:"id"` Login string `json:"login"` Email string `json:"email"` Name string `json:"name"` AvatarURL string `json:"avatar_url"` } // GitHubTokenResponse represents GitHub OAuth token response type GitHubTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` Scope string `json:"scope"` } // GitHubOAuthService handles GitHub OAuth operations // Feature: api-interface-optimization // Validates: Requirements 13.1, 13.2, 13.3, 13.4, 13.5 type GitHubOAuthService struct { userRepo *repository.UserRepository authService *AuthService cfg *config.Config httpClient *http.Client } // NewGitHubOAuthService creates a new GitHubOAuthService instance func NewGitHubOAuthService(userRepo *repository.UserRepository, authService *AuthService, cfg *config.Config) *GitHubOAuthService { return &GitHubOAuthService{ userRepo: userRepo, authService: authService, cfg: cfg, httpClient: &http.Client{ Timeout: 30 * time.Second, // 澧炲姞瓒呮椂鏃堕棿 }, } } // GetAuthorizationURL returns the GitHub OAuth authorization URL // Feature: api-interface-optimization // Validates: Requirements 13.1 func (s *GitHubOAuthService) GetAuthorizationURL(state string) string { params := url.Values{} params.Set("client_id", s.cfg.GitHubClientID) params.Set("redirect_uri", s.cfg.GitHubRedirectURL) params.Set("scope", "user:email") params.Set("state", state) return fmt.Sprintf("https://github.com/login/oauth/authorize?%s", params.Encode()) } // ExchangeCodeForToken exchanges authorization code for access token // Feature: api-interface-optimization // Validates: Requirements 13.2 func (s *GitHubOAuthService) ExchangeCodeForToken(code string) (*GitHubTokenResponse, error) { data := url.Values{} data.Set("client_id", s.cfg.GitHubClientID) data.Set("client_secret", s.cfg.GitHubClientSecret) data.Set("code", code) data.Set("redirect_uri", s.cfg.GitHubRedirectURL) req, err := http.NewRequest("POST", "https://github.com/login/oauth/access_token", strings.NewReader(data.Encode())) if err != nil { fmt.Printf("[GitHub] Failed to create request: %v\n", err) return nil, err } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Accept", "application/json") fmt.Printf("[GitHub] Exchanging code for token...\n") resp, err := s.httpClient.Do(req) if err != nil { fmt.Printf("[GitHub] Request failed: %v\n", err) return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { fmt.Printf("[GitHub] Token exchange failed with status: %d\n", resp.StatusCode) return nil, ErrGitHubOAuthFailed } var tokenResp GitHubTokenResponse if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { fmt.Printf("[GitHub] Failed to decode response: %v\n", err) return nil, err } if tokenResp.AccessToken == "" { fmt.Printf("[GitHub] No access token in response\n") return nil, ErrGitHubOAuthFailed } return &tokenResp, nil } // GetUserInfo retrieves GitHub user information using access token // Feature: api-interface-optimization // Validates: Requirements 13.3 func (s *GitHubOAuthService) GetUserInfo(accessToken string) (*GitHubUser, error) { req, err := http.NewRequest("GET", "https://api.github.com/user", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, ErrGitHubUserInfoFailed } var user GitHubUser if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { return nil, err } // If email is not public, try to get it from emails endpoint if user.Email == "" { email, err := s.getUserEmail(accessToken) if err == nil { user.Email = email } } return &user, nil } // getUserEmail retrieves user's primary email from GitHub func (s *GitHubOAuthService) getUserEmail(accessToken string) (string, error) { req, err := http.NewRequest("GET", "https://api.github.com/user/emails", nil) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+accessToken) req.Header.Set("Accept", "application/json") resp, err := s.httpClient.Do(req) if err != nil { return "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", ErrGitHubUserInfoFailed } body, err := io.ReadAll(resp.Body) if err != nil { return "", err } var emails []struct { Email string `json:"email"` Primary bool `json:"primary"` Verified bool `json:"verified"` } if err := json.Unmarshal(body, &emails); err != nil { return "", err } for _, e := range emails { if e.Primary && e.Verified { return e.Email, nil } } return "", nil } // HandleCallback processes GitHub OAuth callback // Feature: api-interface-optimization // Validates: Requirements 13.4, 13.5 func (s *GitHubOAuthService) HandleCallback(code string) (*models.User, *TokenPair, error) { // Exchange code for token tokenResp, err := s.ExchangeCodeForToken(code) if err != nil { return nil, nil, err } // Get GitHub user info githubUser, err := s.GetUserInfo(tokenResp.AccessToken) if err != nil { return nil, nil, err } // Check if user already exists with this GitHub account user, err := s.userRepo.GetByOAuthProvider("github", fmt.Sprintf("%d", githubUser.ID)) if err == nil { // User exists, update token and return _ = s.userRepo.UpdateOAuthToken("github", fmt.Sprintf("%d", githubUser.ID), tokenResp.AccessToken) tokens, err := s.authService.generateTokenPair(user) if err != nil { return nil, nil, err } return user, tokens, nil } // Check if user exists with same email if githubUser.Email != "" { existingUser, err := s.userRepo.GetByEmail(githubUser.Email) if err == nil { // Link GitHub account to existing user oauth := &models.OAuthAccount{ UserID: existingUser.ID, Provider: "github", ProviderID: fmt.Sprintf("%d", githubUser.ID), AccessToken: tokenResp.AccessToken, } if err := s.userRepo.CreateOAuthAccount(oauth); err != nil { return nil, nil, err } tokens, err := s.authService.generateTokenPair(existingUser) if err != nil { return nil, nil, err } return existingUser, tokens, nil } } // Create new user username := githubUser.Login if githubUser.Name != "" { username = githubUser.Name } email := githubUser.Email if email == "" { email = fmt.Sprintf("%d@github.user", githubUser.ID) } newUser := &models.User{ Email: email, Username: username, Avatar: githubUser.AvatarURL, IsActive: true, } if err := s.userRepo.Create(newUser); err != nil { return nil, nil, err } // Create OAuth account link oauth := &models.OAuthAccount{ UserID: newUser.ID, Provider: "github", ProviderID: fmt.Sprintf("%d", githubUser.ID), AccessToken: tokenResp.AccessToken, } if err := s.userRepo.CreateOAuthAccount(oauth); err != nil { return nil, nil, err } tokens, err := s.authService.generateTokenPair(newUser) if err != nil { return nil, nil, err } return newUser, tokens, nil }