4 changed files with 1261 additions and 29 deletions
@ -0,0 +1,949 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"context" |
||||
|
"encoding/base64" |
||||
|
"encoding/json" |
||||
|
"fmt" |
||||
|
"image" |
||||
|
"image/color" |
||||
|
_ "image/jpeg" |
||||
|
"io" |
||||
|
"log" |
||||
|
neturl "net/url" |
||||
|
"regexp" |
||||
|
"sort" |
||||
|
"strconv" |
||||
|
"strings" |
||||
|
"time" |
||||
|
|
||||
|
fhttp "github.com/bogdanfinn/fhttp" |
||||
|
tlsclient "github.com/bogdanfinn/tls-client" |
||||
|
) |
||||
|
|
||||
|
const ( |
||||
|
captchaDebugInfo = "1d3e9babfd3a74f4588bf90cf5c30d3e8e89a0e2a4544da8de8bbf4d78a32f5c" |
||||
|
sliderCaptchaType = "slider" |
||||
|
defaultSliderAttempts = 4 |
||||
|
) |
||||
|
|
||||
|
type captchaNotRobotSession struct { |
||||
|
ctx context.Context |
||||
|
sessionToken string |
||||
|
hash string |
||||
|
streamID int |
||||
|
client tlsclient.HttpClient |
||||
|
profile Profile |
||||
|
browserFp string |
||||
|
} |
||||
|
|
||||
|
type captchaSettingsResponse struct { |
||||
|
ShowCaptchaType string |
||||
|
SettingsByType map[string]string |
||||
|
} |
||||
|
|
||||
|
type captchaCheckResult struct { |
||||
|
Status string |
||||
|
SuccessToken string |
||||
|
ShowCaptchaType string |
||||
|
Redirect string |
||||
|
} |
||||
|
|
||||
|
type sliderCaptchaContent struct { |
||||
|
Extension string |
||||
|
Image image.Image |
||||
|
Size int |
||||
|
Steps []int |
||||
|
Attempts int |
||||
|
} |
||||
|
|
||||
|
type sliderCandidate struct { |
||||
|
Index int |
||||
|
ActiveSteps []int |
||||
|
Score int64 |
||||
|
} |
||||
|
|
||||
|
type captchaBootstrap struct { |
||||
|
PowInput string |
||||
|
Difficulty int |
||||
|
Settings *captchaSettingsResponse |
||||
|
} |
||||
|
|
||||
|
func newCaptchaNotRobotSession( |
||||
|
ctx context.Context, |
||||
|
sessionToken string, |
||||
|
hash string, |
||||
|
streamID int, |
||||
|
client tlsclient.HttpClient, |
||||
|
profile Profile, |
||||
|
) *captchaNotRobotSession { |
||||
|
return &captchaNotRobotSession{ |
||||
|
ctx: ctx, |
||||
|
sessionToken: sessionToken, |
||||
|
hash: hash, |
||||
|
streamID: streamID, |
||||
|
client: client, |
||||
|
profile: profile, |
||||
|
browserFp: generateBrowserFp(profile), |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) baseValues() neturl.Values { |
||||
|
values := neturl.Values{} |
||||
|
values.Set("session_token", s.sessionToken) |
||||
|
values.Set("domain", "vk.com") |
||||
|
values.Set("adFp", "") |
||||
|
values.Set("access_token", "") |
||||
|
return values |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) request(method string, values neturl.Values) (map[string]interface{}, error) { |
||||
|
reqURL := "https://api.vk.ru/method/" + method + "?v=5.131" |
||||
|
parsedURL, _ := neturl.Parse(reqURL) |
||||
|
domain := parsedURL.Hostname() |
||||
|
|
||||
|
req, err := fhttp.NewRequestWithContext(s.ctx, "POST", reqURL, strings.NewReader(values.Encode())) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
req.Host = domain |
||||
|
applyBrowserProfileFhttp(req, s.profile) |
||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
||||
|
req.Header.Set("Accept", "*/*") |
||||
|
req.Header.Set("Origin", "https://id.vk.ru") |
||||
|
req.Header.Set("Referer", "https://id.vk.ru/") |
||||
|
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") |
||||
|
req.Header.Set("Priority", "u=1, i") |
||||
|
|
||||
|
httpResp, err := s.client.Do(req) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
defer func() { |
||||
|
_ = httpResp.Body.Close() |
||||
|
}() |
||||
|
|
||||
|
body, err := io.ReadAll(httpResp.Body) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
var resp map[string]interface{} |
||||
|
if err := json.Unmarshal(body, &resp); err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
return resp, nil |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) requestComponentDone() error { |
||||
|
values := s.baseValues() |
||||
|
values.Set("browser_fp", s.browserFp) |
||||
|
values.Set("device", buildCaptchaDeviceJSON(s.profile)) |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) requestCheckboxCheck() (*captchaCheckResult, error) { |
||||
|
values := s.baseValues() |
||||
|
values.Set("accelerometer", "[]") |
||||
|
values.Set("gyroscope", "[]") |
||||
|
values.Set("motion", "[]") |
||||
|
values.Set("cursor", "[]") |
||||
|
values.Set("taps", "[]") |
||||
|
values.Set("connectionRtt", "[]") |
||||
|
values.Set("connectionDownlink", "[]") |
||||
|
values.Set("browser_fp", s.browserFp) |
||||
|
values.Set("hash", s.hash) |
||||
|
values.Set("answer", base64.StdEncoding.EncodeToString([]byte("{}"))) |
||||
|
values.Set("debug_info", captchaDebugInfo) |
||||
|
|
||||
|
resp, err := s.request("captchaNotRobot.check", values) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("check failed: %w", err) |
||||
|
} |
||||
|
return parseCaptchaCheckResult(resp) |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) requestSliderCheck(activeSteps []int, candidateIndex int, candidateCount int) (*captchaCheckResult, error) { |
||||
|
answer, err := encodeSliderAnswer(activeSteps) |
||||
|
if err != nil { |
||||
|
return nil, err |
||||
|
} |
||||
|
|
||||
|
values := s.baseValues() |
||||
|
values.Set("accelerometer", "[]") |
||||
|
values.Set("gyroscope", "[]") |
||||
|
values.Set("motion", "[]") |
||||
|
values.Set("cursor", generateSliderCursor(candidateIndex, candidateCount)) |
||||
|
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", captchaDebugInfo) |
||||
|
|
||||
|
resp, err := s.request("captchaNotRobot.check", values) |
||||
|
if err != nil { |
||||
|
return nil, fmt.Errorf("check failed: %w", err) |
||||
|
} |
||||
|
return parseCaptchaCheckResult(resp) |
||||
|
} |
||||
|
|
||||
|
func (s *captchaNotRobotSession) requestEndSession() { |
||||
|
log.Printf("[STREAM %d] [Captcha] Step 4/4: endSession", s.streamID) |
||||
|
if _, err := s.request("captchaNotRobot.endSession", s.baseValues()); err != nil { |
||||
|
log.Printf("[STREAM %d] [Captcha] Warning: endSession failed: %v", s.streamID, err) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func callCaptchaNotRobotWithSliderPOC( |
||||
|
ctx context.Context, |
||||
|
sessionToken string, |
||||
|
hash string, |
||||
|
streamID int, |
||||
|
client tlsclient.HttpClient, |
||||
|
profile Profile, |
||||
|
initialSettings *captchaSettingsResponse, |
||||
|
) (string, error) { |
||||
|
session := newCaptchaNotRobotSession(ctx, sessionToken, hash, streamID, client, profile) |
||||
|
|
||||
|
log.Printf("[STREAM %d] [Captcha] Step 1/4: settings", streamID) |
||||
|
settingsResp, err := session.requestSettings() |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
settingsResp = mergeCaptchaSettings(settingsResp, initialSettings) |
||||
|
|
||||
|
time.Sleep(200 * time.Millisecond) |
||||
|
|
||||
|
log.Printf("[STREAM %d] [Captcha] Step 2/4: componentDone", streamID) |
||||
|
if err := session.requestComponentDone(); err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
time.Sleep(200 * time.Millisecond) |
||||
|
|
||||
|
log.Printf("[STREAM %d] [Captcha] Step 3/4: check", streamID) |
||||
|
initialCheck, err := session.requestCheckboxCheck() |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
if initialCheck.Status == "OK" { |
||||
|
if initialCheck.SuccessToken == "" { |
||||
|
return "", fmt.Errorf("success_token not found") |
||||
|
} |
||||
|
session.requestEndSession() |
||||
|
return initialCheck.SuccessToken, nil |
||||
|
} |
||||
|
|
||||
|
sliderSettings, hasSlider := settingsResp.SettingsByType[sliderCaptchaType] |
||||
|
log.Printf( |
||||
|
"[STREAM %d] [Captcha] Checkbox-style check returned status=%s (settings show_type=%q, check show_type=%q, available_types=%s)", |
||||
|
streamID, |
||||
|
initialCheck.Status, |
||||
|
settingsResp.ShowCaptchaType, |
||||
|
initialCheck.ShowCaptchaType, |
||||
|
describeCaptchaTypes(settingsResp.SettingsByType), |
||||
|
) |
||||
|
|
||||
|
if !hasSlider { |
||||
|
log.Printf( |
||||
|
"[STREAM %d] [Captcha] Slider settings not found in settings response. Trying getContent without captcha_settings...", |
||||
|
streamID, |
||||
|
) |
||||
|
} else { |
||||
|
log.Printf("[STREAM %d] [Captcha] Trying experimental slider solver...", streamID) |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
log.Printf( |
||||
|
"[STREAM %d] [Captcha] Ranked %d slider positions locally; submitting top %d based on attempt budget %d", |
||||
|
streamID, |
||||
|
len(candidates), |
||||
|
minInt(sliderContent.Attempts, len(candidates)), |
||||
|
sliderContent.Attempts, |
||||
|
) |
||||
|
|
||||
|
successToken, err := trySliderCaptchaCandidates(candidates, sliderContent.Attempts, func(candidate sliderCandidate) (*captchaCheckResult, error) { |
||||
|
log.Printf( |
||||
|
"[STREAM %d] [Captcha] Slider guess position=%d score=%d", |
||||
|
streamID, |
||||
|
candidate.Index, |
||||
|
candidate.Score, |
||||
|
) |
||||
|
return session.requestSliderCheck(candidate.ActiveSteps, candidate.Index, len(candidates)) |
||||
|
}) |
||||
|
if err != nil { |
||||
|
return "", err |
||||
|
} |
||||
|
|
||||
|
session.requestEndSession() |
||||
|
return successToken, nil |
||||
|
} |
||||
|
|
||||
|
func buildCaptchaDeviceJSON(profile Profile) 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","userAgent":"%s","platform":"Win32"}`, |
||||
|
profile.UserAgent, |
||||
|
) |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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, |
||||
|
}, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
result.Redirect, _ = respObj["redirect"].(string) |
||||
|
if result.Status == "" { |
||||
|
return nil, fmt.Errorf("check status missing: %v", resp) |
||||
|
} |
||||
|
|
||||
|
return result, nil |
||||
|
} |
||||
|
|
||||
|
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{ |
||||
|
Extension: extension, |
||||
|
Image: img, |
||||
|
Size: size, |
||||
|
Steps: swaps, |
||||
|
Attempts: attempts, |
||||
|
}, nil |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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]...) |
||||
|
} |
||||
|
|
||||
|
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)) |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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 |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
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)) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
func absDiff(left uint32, right uint32) int64 { |
||||
|
if left > right { |
||||
|
return int64(left - right) |
||||
|
} |
||||
|
return int64(right - left) |
||||
|
} |
||||
|
|
||||
|
func generateSliderCursor(candidateIndex int, candidateCount int) string { |
||||
|
return buildSliderCursor(candidateIndex, candidateCount, time.Now().Add(-220*time.Millisecond).UnixMilli()) |
||||
|
} |
||||
|
|
||||
|
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) |
||||
|
} |
||||
|
|
||||
|
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") |
||||
|
} |
||||
|
|
||||
|
func minInt(left int, right int) int { |
||||
|
if left < right { |
||||
|
return left |
||||
|
} |
||||
|
return right |
||||
|
} |
||||
|
|
||||
|
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, ",") |
||||
|
} |
||||
@ -0,0 +1,278 @@ |
|||||
|
package main |
||||
|
|
||||
|
import ( |
||||
|
"bytes" |
||||
|
"encoding/base64" |
||||
|
"encoding/json" |
||||
|
"image" |
||||
|
"image/color" |
||||
|
"image/jpeg" |
||||
|
"reflect" |
||||
|
"testing" |
||||
|
) |
||||
|
|
||||
|
func TestParseSliderSteps(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
size, swaps, attempts, err := parseSliderSteps([]int{5, 0, 1, 2, 3, 7}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parseSliderSteps returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
if size != 5 { |
||||
|
t.Fatalf("expected size 5, got %d", size) |
||||
|
} |
||||
|
if attempts != 7 { |
||||
|
t.Fatalf("expected attempts 7, got %d", attempts) |
||||
|
} |
||||
|
expected := []int{0, 1, 2, 3} |
||||
|
if !reflect.DeepEqual(swaps, expected) { |
||||
|
t.Fatalf("expected swaps %v, got %v", expected, swaps) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseCaptchaSettingsResponseSupportsJSONStringMap(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
resp := map[string]interface{}{ |
||||
|
"response": map[string]interface{}{ |
||||
|
"show_captcha_type": "checkbox", |
||||
|
"captcha_settings": `{"slider":"slider-token","sound":"sound-token"}`, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
settings, err := parseCaptchaSettingsResponse(resp) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parseCaptchaSettingsResponse returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
if settings.ShowCaptchaType != "checkbox" { |
||||
|
t.Fatalf("expected show_captcha_type checkbox, got %q", settings.ShowCaptchaType) |
||||
|
} |
||||
|
if settings.SettingsByType["slider"] != "slider-token" { |
||||
|
t.Fatalf("expected slider settings token, got %q", settings.SettingsByType["slider"]) |
||||
|
} |
||||
|
if settings.SettingsByType["sound"] != "sound-token" { |
||||
|
t.Fatalf("expected sound settings token, got %q", settings.SettingsByType["sound"]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestParseCaptchaBootstrapHTML(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
html := ` |
||||
|
<script> |
||||
|
window.init = {"data":{"show_captcha_type":"checkbox","captcha_settings":[{"type":"slider","settings":"slider-token"},{"type":"sound","settings":"sound-token"}]}}; |
||||
|
window.lang = {}; |
||||
|
</script> |
||||
|
<script> |
||||
|
const powInput = "abc123"; |
||||
|
const difficulty = 3; |
||||
|
</script>` |
||||
|
|
||||
|
bootstrap, err := parseCaptchaBootstrapHTML(html) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parseCaptchaBootstrapHTML returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
if bootstrap.PowInput != "abc123" { |
||||
|
t.Fatalf("expected pow input abc123, got %q", bootstrap.PowInput) |
||||
|
} |
||||
|
if bootstrap.Difficulty != 3 { |
||||
|
t.Fatalf("expected difficulty 3, got %d", bootstrap.Difficulty) |
||||
|
} |
||||
|
if bootstrap.Settings == nil { |
||||
|
t.Fatal("expected bootstrap settings") |
||||
|
} |
||||
|
if bootstrap.Settings.ShowCaptchaType != "checkbox" { |
||||
|
t.Fatalf("expected show_captcha_type checkbox, got %q", bootstrap.Settings.ShowCaptchaType) |
||||
|
} |
||||
|
if bootstrap.Settings.SettingsByType["slider"] != "slider-token" { |
||||
|
t.Fatalf("expected slider token, got %q", bootstrap.Settings.SettingsByType["slider"]) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestRenderSliderCandidateMatchesSwapLayout(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
src := image.NewRGBA(image.Rect(0, 0, 20, 20)) |
||||
|
fillRect(src, image.Rect(0, 0, 10, 10), color.RGBA{255, 0, 0, 255}) |
||||
|
fillRect(src, image.Rect(10, 0, 20, 10), color.RGBA{0, 255, 0, 255}) |
||||
|
fillRect(src, image.Rect(0, 10, 10, 20), color.RGBA{0, 0, 255, 255}) |
||||
|
fillRect(src, image.Rect(10, 10, 20, 20), color.RGBA{255, 255, 0, 255}) |
||||
|
|
||||
|
mapping, err := buildSliderTileMapping(2, []int{0, 1}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("buildSliderTileMapping returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
rendered, err := renderSliderCandidate(src, 2, mapping) |
||||
|
if err != nil { |
||||
|
t.Fatalf("renderSliderCandidate returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
assertPixelEquals(t, rendered.At(2, 2), color.RGBA{0, 255, 0, 255}) |
||||
|
assertPixelEquals(t, rendered.At(12, 2), color.RGBA{255, 0, 0, 255}) |
||||
|
assertPixelEquals(t, rendered.At(2, 12), color.RGBA{0, 0, 255, 255}) |
||||
|
assertPixelEquals(t, rendered.At(12, 12), color.RGBA{255, 255, 0, 255}) |
||||
|
} |
||||
|
|
||||
|
func TestRankSliderCandidatesPrefersMostCoherentImage(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
src := image.NewRGBA(image.Rect(0, 0, 30, 30)) |
||||
|
for y := 0; y < 30; y++ { |
||||
|
for x := 0; x < 30; x++ { |
||||
|
src.Set(x, y, color.RGBA{ |
||||
|
R: uint8(x * 5), |
||||
|
G: uint8(y * 5), |
||||
|
B: uint8((x + y) * 3), |
||||
|
A: 255, |
||||
|
}) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
candidates, err := rankSliderCandidates(src, 3, []int{0, 1, 0, 1}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("rankSliderCandidates returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
if len(candidates) != 2 { |
||||
|
t.Fatalf("expected 2 candidates, got %d", len(candidates)) |
||||
|
} |
||||
|
if candidates[0].Index != 2 { |
||||
|
t.Fatalf("expected solved candidate to rank first, got candidate %d", candidates[0].Index) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestEncodeSliderAnswer(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
encoded, err := encodeSliderAnswer([]int{9, 10, 2}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("encodeSliderAnswer returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded) |
||||
|
if err != nil { |
||||
|
t.Fatalf("failed to decode answer: %v", err) |
||||
|
} |
||||
|
|
||||
|
var payload struct { |
||||
|
Value []int `json:"value"` |
||||
|
} |
||||
|
if err := json.Unmarshal(decoded, &payload); err != nil { |
||||
|
t.Fatalf("failed to unmarshal answer payload: %v", err) |
||||
|
} |
||||
|
|
||||
|
expected := []int{9, 10, 2} |
||||
|
if !reflect.DeepEqual(payload.Value, expected) { |
||||
|
t.Fatalf("expected payload %v, got %v", expected, payload.Value) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func TestTrySliderCaptchaCandidates(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
candidates := []sliderCandidate{ |
||||
|
{Index: 1, ActiveSteps: []int{0, 1}, Score: 10}, |
||||
|
{Index: 2, ActiveSteps: []int{0, 1, 0, 1}, Score: 20}, |
||||
|
} |
||||
|
|
||||
|
t.Run("success on first candidate", func(t *testing.T) { |
||||
|
token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) { |
||||
|
if candidate.Index != 1 { |
||||
|
t.Fatalf("unexpected candidate index %d", candidate.Index) |
||||
|
} |
||||
|
return &captchaCheckResult{Status: "OK", SuccessToken: "token-1"}, nil |
||||
|
}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("trySliderCaptchaCandidates returned error: %v", err) |
||||
|
} |
||||
|
if token != "token-1" { |
||||
|
t.Fatalf("expected token-1, got %s", token) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("success on later candidate", func(t *testing.T) { |
||||
|
calls := 0 |
||||
|
token, err := trySliderCaptchaCandidates(candidates, 2, func(candidate sliderCandidate) (*captchaCheckResult, error) { |
||||
|
calls++ |
||||
|
if candidate.Index == 1 { |
||||
|
return &captchaCheckResult{Status: "BOT"}, nil |
||||
|
} |
||||
|
return &captchaCheckResult{Status: "OK", SuccessToken: "token-2"}, nil |
||||
|
}) |
||||
|
if err != nil { |
||||
|
t.Fatalf("trySliderCaptchaCandidates returned error: %v", err) |
||||
|
} |
||||
|
if calls != 2 { |
||||
|
t.Fatalf("expected 2 calls, got %d", calls) |
||||
|
} |
||||
|
if token != "token-2" { |
||||
|
t.Fatalf("expected token-2, got %s", token) |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
t.Run("exhausted candidates", func(t *testing.T) { |
||||
|
_, err := trySliderCaptchaCandidates(candidates, 1, func(candidate sliderCandidate) (*captchaCheckResult, error) { |
||||
|
return &captchaCheckResult{Status: "BOT"}, nil |
||||
|
}) |
||||
|
if err == nil { |
||||
|
t.Fatal("expected error after exhausting ranked candidates") |
||||
|
} |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
func TestParseSliderCaptchaContentResponse(t *testing.T) { |
||||
|
t.Parallel() |
||||
|
|
||||
|
src := image.NewRGBA(image.Rect(0, 0, 20, 20)) |
||||
|
fillRect(src, src.Bounds(), color.RGBA{12, 34, 56, 255}) |
||||
|
|
||||
|
var buf bytes.Buffer |
||||
|
if err := jpeg.Encode(&buf, src, nil); err != nil { |
||||
|
t.Fatalf("failed to encode jpeg fixture: %v", err) |
||||
|
} |
||||
|
|
||||
|
resp := map[string]interface{}{ |
||||
|
"response": map[string]interface{}{ |
||||
|
"status": "OK", |
||||
|
"extension": "jpeg", |
||||
|
"image": base64.StdEncoding.EncodeToString(buf.Bytes()), |
||||
|
"steps": []interface{}{float64(5), float64(0), float64(1), float64(2), float64(3), float64(6)}, |
||||
|
}, |
||||
|
} |
||||
|
|
||||
|
content, err := parseSliderCaptchaContentResponse(resp) |
||||
|
if err != nil { |
||||
|
t.Fatalf("parseSliderCaptchaContentResponse returned error: %v", err) |
||||
|
} |
||||
|
|
||||
|
if content.Size != 5 { |
||||
|
t.Fatalf("expected size 5, got %d", content.Size) |
||||
|
} |
||||
|
if content.Attempts != 6 { |
||||
|
t.Fatalf("expected attempts 6, got %d", content.Attempts) |
||||
|
} |
||||
|
if len(content.Steps) != 4 { |
||||
|
t.Fatalf("expected 4 swap entries, got %d", len(content.Steps)) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func fillRect(img *image.RGBA, rect image.Rectangle, c color.Color) { |
||||
|
for y := rect.Min.Y; y < rect.Max.Y; y++ { |
||||
|
for x := rect.Min.X; x < rect.Max.X; x++ { |
||||
|
img.Set(x, y, c) |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
func assertPixelEquals(t *testing.T, actual color.Color, expected color.RGBA) { |
||||
|
t.Helper() |
||||
|
|
||||
|
ar, ag, ab, aa := actual.RGBA() |
||||
|
if ar != uint32(expected.R)*0x101 || ag != uint32(expected.G)*0x101 || ab != uint32(expected.B)*0x101 || aa != uint32(expected.A)*0x101 { |
||||
|
t.Fatalf("expected pixel %+v, got rgba(%d,%d,%d,%d)", expected, ar, ag, ab, aa) |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue