You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1265 lines
35 KiB
1265 lines
35 KiB
/* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* Copyright © 2026 WireGuard LLC. All Rights Reserved.
|
|
*/
|
|
|
|
package main
|
|
|
|
/*
|
|
#include <stdlib.h>
|
|
// These are JNI functions from Android, they won't work in standalone exe
|
|
// But we keep the signature for consistency with libwg-go if asked
|
|
// extern const char* requestCaptcha(const char* redirect_uri);
|
|
*/
|
|
// import "C" // Disabled cgo for standalone compatibility unless strictly needed
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
_ "image/jpeg"
|
|
"io"
|
|
"log"
|
|
"math/rand"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// --- Compatibility Shims for Standalone vk-turn-proxy ---
|
|
|
|
func turnLog(format string, v ...interface{}) {
|
|
log.Printf(format, v...)
|
|
}
|
|
|
|
// Simple host cache for standalone
|
|
var hostCache = &simpleHostCache{}
|
|
|
|
type simpleHostCache struct{}
|
|
|
|
func (s *simpleHostCache) Resolve(ctx context.Context, host string) (string, error) {
|
|
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(ips) == 0 {
|
|
return "", fmt.Errorf("no IP found for %s", host)
|
|
}
|
|
return ips[0].IP.String(), nil
|
|
}
|
|
|
|
// No-op protectControl for standalone
|
|
func protectControl(network, address string, c syscall.RawConn) error {
|
|
return nil
|
|
}
|
|
|
|
// --- Original libwg-go code (adapted) ---
|
|
|
|
// VkCaptchaError represents a VK captcha error
|
|
type VkCaptchaError struct {
|
|
ErrorCode int
|
|
ErrorMsg string
|
|
CaptchaSid string
|
|
CaptchaImg string
|
|
RedirectUri string
|
|
IsSoundCaptchaAvailable bool
|
|
SessionToken string
|
|
CaptchaTs string // captcha_ts from error
|
|
CaptchaAttempt string // captcha_attempt from error
|
|
}
|
|
|
|
// ParseVkCaptchaError parses a VK error response into VkCaptchaError
|
|
func ParseVkCaptchaError(errData map[string]interface{}) *VkCaptchaError {
|
|
codeFloat, _ := errData["error_code"].(float64)
|
|
code := int(codeFloat)
|
|
|
|
redirectUri, _ := errData["redirect_uri"].(string)
|
|
captchaSid, _ := errData["captcha_sid"].(string)
|
|
captchaImg, _ := errData["captcha_img"].(string)
|
|
errorMsg, _ := errData["error_msg"].(string)
|
|
|
|
// Extract session_token from redirect_uri
|
|
var sessionToken string
|
|
if redirectUri != "" {
|
|
if parsed, err := url.Parse(redirectUri); err == nil {
|
|
sessionToken = parsed.Query().Get("session_token")
|
|
}
|
|
}
|
|
|
|
isSound, _ := errData["is_sound_captcha_available"].(bool)
|
|
|
|
// captcha_ts can be float64 (scientific notation) or string
|
|
var captchaTs string
|
|
if tsFloat, ok := errData["captcha_ts"].(float64); ok {
|
|
captchaTs = fmt.Sprintf("%.0f", tsFloat)
|
|
} else if tsStr, ok := errData["captcha_ts"].(string); ok {
|
|
captchaTs = tsStr
|
|
}
|
|
|
|
// captcha_attempt is usually a float64
|
|
var captchaAttempt string
|
|
if attFloat, ok := errData["captcha_attempt"].(float64); ok {
|
|
captchaAttempt = fmt.Sprintf("%.0f", attFloat)
|
|
} else if attStr, ok := errData["captcha_attempt"].(string); ok {
|
|
captchaAttempt = attStr
|
|
}
|
|
|
|
return &VkCaptchaError{
|
|
ErrorCode: code,
|
|
ErrorMsg: errorMsg,
|
|
CaptchaSid: captchaSid,
|
|
CaptchaImg: captchaImg,
|
|
RedirectUri: redirectUri,
|
|
IsSoundCaptchaAvailable: isSound,
|
|
SessionToken: sessionToken,
|
|
CaptchaTs: captchaTs,
|
|
CaptchaAttempt: captchaAttempt,
|
|
}
|
|
}
|
|
|
|
// IsCaptchaError checks if the error data is a Not Robot Captcha error
|
|
func (e *VkCaptchaError) IsCaptchaError() bool {
|
|
return e.ErrorCode == 14 && e.RedirectUri != "" && e.SessionToken != ""
|
|
}
|
|
|
|
// captchaMutex serializes captcha solving to avoid multiple concurrent attempts
|
|
var captchaMutex sync.Mutex
|
|
|
|
// SolveVkCaptcha solves the VK Not Robot Captcha and returns success_token
|
|
// First tries automatic solution, falls back to manual solution if it fails
|
|
func SolveVkCaptcha(ctx context.Context, captchaErr *VkCaptchaError) (string, error) {
|
|
// Serialize captcha solving to avoid multiple concurrent attempts
|
|
captchaMutex.Lock()
|
|
defer captchaMutex.Unlock()
|
|
|
|
turnLog("[Captcha] Solving Not Robot Captcha...")
|
|
|
|
// Step 1: Try automatic solution
|
|
turnLog("[Captcha] Attempting automatic solution...")
|
|
successToken, err := solveVkCaptchaAutomatic(ctx, captchaErr)
|
|
if err == nil && successToken != "" {
|
|
turnLog("[Captcha] Automatic solution SUCCESS!")
|
|
return successToken, nil
|
|
}
|
|
|
|
turnLog("[Captcha] Automatic solution FAILED: %v", err)
|
|
|
|
// Step 2: Fall back to manual solving
|
|
turnLog("[Captcha] Triggering manual captcha fallback...")
|
|
if captchaErr.RedirectUri != "" {
|
|
return solveCaptchaViaProxy(captchaErr.RedirectUri)
|
|
} else if captchaErr.CaptchaImg != "" {
|
|
return solveCaptchaViaHTTP(captchaErr.CaptchaImg)
|
|
}
|
|
|
|
return "", fmt.Errorf("no more solve modes available")
|
|
}
|
|
|
|
// solveVkCaptchaAutomatic performs the automatic captcha solving without UI
|
|
func solveVkCaptchaAutomatic(ctx context.Context, captchaErr *VkCaptchaError) (string, error) {
|
|
sessionToken := captchaErr.SessionToken
|
|
if sessionToken == "" {
|
|
return "", fmt.Errorf("no session_token in redirect_uri")
|
|
}
|
|
|
|
// Step 1: Fetch the captcha HTML page to get powInput
|
|
bootstrap, err := fetchCaptchaBootstrap(ctx, captchaErr.RedirectUri)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to fetch captcha bootstrap: %w", err)
|
|
}
|
|
|
|
turnLog("[Captcha] PoW input: %s, difficulty: %d", bootstrap.PowInput, bootstrap.Difficulty)
|
|
|
|
// Step 2: Solve PoW
|
|
hash := solvePoW(bootstrap.PowInput, bootstrap.Difficulty)
|
|
turnLog("[Captcha] PoW solved: hash=%s", hash)
|
|
|
|
// Step 3: Call captchaNotRobot API with slider POC support
|
|
successToken, err := callCaptchaNotRobotWithSliderPOC(ctx, sessionToken, hash, bootstrap.Settings)
|
|
if err != nil {
|
|
return "", fmt.Errorf("captchaNotRobot API failed: %w", err)
|
|
}
|
|
|
|
turnLog("[Captcha] Success! Got success_token")
|
|
return successToken, nil
|
|
}
|
|
|
|
// fetchCaptchaBootstrap fetches the captcha HTML page and extracts PoW input, difficulty, and settings
|
|
func fetchCaptchaBootstrap(ctx context.Context, redirectUri string) (*captchaBootstrap, error) {
|
|
parsedURL, err := url.Parse(redirectUri)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse redirect_uri: %w", err)
|
|
}
|
|
|
|
domain := parsedURL.Hostname()
|
|
resolvedIP, err := hostCache.Resolve(ctx, domain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err)
|
|
}
|
|
|
|
port := parsedURL.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path
|
|
if parsedURL.RawQuery != "" {
|
|
ipURL += "?" + parsedURL.RawQuery
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", ipURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Host = domain
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
|
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
|
|
client := &http.Client{
|
|
Timeout: 20 * time.Second,
|
|
Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
// Control: protectControl, // Disabled for standalone
|
|
}).DialContext,
|
|
TLSClientConfig: &tls.Config{
|
|
ServerName: domain,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
html := string(body)
|
|
bootstrap, err := parseCaptchaBootstrapHTML(html)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return bootstrap, nil
|
|
}
|
|
|
|
// solvePoW finds nonce where SHA-256(powInput + nonce) starts with '0' * difficulty
|
|
func solvePoW(powInput string, difficulty int) string {
|
|
target := strings.Repeat("0", difficulty)
|
|
|
|
for nonce := 1; nonce <= 10000000; nonce++ {
|
|
data := powInput + strconv.Itoa(nonce)
|
|
hash := sha256.Sum256([]byte(data))
|
|
hexHash := hex.EncodeToString(hash[:])
|
|
|
|
if strings.HasPrefix(hexHash, target) {
|
|
return hexHash
|
|
}
|
|
}
|
|
|
|
// Fallback: should not happen with difficulty <= 3
|
|
return ""
|
|
}
|
|
|
|
const (
|
|
sliderCaptchaType = "slider"
|
|
defaultSliderAttempts = 4
|
|
)
|
|
|
|
// captchaBootstrap holds parsed captcha bootstrap data
|
|
type captchaBootstrap struct {
|
|
PowInput string
|
|
Difficulty int
|
|
Settings *captchaSettingsResponse
|
|
}
|
|
|
|
// captchaSettingsResponse holds captcha settings from VK API
|
|
type captchaSettingsResponse struct {
|
|
ShowCaptchaType string
|
|
SettingsByType map[string]string
|
|
}
|
|
|
|
// captchaCheckResult holds the result of a captcha check request
|
|
type captchaCheckResult struct {
|
|
Status string
|
|
SuccessToken string
|
|
ShowCaptchaType string
|
|
}
|
|
|
|
// sliderCaptchaContent holds decoded slider captcha content
|
|
type sliderCaptchaContent struct {
|
|
Image image.Image
|
|
Size int
|
|
Steps []int
|
|
Attempts int
|
|
}
|
|
|
|
// sliderCandidate represents a ranked slider candidate
|
|
type sliderCandidate struct {
|
|
Index int
|
|
ActiveSteps []int
|
|
Score int64
|
|
}
|
|
|
|
// captchaNotRobotSession represents a captcha solving session
|
|
type captchaNotRobotSession struct {
|
|
ctx context.Context
|
|
sessionToken string
|
|
hash string
|
|
browserFp string
|
|
}
|
|
|
|
// newCaptchaNotRobotSession creates a new captcha solving session
|
|
func newCaptchaNotRobotSession(
|
|
ctx context.Context,
|
|
sessionToken string,
|
|
hash string,
|
|
) *captchaNotRobotSession {
|
|
// Generate random browser fingerprint
|
|
browserFp := fmt.Sprintf("%032x", randInt63())
|
|
|
|
return &captchaNotRobotSession{
|
|
ctx: ctx,
|
|
sessionToken: sessionToken,
|
|
hash: hash,
|
|
browserFp: browserFp,
|
|
}
|
|
}
|
|
|
|
// baseValues returns base URL values for API requests
|
|
func (s *captchaNotRobotSession) baseValues() url.Values {
|
|
values := url.Values{}
|
|
values.Set("session_token", s.sessionToken)
|
|
values.Set("domain", "vk.com")
|
|
values.Set("adFp", "")
|
|
values.Set("access_token", "")
|
|
return values
|
|
}
|
|
|
|
// request makes a VK API request
|
|
func (s *captchaNotRobotSession) request(method string, values url.Values) (map[string]interface{}, error) {
|
|
reqURL := "https://api.vk.ru/method/" + method + "?v=5.131"
|
|
|
|
parsedURL, err := url.Parse(reqURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
domain := parsedURL.Hostname()
|
|
resolvedIP, err := hostCache.Resolve(s.ctx, domain)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DNS resolution failed for %s: %w", domain, err)
|
|
}
|
|
|
|
port := parsedURL.Port()
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
ipURL := "https://" + resolvedIP + ":" + port + parsedURL.Path
|
|
if parsedURL.RawQuery != "" {
|
|
ipURL += "?" + parsedURL.RawQuery
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(s.ctx, "POST", ipURL, strings.NewReader(values.Encode()))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
req.Host = domain
|
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36")
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "*/*")
|
|
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
|
|
req.Header.Set("Origin", "https://vk.ru")
|
|
req.Header.Set("Referer", "https://vk.ru/")
|
|
req.Header.Set("sec-ch-ua-platform", "\"Linux\"")
|
|
req.Header.Set("sec-ch-ua", "\"Chromium\";v=\"146\", \"Not-A.Brand\";v=\"24\", \"Google Chrome\";v=\"146\"")
|
|
req.Header.Set("sec-ch-ua-mobile", "?0")
|
|
req.Header.Set("DNT", "1")
|
|
req.Header.Set("Sec-Fetch-Site", "same-site")
|
|
req.Header.Set("Sec-Fetch-Mode", "cors")
|
|
req.Header.Set("Sec-Fetch-Dest", "empty")
|
|
req.Header.Set("Sec-GPC", "1")
|
|
|
|
client := &http.Client{
|
|
Timeout: 20 * time.Second,
|
|
Transport: &http.Transport{
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
// Control: protectControl,
|
|
}).DialContext,
|
|
TLSClientConfig: &tls.Config{
|
|
ServerName: domain,
|
|
},
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var respMap map[string]interface{}
|
|
if err := json.Unmarshal(body, &respMap); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return respMap, nil
|
|
}
|
|
|
|
// requestSettings fetches captcha settings from VK API
|
|
func (s *captchaNotRobotSession) requestSettings() (*captchaSettingsResponse, error) {
|
|
resp, err := s.request("captchaNotRobot.settings", s.baseValues())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("settings failed: %w", err)
|
|
}
|
|
return parseCaptchaSettingsResponse(resp)
|
|
}
|
|
|
|
// requestComponentDone marks the component as done
|
|
func (s *captchaNotRobotSession) requestComponentDone() error {
|
|
values := s.baseValues()
|
|
values.Set("browser_fp", s.browserFp)
|
|
values.Set("device", buildCaptchaDeviceJSON())
|
|
|
|
resp, err := s.request("captchaNotRobot.componentDone", values)
|
|
if err != nil {
|
|
return fmt.Errorf("componentDone failed: %w", err)
|
|
}
|
|
|
|
respObj, ok := resp["response"].(map[string]interface{})
|
|
if ok {
|
|
if status, _ := respObj["status"].(string); status != "" && status != "OK" {
|
|
return fmt.Errorf("componentDone status: %s", status)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// requestCheckboxCheck performs a checkbox-style captcha check
|
|
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) {
|
|
return s.requestCheck("[]", base64.StdEncoding.EncodeToString([]byte("{}")))
|
|
}
|
|
|
|
// requestSliderContent fetches slider captcha content
|
|
func (s *captchaNotRobotSession) requestSliderContent(sliderSettings string) (*sliderCaptchaContent, error) {
|
|
values := s.baseValues()
|
|
if sliderSettings != "" {
|
|
values.Set("captcha_settings", sliderSettings)
|
|
}
|
|
|
|
resp, err := s.request("captchaNotRobot.getContent", values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("getContent failed: %w", err)
|
|
}
|
|
return parseSliderCaptchaContentResponse(resp)
|
|
}
|
|
|
|
// requestSliderCheck performs a slider captcha check
|
|
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) {
|
|
answer, err := encodeSliderAnswer(activeSteps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return s.requestCheck(generateSliderCursor(candidateIndex, candidateCount), answer)
|
|
}
|
|
|
|
// requestCheck performs the main captcha check request
|
|
func (s *captchaNotRobotSession) requestCheck(cursor string, answer string) (*captchaCheckResult, error) {
|
|
values := s.baseValues()
|
|
values.Set("accelerometer", "[]")
|
|
values.Set("gyroscope", "[]")
|
|
values.Set("motion", "[]")
|
|
values.Set("cursor", cursor)
|
|
values.Set("taps", "[]")
|
|
values.Set("connectionRtt", "[]")
|
|
values.Set("connectionDownlink", "[]")
|
|
values.Set("browser_fp", s.browserFp)
|
|
values.Set("hash", s.hash)
|
|
values.Set("answer", answer)
|
|
values.Set("debug_info", "d44f534ce8deb56ba20be52e05c433309b49ee4d2a70602deeb17a1954257785")
|
|
|
|
resp, err := s.request("captchaNotRobot.check", values)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("check failed: %w", err)
|
|
}
|
|
return parseCaptchaCheckResult(resp)
|
|
}
|
|
|
|
// requestEndSession ends the captcha session
|
|
func (s *captchaNotRobotSession) requestEndSession() {
|
|
turnLog("[Captcha] Step 4/4: endSession")
|
|
if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil {
|
|
turnLog("[Captcha] Warning: endSession failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// callCaptchaNotRobotWithSliderPOC solves captcha with slider POC support
|
|
func callCaptchaNotRobotWithSliderPOC(
|
|
ctx context.Context,
|
|
sessionToken string,
|
|
hash string,
|
|
initialSettings *captchaSettingsResponse,
|
|
) (string, error) {
|
|
session := newCaptchaNotRobotSession(ctx, sessionToken, hash)
|
|
|
|
turnLog("[Captcha] Step 1/4: settings")
|
|
settingsResp, err := session.requestSettings()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
settingsResp = mergeCaptchaSettings(settingsResp, initialSettings)
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
turnLog("[Captcha] Step 2/4: componentDone")
|
|
if err := session.requestComponentDone(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
turnLog("[Captcha] Step 3/4: check")
|
|
initialCheck, err := session.requestCheckboxCheck()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if initialCheck.Status == "OK" {
|
|
if initialCheck.SuccessToken == "" {
|
|
return "", fmt.Errorf("success_token not found")
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
session.requestEndSession()
|
|
return initialCheck.SuccessToken, nil
|
|
}
|
|
|
|
sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType]
|
|
turnLog(
|
|
"[Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)",
|
|
initialCheck.Status,
|
|
settingsResp.ShowCaptchaType,
|
|
initialCheck.ShowCaptchaType,
|
|
describeCaptchaTypes(settingsResp.SettingsByType),
|
|
)
|
|
|
|
if !hasSlider {
|
|
turnLog(
|
|
"[Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...",
|
|
)
|
|
} else {
|
|
turnLog("[Captcha] Trying experimental slider solver...")
|
|
}
|
|
|
|
sliderContent, err := session.requestSliderContent(sliderSettings)
|
|
if err != nil {
|
|
return "", fmt.Errorf("check status: %s (slider getContent failed: %w)", initialCheck.Status, err)
|
|
}
|
|
|
|
candidates, err := rankSliderCandidates(sliderContent.Image, sliderContent.Size, sliderContent.Steps)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
turnLog(
|
|
"[Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d",
|
|
len(candidates),
|
|
minInt(sliderContent.Attempts, len(candidates)),
|
|
sliderContent.Attempts,
|
|
)
|
|
|
|
successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) {
|
|
turnLog(
|
|
"[Captcha] Slider guess position=%d score=%d",
|
|
candidate.Index,
|
|
candidate.Score,
|
|
)
|
|
return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates))
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
time.Sleep(200 * time.Millisecond)
|
|
session.requestEndSession()
|
|
return successToken, nil
|
|
}
|
|
|
|
// buildCaptchaDeviceJSON builds device information JSON
|
|
func buildCaptchaDeviceJSON() string {
|
|
return fmt.Sprintf(
|
|
`{"screenWidth":1920,"screenHeight":1080,"screenAvailWidth":1920,"screenAvailHeight":1040,"innerWidth":1920,"innerHeight":969,"devicePixelRatio":1,"language":"en-US","languages":["en-US"],"webdriver":false,"hardwareConcurrency":8,"deviceMemory":8,"connectionEffectiveType":"4g","notificationsPermission":"default"}`,
|
|
)
|
|
}
|
|
|
|
// parseCaptchaSettingsResponse parses captcha settings from API response
|
|
func parseCaptchaSettingsResponse(resp map[string]interface{}) (*captchaSettingsResponse, error) {
|
|
respObj, ok := resp["response"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid settings response: %v", resp)
|
|
}
|
|
|
|
settings := &captchaSettingsResponse{
|
|
SettingsByType: make(map[string]string),
|
|
}
|
|
settings.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
|
|
|
|
rawSettings, ok := expandCaptchaSettings(respObj["captcha_settings"])
|
|
if !ok {
|
|
return settings, nil
|
|
}
|
|
|
|
for _, rawItem := range rawSettings {
|
|
item, ok := rawItem.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
captchaType, _ := item["type"].(string)
|
|
if captchaType == "" {
|
|
continue
|
|
}
|
|
|
|
normalized, err := normalizeCaptchaSettings(item["settings"])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid captcha_settings for %s: %w", captchaType, err)
|
|
}
|
|
|
|
settings.SettingsByType[captchaType] = normalized
|
|
}
|
|
|
|
return settings, nil
|
|
}
|
|
|
|
// parseCaptchaBootstrapHTML parses HTML page to extract PoW input and settings
|
|
func parseCaptchaBootstrapHTML(html string) (*captchaBootstrap, error) {
|
|
powInputRe := regexp.MustCompile(`const\s+powInput\s*=\s*"([^"]+)"`)
|
|
powInputMatch := powInputRe.FindStringSubmatch(html)
|
|
if len(powInputMatch) < 2 {
|
|
return nil, fmt.Errorf("powInput not found in captcha HTML")
|
|
}
|
|
|
|
difficulty := 2
|
|
for _, expr := range []*regexp.Regexp{
|
|
regexp.MustCompile(`startsWith\('0'\.repeat\((\d+)\)\)`),
|
|
regexp.MustCompile(`const\s+difficulty\s*=\s*(\d+)`),
|
|
} {
|
|
if match := expr.FindStringSubmatch(html); len(match) >= 2 {
|
|
if parsed, err := strconv.Atoi(match[1]); err == nil {
|
|
difficulty = parsed
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
settings, err := parseCaptchaSettingsFromHTML(html)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &captchaBootstrap{
|
|
PowInput: powInputMatch[1],
|
|
Difficulty: difficulty,
|
|
Settings: settings,
|
|
}, nil
|
|
}
|
|
|
|
// parseCaptchaSettingsFromHTML parses captcha settings from HTML window.init
|
|
func parseCaptchaSettingsFromHTML(html string) (*captchaSettingsResponse, error) {
|
|
initRe := regexp.MustCompile(`(?s)window\.init\s*=\s*(\{.*?})\s*;\s*window\.lang`)
|
|
initMatch := initRe.FindStringSubmatch(html)
|
|
if len(initMatch) < 2 {
|
|
return &captchaSettingsResponse{SettingsByType: make(map[string]string)}, nil
|
|
}
|
|
|
|
var initPayload struct {
|
|
Data struct {
|
|
ShowCaptchaType string `json:"show_captcha_type"`
|
|
CaptchaSettings interface{} `json:"captcha_settings"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(initMatch[1]), &initPayload); err != nil {
|
|
return nil, fmt.Errorf("parse window.init captcha data: %w", err)
|
|
}
|
|
|
|
return parseCaptchaSettingsResponse(map[string]interface{}{
|
|
"response": map[string]interface{}{
|
|
"show_captcha_type": initPayload.Data.ShowCaptchaType,
|
|
"captcha_settings": initPayload.Data.CaptchaSettings,
|
|
},
|
|
})
|
|
}
|
|
|
|
// mergeCaptchaSettings merges two captcha settings responses
|
|
func mergeCaptchaSettings(primary *captchaSettingsResponse, fallback *captchaSettingsResponse) *captchaSettingsResponse {
|
|
if primary == nil {
|
|
return cloneCaptchaSettings(fallback)
|
|
}
|
|
if primary.SettingsByType == nil {
|
|
primary.SettingsByType = make(map[string]string)
|
|
}
|
|
if fallback == nil {
|
|
return primary
|
|
}
|
|
if primary.ShowCaptchaType == "" {
|
|
primary.ShowCaptchaType = fallback.ShowCaptchaType
|
|
}
|
|
for captchaType, settings := range fallback.SettingsByType {
|
|
if _, exists := primary.SettingsByType[captchaType]; !exists {
|
|
primary.SettingsByType[captchaType] = settings
|
|
}
|
|
}
|
|
return primary
|
|
}
|
|
|
|
// cloneCaptchaSettings clones a captcha settings response
|
|
func cloneCaptchaSettings(src *captchaSettingsResponse) *captchaSettingsResponse {
|
|
if src == nil {
|
|
return nil
|
|
}
|
|
|
|
cloned := &captchaSettingsResponse{
|
|
ShowCaptchaType: src.ShowCaptchaType,
|
|
SettingsByType: make(map[string]string, len(src.SettingsByType)),
|
|
}
|
|
for captchaType, settings := range src.SettingsByType {
|
|
cloned.SettingsByType[captchaType] = settings
|
|
}
|
|
return cloned
|
|
}
|
|
|
|
// expandCaptchaSettings expands raw captcha settings into a slice
|
|
func expandCaptchaSettings(raw interface{}) ([]interface{}, bool) {
|
|
switch value := raw.(type) {
|
|
case nil:
|
|
return nil, false
|
|
case []interface{}:
|
|
return value, true
|
|
case map[string]interface{}:
|
|
items := make([]interface{}, 0, len(value))
|
|
for captchaType, settings := range value {
|
|
items = append(items, map[string]interface{}{
|
|
"type": captchaType,
|
|
"settings": settings,
|
|
})
|
|
}
|
|
return items, true
|
|
case string:
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil, false
|
|
}
|
|
|
|
var items []interface{}
|
|
if err := json.Unmarshal([]byte(trimmed), &items); err == nil {
|
|
return items, true
|
|
}
|
|
|
|
var mapping map[string]interface{}
|
|
if err := json.Unmarshal([]byte(trimmed), &mapping); err == nil {
|
|
return expandCaptchaSettings(mapping)
|
|
}
|
|
}
|
|
|
|
return nil, false
|
|
}
|
|
|
|
// normalizeCaptchaSettings normalizes captcha settings to string
|
|
func normalizeCaptchaSettings(raw interface{}) (string, error) {
|
|
switch value := raw.(type) {
|
|
case nil:
|
|
return "", nil
|
|
case string:
|
|
return value, nil
|
|
default:
|
|
data, err := json.Marshal(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(data), nil
|
|
}
|
|
}
|
|
|
|
// parseCaptchaCheckResult parses captcha check result from API response
|
|
func parseCaptchaCheckResult(resp map[string]interface{}) (*captchaCheckResult, error) {
|
|
respObj, ok := resp["response"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid check response: %v", resp)
|
|
}
|
|
|
|
result := &captchaCheckResult{}
|
|
result.Status, _ = respObj["status"].(string)
|
|
result.SuccessToken, _ = respObj["success_token"].(string)
|
|
result.ShowCaptchaType, _ = respObj["show_captcha_type"].(string)
|
|
if result.Status == "" {
|
|
return nil, fmt.Errorf("check status missing: %v", resp)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// parseSliderCaptchaContentResponse parses slider captcha content from API response
|
|
func parseSliderCaptchaContentResponse(resp map[string]interface{}) (*sliderCaptchaContent, error) {
|
|
respObj, ok := resp["response"].(map[string]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("invalid slider content response: %v", resp)
|
|
}
|
|
|
|
status, _ := respObj["status"].(string)
|
|
if status != "OK" {
|
|
return nil, fmt.Errorf("slider getContent status: %s", status)
|
|
}
|
|
|
|
extension, _ := respObj["extension"].(string)
|
|
extension = strings.ToLower(extension)
|
|
if extension != "jpeg" && extension != "jpg" {
|
|
return nil, fmt.Errorf("unsupported slider image format: %s", extension)
|
|
}
|
|
|
|
rawImage, _ := respObj["image"].(string)
|
|
if rawImage == "" {
|
|
return nil, fmt.Errorf("slider image missing")
|
|
}
|
|
|
|
rawSteps, ok := respObj["steps"].([]interface{})
|
|
if !ok {
|
|
return nil, fmt.Errorf("slider steps missing")
|
|
}
|
|
|
|
steps, err := parseIntSlice(rawSteps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
size, swaps, attempts, err := parseSliderSteps(steps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
img, err := decodeSliderImage(rawImage)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &sliderCaptchaContent{
|
|
Image: img,
|
|
Size: size,
|
|
Steps: swaps,
|
|
Attempts: attempts,
|
|
}, nil
|
|
}
|
|
|
|
// parseIntSlice parses a slice of integers from interface{}
|
|
func parseIntSlice(raw []interface{}) ([]int, error) {
|
|
values := make([]int, 0, len(raw))
|
|
for _, item := range raw {
|
|
number, err := parseIntValue(item)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values = append(values, number)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// parseIntValue parses a single integer from interface{}
|
|
func parseIntValue(raw interface{}) (int, error) {
|
|
switch value := raw.(type) {
|
|
case float64:
|
|
return int(value), nil
|
|
case int:
|
|
return value, nil
|
|
case string:
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid numeric value: %v", raw)
|
|
}
|
|
return parsed, nil
|
|
default:
|
|
return 0, fmt.Errorf("invalid numeric value: %v", raw)
|
|
}
|
|
}
|
|
|
|
// parseSliderSteps parses slider steps into size, swaps, and attempts
|
|
func parseSliderSteps(steps []int) (int, []int, int, error) {
|
|
if len(steps) < 3 {
|
|
return 0, nil, 0, fmt.Errorf("slider steps payload too short")
|
|
}
|
|
|
|
size := steps[0]
|
|
if size <= 0 {
|
|
return 0, nil, 0, fmt.Errorf("invalid slider size: %d", size)
|
|
}
|
|
|
|
remaining := append([]int(nil), steps[1:]...)
|
|
attempts := defaultSliderAttempts
|
|
if len(remaining)%2 != 0 {
|
|
attempts = remaining[len(remaining)-1]
|
|
remaining = remaining[:len(remaining)-1]
|
|
}
|
|
if attempts <= 0 {
|
|
attempts = defaultSliderAttempts
|
|
}
|
|
if len(remaining) == 0 || len(remaining)%2 != 0 {
|
|
return 0, nil, 0, fmt.Errorf("invalid slider swap payload")
|
|
}
|
|
|
|
return size, remaining, attempts, nil
|
|
}
|
|
|
|
// decodeSliderImage decodes base64-encoded slider image
|
|
func decodeSliderImage(rawImage string) (image.Image, error) {
|
|
decoded, err := base64.StdEncoding.DecodeString(rawImage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode slider image: %w", err)
|
|
}
|
|
|
|
img, _, err := image.Decode(bytes.NewReader(decoded))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("decode slider image: %w", err)
|
|
}
|
|
|
|
return img, nil
|
|
}
|
|
|
|
// encodeSliderAnswer encodes slider answer to base64 JSON
|
|
func encodeSliderAnswer(activeSteps []int) (string, error) {
|
|
payload := struct {
|
|
Value []int `json:"value"`
|
|
}{
|
|
Value: activeSteps,
|
|
}
|
|
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return base64.StdEncoding.EncodeToString(data), nil
|
|
}
|
|
|
|
// buildSliderActiveSteps builds active steps for a candidate
|
|
func buildSliderActiveSteps(swaps []int, candidateIndex int) []int {
|
|
if candidateIndex <= 0 {
|
|
return []int{}
|
|
}
|
|
|
|
end := candidateIndex * 2
|
|
if end > len(swaps) {
|
|
end = len(swaps)
|
|
}
|
|
|
|
return append([]int(nil), swaps[:end]...)
|
|
}
|
|
|
|
// buildSliderTileMapping builds tile mapping for a candidate
|
|
func buildSliderTileMapping(gridSize int, activeSteps []int) ([]int, error) {
|
|
tileCount := gridSize * gridSize
|
|
if tileCount <= 0 {
|
|
return nil, fmt.Errorf("invalid slider tile count: %d", tileCount)
|
|
}
|
|
if len(activeSteps)%2 != 0 {
|
|
return nil, fmt.Errorf("invalid active steps length: %d", len(activeSteps) / 2)
|
|
}
|
|
|
|
mapping := make([]int, tileCount)
|
|
for i := range mapping {
|
|
mapping[i] = i
|
|
}
|
|
|
|
for idx := 0; idx < len(activeSteps); idx += 2 {
|
|
left := activeSteps[idx]
|
|
right := activeSteps[idx+1]
|
|
if left < 0 || right < 0 || left >= tileCount || right >= tileCount {
|
|
return nil, fmt.Errorf("slider step out of range: %d,%d", left, right)
|
|
}
|
|
mapping[left], mapping[right] = mapping[right], mapping[left]
|
|
}
|
|
|
|
return mapping, nil
|
|
}
|
|
|
|
// rankSliderCandidates ranks slider candidates by score
|
|
func rankSliderCandidates(img image.Image, gridSize int, swaps []int) ([]sliderCandidate, error) {
|
|
candidateCount := len(swaps) / 2
|
|
if candidateCount == 0 {
|
|
return nil, fmt.Errorf("slider has no candidates")
|
|
}
|
|
|
|
candidates := make([]sliderCandidate, 0, candidateCount)
|
|
for idx := 1; idx <= candidateCount; idx++ {
|
|
activeSteps := buildSliderActiveSteps(swaps, idx)
|
|
mapping, err := buildSliderTileMapping(gridSize, activeSteps)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
score, err := scoreSliderCandidate(img, gridSize, mapping)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
candidates = append(candidates, sliderCandidate{
|
|
Index: idx,
|
|
ActiveSteps: activeSteps,
|
|
Score: score,
|
|
})
|
|
}
|
|
|
|
sort.SliceStable(candidates, func(i, j int) bool {
|
|
if candidates[i].Score == candidates[j].Score {
|
|
return candidates[i].Index < candidates[j].Index
|
|
}
|
|
return candidates[i].Score < candidates[j].Score
|
|
})
|
|
|
|
return candidates, nil
|
|
}
|
|
|
|
// scoreSliderCandidate scores a slider candidate
|
|
func scoreSliderCandidate(img image.Image, gridSize int, mapping []int) (int64, error) {
|
|
rendered, err := renderSliderCandidate(img, gridSize, mapping)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return scoreRenderedSliderImage(rendered, gridSize), nil
|
|
}
|
|
|
|
// renderSliderCandidate renders a slider candidate image
|
|
func renderSliderCandidate(img image.Image, gridSize int, mapping []int) (*image.RGBA, error) {
|
|
if gridSize <= 0 {
|
|
return nil, fmt.Errorf("invalid grid size: %d", gridSize)
|
|
}
|
|
|
|
tileCount := gridSize * gridSize
|
|
if len(mapping) != tileCount {
|
|
return nil, fmt.Errorf("unexpected tile mapping length: %d", len(mapping))
|
|
}
|
|
|
|
bounds := img.Bounds()
|
|
rendered := image.NewRGBA(bounds)
|
|
for dstIndex, srcIndex := range mapping {
|
|
srcRect := sliderTileRect(bounds, gridSize, srcIndex)
|
|
dstRect := sliderTileRect(bounds, gridSize, dstIndex)
|
|
copyScaledTile(rendered, dstRect, img, srcRect)
|
|
}
|
|
|
|
return rendered, nil
|
|
}
|
|
|
|
// scoreRenderedSliderImage scores a rendered slider image
|
|
func scoreRenderedSliderImage(img image.Image, gridSize int) int64 {
|
|
bounds := img.Bounds()
|
|
var score int64
|
|
|
|
for row := 0; row < gridSize; row++ {
|
|
for col := 0; col < gridSize-1; col++ {
|
|
leftRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
|
|
rightRect := sliderTileRect(bounds, gridSize, row*gridSize+col+1)
|
|
height := minInt(leftRect.Dy(), rightRect.Dy())
|
|
for offset := 0; offset < height; offset++ {
|
|
score += pixelDiff(
|
|
img.At(leftRect.Max.X-1, leftRect.Min.Y+offset),
|
|
img.At(rightRect.Min.X, rightRect.Min.Y+offset),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
for row := 0; row < gridSize-1; row++ {
|
|
for col := 0; col < gridSize; col++ {
|
|
topRect := sliderTileRect(bounds, gridSize, row*gridSize+col)
|
|
bottomRect := sliderTileRect(bounds, gridSize, (row+1)*gridSize+col)
|
|
width := minInt(topRect.Dx(), bottomRect.Dx())
|
|
for offset := 0; offset < width; offset++ {
|
|
score += pixelDiff(
|
|
img.At(topRect.Min.X+offset, topRect.Max.Y-1),
|
|
img.At(bottomRect.Min.X+offset, bottomRect.Min.Y),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
// sliderTileRect returns the rectangle for a tile
|
|
func sliderTileRect(bounds image.Rectangle, gridSize int, index int) image.Rectangle {
|
|
row := index / gridSize
|
|
col := index % gridSize
|
|
|
|
x0 := bounds.Min.X + col*bounds.Dx()/gridSize
|
|
x1 := bounds.Min.X + (col+1)*bounds.Dx()/gridSize
|
|
y0 := bounds.Min.Y + row*bounds.Dy()/gridSize
|
|
y1 := bounds.Min.Y + (row+1)*bounds.Dy()/gridSize
|
|
|
|
return image.Rect(x0, y0, x1, y1)
|
|
}
|
|
|
|
// copyScaledTile copies a scaled tile
|
|
func copyScaledTile(dst *image.RGBA, dstRect image.Rectangle, src image.Image, srcRect image.Rectangle) {
|
|
if dstRect.Empty() || srcRect.Empty() {
|
|
return
|
|
}
|
|
|
|
dstWidth := dstRect.Dx()
|
|
dstHeight := dstRect.Dy()
|
|
srcWidth := srcRect.Dx()
|
|
srcHeight := srcRect.Dy()
|
|
|
|
for y := 0; y < dstHeight; y++ {
|
|
sy := srcRect.Min.Y + y*srcHeight/dstHeight
|
|
for x := 0; x < dstWidth; x++ {
|
|
sx := srcRect.Min.X + x*srcWidth/dstWidth
|
|
dst.Set(dstRect.Min.X+x, dstRect.Min.Y+y, src.At(sx, sy))
|
|
}
|
|
}
|
|
}
|
|
|
|
// pixelDiff calculates pixel difference
|
|
func pixelDiff(left color.Color, right color.Color) int64 {
|
|
lr, lg, lb, _ := left.RGBA()
|
|
rr, rg, rb, _ := right.RGBA()
|
|
|
|
return absDiff(lr, rr) + absDiff(lg, rg) + absDiff(lb, rb)
|
|
}
|
|
|
|
// absDiff calculates absolute difference
|
|
func absDiff(left uint32, right uint32) int64 {
|
|
if left > right {
|
|
return int64(left - right)
|
|
}
|
|
return int64(right - left)
|
|
}
|
|
|
|
// generateSliderCursor generates a fake slider cursor
|
|
func generateSliderCursor(candidateIndex int, candidateCount int) string {
|
|
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli())
|
|
}
|
|
|
|
// buildSliderCursor builds a fake slider cursor
|
|
func buildSliderCursor(candidateIndex int, candidateCount int, startTime int64) string {
|
|
if candidateCount <= 0 {
|
|
return "[]"
|
|
}
|
|
|
|
type cursorPoint struct {
|
|
X int `json:"x"`
|
|
Y int `json:"y"`
|
|
T int64 `json:"t"`
|
|
}
|
|
|
|
startX := 140
|
|
endX := startX + 620*candidateIndex/candidateCount
|
|
startY := 430
|
|
|
|
points := make([]cursorPoint, 0, 12)
|
|
for step := 0; step < 12; step++ {
|
|
x := startX + (endX-startX)*step/11
|
|
y := startY + ((step % 3) - 1)
|
|
points = append(points, cursorPoint{
|
|
X: x,
|
|
Y: y,
|
|
T: startTime + int64(step*18),
|
|
})
|
|
}
|
|
|
|
data, err := json.Marshal(points)
|
|
if err != nil {
|
|
return "[]"
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// trySliderCaptchaCandidates tries slider captcha candidates
|
|
func trySliderCaptchaCandidates(
|
|
candidates []sliderCandidate,
|
|
maxAttempts int,
|
|
check func(candidate sliderCandidate) (*captchaCheckResult, error),
|
|
) (string, error) {
|
|
if len(candidates) == 0 {
|
|
return "", fmt.Errorf("slider has no ranked candidates")
|
|
}
|
|
|
|
limit := minInt(maxAttempts, len(candidates))
|
|
if limit <= 0 {
|
|
return "", fmt.Errorf("slider has no attempts available")
|
|
}
|
|
|
|
for idx := 0; idx < limit; idx++ {
|
|
result, err := check(candidates[idx])
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
switch result.Status {
|
|
case "OK":
|
|
if result.SuccessToken == "" {
|
|
return "", fmt.Errorf("success_token not found")
|
|
}
|
|
return result.SuccessToken, nil
|
|
case "ERROR_LIMIT":
|
|
return "", fmt.Errorf("slider check status: %s", result.Status)
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("slider guesses exhausted")
|
|
}
|
|
|
|
// minInt returns the minimum of two integers
|
|
func minInt(left int, right int) int {
|
|
if left < right {
|
|
return left
|
|
}
|
|
return right
|
|
}
|
|
|
|
// describeCaptchaTypes describes available captcha types
|
|
func describeCaptchaTypes(settingsByType map[string]string) string {
|
|
if len(settingsByType) == 0 {
|
|
return "none"
|
|
}
|
|
|
|
types := make([]string, 0, len(settingsByType))
|
|
for captchaType := range settingsByType {
|
|
types = append(types, captchaType)
|
|
}
|
|
sort.Strings(types)
|
|
return strings.Join(types, ",")
|
|
}
|
|
|
|
// randInt63 generates a random int63
|
|
func randInt63() int64 {
|
|
return rand.Int63()
|
|
}
|
|
|