sub2api/backend/internal/service/windsurf_services.go

367 lines
9.7 KiB
Go

package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/windsurf"
)
type WindsurfLSService struct {
cfg config.WindsurfConfig
connector windsurf.LSConnector
}
func NewWindsurfLSService(cfg config.WindsurfConfig, pool *windsurf.LSPool) *WindsurfLSService {
var connector windsurf.LSConnector
switch cfg.LSMode {
case "docker":
connector = windsurf.NewCompatDockerConnector(
cfg.Docker.Host,
cfg.Docker.Port,
windsurf.DockerDiscoveryConfig{
DefaultCSRFToken: cfg.Docker.CSRFToken,
ProbeInterval: cfg.Docker.ProbeInterval,
ProbeTimeout: cfg.Docker.ProbeTimeout,
DiscoverInterval: cfg.Docker.DiscoverInterval,
},
)
case "embedded":
connector = windsurf.NewEmbeddedConnector(pool)
case "external":
port := 0
if cfg.External.BaseURL != "" {
port = 443
}
connector = windsurf.NewExternalConnector(
cfg.External.BaseURL,
port,
cfg.External.CSRFToken,
)
default:
connector = windsurf.NewDockerConnector(
cfg.Docker.Host,
cfg.Docker.Port,
cfg.Docker.CSRFToken,
)
slog.Warn("windsurf_ls_unknown_mode", "mode", cfg.LSMode, "fallback", "docker")
}
return &WindsurfLSService{
cfg: cfg,
connector: connector,
}
}
func (s *WindsurfLSService) Connector() windsurf.LSConnector {
return s.connector
}
func (s *WindsurfLSService) Acquire(ctx context.Context, proxyURL string) (*windsurf.LSLease, error) {
return s.connector.Acquire(ctx, proxyURL)
}
func (s *WindsurfLSService) AcquireByBinding(binding WindsurfLSBinding) (*windsurf.LSLease, error) {
if binding.ContainerID == "" && binding.ContainerName == "" {
return s.connector.Acquire(context.Background(), "")
}
if dc, ok := s.connector.(*windsurf.DockerDiscoveryConnector); ok {
id := binding.ContainerID
if id == "" {
id = binding.ContainerName
}
return dc.AcquireByID(id)
}
return s.connector.Acquire(context.Background(), "")
}
func (s *WindsurfLSService) Health(ctx context.Context) error {
return s.connector.Health(ctx)
}
func (s *WindsurfLSService) Status() *windsurf.LSConnectorStatus {
return s.connector.Status()
}
// Stop terminates LS resources owned by this service (embedded LS processes,
// docker discovery goroutines). Safe to call on a nil receiver.
func (s *WindsurfLSService) Stop() {
if s == nil || s.connector == nil {
return
}
s.connector.Shutdown()
}
type WindsurfAuthService struct {
cfg config.WindsurfConfig
authClient *windsurf.AuthClient
accountRepo AccountRepository
proxyRepo ProxyRepository
adminSvc AdminService
}
func NewWindsurfAuthService(
cfg config.WindsurfConfig,
accountRepo AccountRepository,
proxyRepo ProxyRepository,
adminSvc AdminService,
) *WindsurfAuthService {
authClient := &windsurf.AuthClient{
Auth1BaseURL: cfg.Auth1BaseURL,
SeatServiceBaseURL: cfg.SeatServiceBaseURL,
CodeiumRegisterURL: cfg.CodeiumRegisterURL,
FirebaseAPIKey: cfg.FirebaseAPIKey,
RequestTimeout: cfg.RequestTimeout,
}
return &WindsurfAuthService{
cfg: cfg,
authClient: authClient,
accountRepo: accountRepo,
proxyRepo: proxyRepo,
adminSvc: adminSvc,
}
}
type WindsurfLoginInput struct {
Email string
Password string
Name string
Notes *string
ProxyID *int64
GroupIDs []int64
Concurrency int
Priority int
ProbeAfter bool
LSInstanceID string
}
type WindsurfLoginOutput struct {
AccountID int64 `json:"account_id"`
Email string `json:"email"`
Tier string `json:"tier"`
AuthMethod string `json:"auth_method"`
APIKeyPresent bool `json:"api_key_present"`
RefreshTokenPresent bool `json:"refresh_token_present"`
}
func (s *WindsurfAuthService) Login(ctx context.Context, input *WindsurfLoginInput) (*WindsurfLoginOutput, error) {
existing, err := s.accountRepo.FindByCredentialField(ctx, domain.PlatformWindsurf, "email", input.Email)
if err != nil {
return nil, fmt.Errorf("check existing account: %w", err)
}
if len(existing) > 0 {
return nil, fmt.Errorf("windsurf account with email %s already exists (account_id=%d)", input.Email, existing[0].ID)
}
proxyURL := ""
if input.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *input.ProxyID)
if err != nil {
return nil, fmt.Errorf("get proxy: %w", err)
}
proxyURL = proxy.URL()
}
result, err := s.authClient.Login(ctx, input.Email, input.Password, proxyURL)
if err != nil {
return nil, err
}
creds := WindsurfCredentials{
Email: input.Email,
APIKey: result.APIKey,
RefreshToken: result.RefreshToken,
IDToken: result.IDToken,
SessionToken: result.SessionToken,
Auth1Token: result.Auth1Token,
AuthMethod: result.AuthMethod,
APIServerURL: result.APIServerURL,
RegisteredAt: time.Now().Format(time.RFC3339),
}
expiresAt := time.Now().Add(50 * time.Minute)
creds.ExpiresAt = expiresAt.Format(time.RFC3339)
credMap := StoreWindsurfCredentials(creds)
extra := WindsurfExtra{
Profile: WindsurfProfileSnapshot{
TierSource: "login",
},
Refresh: WindsurfRefreshState{},
}
if input.LSInstanceID != "" {
extra.LSBinding = WindsurfLSBinding{
ContainerID: input.LSInstanceID,
}
}
extraMap := StoreWindsurfExtra(extra)
name := input.Name
if name == "" {
if result.Name != "" {
name = result.Name
} else {
name = input.Email
}
}
concurrency := input.Concurrency
if concurrency <= 0 {
concurrency = 1
}
createInput := &CreateAccountInput{
Name: name,
Notes: input.Notes,
Platform: domain.PlatformWindsurf,
Type: domain.AccountTypeWindsurfSession,
Credentials: credMap,
Extra: extraMap,
ProxyID: input.ProxyID,
Concurrency: concurrency,
Priority: input.Priority,
GroupIDs: input.GroupIDs,
}
account, err := s.adminSvc.CreateAccount(ctx, createInput)
if err != nil {
return nil, fmt.Errorf("create account: %w", err)
}
return &WindsurfLoginOutput{
AccountID: account.ID,
Email: input.Email,
Tier: "unknown",
AuthMethod: result.AuthMethod,
APIKeyPresent: result.APIKey != "",
RefreshTokenPresent: result.RefreshToken != "",
}, nil
}
func (s *WindsurfAuthService) BatchLogin(ctx context.Context, items []string, proxyID *int64, groupIDs []int64, concurrency, priority int, probeAfter bool) ([]WindsurfBatchResult, error) {
results := make([]WindsurfBatchResult, 0, len(items))
for _, item := range items {
email, password, err := parseEmailPassword(item)
if err != nil {
results = append(results, WindsurfBatchResult{
Email: item,
Success: false,
Error: err.Error(),
})
continue
}
input := &WindsurfLoginInput{
Email: email,
Password: password,
ProxyID: proxyID,
GroupIDs: groupIDs,
Concurrency: concurrency,
Priority: priority,
ProbeAfter: probeAfter,
}
output, loginErr := s.Login(ctx, input)
if loginErr != nil {
results = append(results, WindsurfBatchResult{
Email: email,
Success: false,
Error: loginErr.Error(),
})
continue
}
results = append(results, WindsurfBatchResult{
Email: email,
Success: true,
AccountID: output.AccountID,
Output: output,
})
}
return results, nil
}
type WindsurfBatchResult struct {
Email string `json:"email"`
Success bool `json:"success"`
AccountID int64 `json:"account_id,omitempty"`
Output *WindsurfLoginOutput `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
func parseEmailPassword(item string) (string, string, error) {
sep := "----"
idx := -1
for i := 0; i <= len(item)-len(sep); i++ {
if item[i:i+len(sep)] == sep {
idx = i
break
}
}
if idx < 0 {
return "", "", fmt.Errorf("invalid format: expected email----password")
}
email := item[:idx]
password := item[idx+len(sep):]
if email == "" || password == "" {
return "", "", fmt.Errorf("email and password cannot be empty")
}
return email, password, nil
}
func (s *WindsurfAuthService) RefreshToken(ctx context.Context, accountID int64) error {
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return err
}
if account.Platform != domain.PlatformWindsurf {
return fmt.Errorf("account %d is not a windsurf account", accountID)
}
creds := LoadWindsurfCredentials(account.Credentials)
proxyURL := ""
if account.ProxyID != nil {
proxy, err := s.proxyRepo.GetByID(ctx, *account.ProxyID)
if err == nil {
proxyURL = proxy.URL()
}
}
if creds.AuthMethod == "firebase" && creds.RefreshToken != "" {
refreshResult, err := s.authClient.RefreshFirebaseToken(ctx, creds.RefreshToken, proxyURL)
if err != nil {
return fmt.Errorf("firebase refresh: %w", err)
}
creds.IDToken = refreshResult.IDToken
creds.RefreshToken = refreshResult.RefreshToken
creds.ExpiresAt = time.Now().Add(time.Duration(refreshResult.ExpiresIn) * time.Second).Format(time.RFC3339)
creds.LastRefreshAt = time.Now().Format(time.RFC3339)
regResult, err := s.authClient.ReRegisterWithCodeium(ctx, refreshResult.IDToken, proxyURL)
if err != nil {
slog.Warn("windsurf_reregister_failed", "account_id", accountID, "error", err)
} else {
creds.APIKey = regResult.APIKey
creds.LastReregisterAt = time.Now().Format(time.RFC3339)
}
} else if creds.AuthMethod == "auth1" {
// Auth1 tokens don't use Firebase refresh; re-login would be needed
return fmt.Errorf("auth1 accounts require re-login for token refresh")
} else {
return fmt.Errorf("unknown auth method: %s", creds.AuthMethod)
}
credMap := StoreWindsurfCredentials(creds)
account.Credentials = credMap
return s.accountRepo.Update(ctx, account)
}