package feichibridge import ( "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" "errors" "fmt" "net/http" "strings" "sync" "sync/atomic" "time" ) const feichiPublicKey = `-----BEGIN PUBLIC KEY----- MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDOwmFtEk1oJxDU0NI4kVO0Jx0X nt+Abx+JKGUzcRzPwjUkd5z9Ice5rh87CCmj0XjZ5pPac6TtA3f0v5FqiK/kjQY5 XMLti4weJ4dcp1/q1O7PCYxRX8WgetGxwsjxGn+uoZOZkclN1PFS4wRnKEso6+G/ e60QGB29cZoo4jZnZwIDAQAB -----END PUBLIC KEY-----` type LoginCredentials struct { Username string Password string MaxAttempts int RetryDelay time.Duration } type loginSession struct { credentials LoginCredentials solver CaptchaSolver mu sync.Mutex ready atomic.Bool csrf atomic.Value token atomic.Value } type firstLoginEnvelope struct { Code any `json:"code"` } type captchaPayload struct { Src string `json:"src"` } type loginPayload struct { Token string `json:"token"` } func newLoginSession(credentials LoginCredentials, solver CaptchaSolver) (*loginSession, error) { credentials.Username = strings.TrimSpace(credentials.Username) if credentials.Username == "" || credentials.Password == "" { return nil, errors.New("Feichi username and password are required") } if solver == nil { return nil, errors.New("captcha solver is required") } if credentials.MaxAttempts <= 0 { credentials.MaxAttempts = 20 } if credentials.RetryDelay <= 0 { credentials.RetryDelay = time.Second } session := &loginSession{credentials: credentials, solver: solver} session.csrf.Store("") session.token.Store("") return session, nil } func (s *loginSession) invalidate() { s.ready.Store(false) } func (s *loginSession) cookieHeader() string { var cookies []string if csrf := s.csrf.Load().(string); csrf != "" { cookies = append(cookies, "CSRF="+csrf) } if token := s.token.Load().(string); token != "" { cookies = append(cookies, "R_SESS="+token) } return strings.Join(cookies, "; ") } func (s *loginSession) csrfHeader() string { return s.csrf.Load().(string) } func (s *loginSession) ensure(ctx context.Context, client *APIClient) error { if s.ready.Load() { return nil } s.mu.Lock() defer s.mu.Unlock() if s.ready.Load() { return nil } var lastErr error for attempt := 1; attempt <= s.credentials.MaxAttempts; attempt++ { if err := s.loginAttempt(ctx, client); err == nil { s.ready.Store(true) return nil } else { lastErr = err } if attempt < s.credentials.MaxAttempts { timer := time.NewTimer(s.credentials.RetryDelay) select { case <-ctx.Done(): timer.Stop() return ctx.Err() case <-timer.C: } } } return fmt.Errorf("Feichi automatic login failed after %d attempts: %w", s.credentials.MaxAttempts, lastErr) } func (s *loginSession) loginAttempt(ctx context.Context, client *APIClient) error { s.ready.Store(false) s.token.Store("") var first firstLoginEnvelope headers, err := client.rawJSON(ctx, http.MethodGet, "api/v1/first-login", nil, &first) if err != nil { return fmt.Errorf("initialize login session: %w", err) } if !apiCodeOK(first.Code) { return apiCodeError(first.Code) } csrf := strings.TrimSpace(headers.Get("x-api-csrf")) if csrf == "" { for _, rawCookie := range headers.Values("Set-Cookie") { cookie, parseErr := http.ParseSetCookie(rawCookie) if parseErr == nil && cookie.Name == "CSRF" { csrf = cookie.Value break } } } if csrf == "" { return errors.New("first-login response did not provide CSRF") } s.csrf.Store(csrf) var captcha objectEnvelope[captchaPayload] if _, err := client.rawJSON(ctx, http.MethodGet, "api/v1/login/randCode", nil, &captcha); err != nil { return fmt.Errorf("request captcha: %w", err) } if !apiCodeOK(captcha.Code) { return apiCodeError(captcha.Code) } encodedImage := captcha.Data.Src if comma := strings.IndexByte(encodedImage, ','); comma >= 0 { encodedImage = encodedImage[comma+1:] } image, err := base64.StdEncoding.DecodeString(encodedImage) if err != nil { return fmt.Errorf("decode captcha image: %w", err) } validCode, err := s.solver.Solve(ctx, image) if err != nil { return err } encrypted, err := encryptFeichiPassword(s.credentials.Password) if err != nil { return err } var login objectEnvelope[loginPayload] if _, err := client.rawJSON(ctx, http.MethodPost, "api/v1/login", map[string]string{ "username": s.credentials.Username, "password": encrypted, "validCode": validCode, }, &login); err != nil { return fmt.Errorf("submit login: %w", err) } if !apiCodeOK(login.Code) { return apiCodeError(login.Code) } token := strings.TrimSpace(login.Data.Token) if token == "" { return errors.New("login succeeded without R_SESS token") } s.token.Store(token) return nil } func encryptFeichiPassword(password string) (string, error) { block, _ := pem.Decode([]byte(feichiPublicKey)) if block == nil { return "", errors.New("decode Feichi RSA public key") } parsed, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { return "", fmt.Errorf("parse Feichi RSA public key: %w", err) } publicKey, ok := parsed.(*rsa.PublicKey) if !ok { return "", errors.New("Feichi public key is not RSA") } encrypted, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, []byte(password)) if err != nil { return "", fmt.Errorf("encrypt Feichi password: %w", err) } return base64.StdEncoding.EncodeToString(encrypted), nil }