diff --git a/internal/sarin/captcha.go b/internal/sarin/captcha.go new file mode 100644 index 0000000..fc9e49c --- /dev/null +++ b/internal/sarin/captcha.go @@ -0,0 +1,271 @@ +package sarin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "go.aykhans.me/sarin/internal/types" +) + +const ( + captchaPollInterval = 5 * time.Second + captchaTimeout = 120 * time.Second +) + +var captchaHTTPClient = &http.Client{Timeout: captchaTimeout} + +// solveCaptcha creates a task and polls for the result. +// baseURL is the service API base (e.g. "https://api.2captcha.com"). +// taskIDIsString controls whether taskId is sent back as a string or number. +// solutionKey is the field name in the solution object that holds the token. +func solveCaptcha(baseURL, apiKey string, task map[string]any, solutionKey string, taskIDIsString bool) (string, error) { + if apiKey == "" { + return "", types.ErrCaptchaKeyEmpty + } + + taskID, err := captchaCreateTask(baseURL, apiKey, task) + if err != nil { + return "", err + } + return captchaPollResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) +} + +func captchaCreateTask(baseURL, apiKey string, task map[string]any) (string, error) { + body := map[string]any{ + "clientKey": apiKey, + "task": task, + } + + data, err := json.Marshal(body) + if err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + + resp, err := captchaHTTPClient.Post( + baseURL+"/createTask", + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + defer resp.Body.Close() //nolint:errcheck + + var result struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + TaskID json.RawMessage `json:"taskId"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", types.NewCaptchaRequestError("createTask", err) + } + + if result.ErrorID != 0 { + return "", types.NewCaptchaAPIError("createTask", result.ErrorCode, result.ErrorDescription) + } + + // taskId may be a JSON number (2captcha, anti-captcha) or a quoted string (capsolver UUIDs). + // Strip surrounding quotes if present so we always work with the underlying value. + return strings.Trim(string(result.TaskID), `"`), nil +} + +func captchaPollResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), captchaTimeout) + defer cancel() + + ticker := time.NewTicker(captchaPollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return "", types.NewCaptchaTimeoutError(taskID) + case <-ticker.C: + token, done, err := captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey, taskIDIsString) + if err != nil { + return "", err + } + if done { + return token, nil + } + } + } +} + +func captchaGetTaskResult(baseURL, apiKey, taskID, solutionKey string, taskIDIsString bool) (string, bool, error) { + var bodyMap map[string]any + if taskIDIsString { + bodyMap = map[string]any{"clientKey": apiKey, "taskId": taskID} + } else { + bodyMap = map[string]any{"clientKey": apiKey, "taskId": json.Number(taskID)} + } + + data, err := json.Marshal(bodyMap) + if err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + + resp, err := captchaHTTPClient.Post( + baseURL+"/getTaskResult", + "application/json", + bytes.NewReader(data), + ) + if err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + defer resp.Body.Close() //nolint:errcheck + + var result struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + Status string `json:"status"` + Solution map[string]any `json:"solution"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", false, types.NewCaptchaRequestError("getTaskResult", err) + } + + if result.ErrorID != 0 { + return "", false, types.NewCaptchaAPIError("getTaskResult", result.ErrorCode, result.ErrorDescription) + } + + if result.Status == "processing" || result.Status == "idle" { + return "", false, nil + } + + token, ok := result.Solution[solutionKey] + if !ok { + return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + } + tokenStr, ok := token.(string) + if !ok { + return "", false, types.NewCaptchaSolutionKeyError(solutionKey) + } + + return tokenStr, true, nil +} + +// ======================================== 2Captcha ======================================== + +const twoCaptchaBaseURL = "https://api.2captcha.com" + +func twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(twoCaptchaBaseURL, apiKey, map[string]any{ + "type": "RecaptchaV2TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + task := map[string]any{ + "type": "RecaptchaV3TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) +} + +func twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["data"] = cData + } + return solveCaptcha(twoCaptchaBaseURL, apiKey, task, "token", false) +} + +// ======================================== Anti-Captcha ======================================== + +const antiCaptchaBaseURL = "https://api.anti-captcha.com" + +func antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ + "type": "RecaptchaV2TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + // Anti-Captcha requires minScore for reCAPTCHA v3. 0.3 is the loosest threshold. + task := map[string]any{ + "type": "RecaptchaV3TaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + "minScore": 0.3, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey string) (string, error) { + // Anti-Captcha returns hCaptcha tokens under "gRecaptchaResponse" (not "token"). + return solveCaptcha(antiCaptchaBaseURL, apiKey, map[string]any{ + "type": "HCaptchaTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", false) +} + +func antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "TurnstileTaskProxyless", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["cData"] = cData + } + return solveCaptcha(antiCaptchaBaseURL, apiKey, task, "token", false) +} + +// ======================================== CapSolver ======================================== + +const capSolverBaseURL = "https://api.capsolver.com" + +func capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey string) (string, error) { + return solveCaptcha(capSolverBaseURL, apiKey, map[string]any{ + "type": "ReCaptchaV2TaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + }, "gRecaptchaResponse", true) +} + +func capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction string) (string, error) { + task := map[string]any{ + "type": "ReCaptchaV3TaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if pageAction != "" { + task["pageAction"] = pageAction + } + return solveCaptcha(capSolverBaseURL, apiKey, task, "gRecaptchaResponse", true) +} + +func capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, cData string) (string, error) { + task := map[string]any{ + "type": "AntiTurnstileTaskProxyLess", + "websiteURL": websiteURL, + "websiteKey": websiteKey, + } + if cData != "" { + task["metadata"] = map[string]any{"cdata": cData} + } + return solveCaptcha(capSolverBaseURL, apiKey, task, "token", true) +} diff --git a/internal/sarin/helpers.go b/internal/sarin/helpers.go index dab4942..6e70365 100644 --- a/internal/sarin/helpers.go +++ b/internal/sarin/helpers.go @@ -12,3 +12,10 @@ func NewDefaultRandSource() rand.Source { uint64(now>>32), ) } + +func firstOrEmpty(values []string) string { + if len(values) == 0 { + return "" + } + return values[0] +} diff --git a/internal/sarin/template.go b/internal/sarin/template.go index 917ff92..c74ca8b 100644 --- a/internal/sarin/template.go +++ b/internal/sarin/template.go @@ -574,8 +574,7 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "fakeit_ErrorHTTP": func() string { return fakeit.ErrorHTTP().Error() }, "fakeit_ErrorHTTPClient": func() string { return fakeit.ErrorHTTPClient().Error() }, "fakeit_ErrorHTTPServer": func() string { return fakeit.ErrorHTTPServer().Error() }, - // "fakeit_ErrorInput": func() string { return fakeit.ErrorInput().Error() }, - "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, + "fakeit_ErrorRuntime": func() string { return fakeit.ErrorRuntime().Error() }, // Fakeit / School "fakeit_School": fakeit.School, @@ -585,6 +584,55 @@ func NewDefaultTemplateFuncMap(randSource rand.Source, fileCache *FileCache) tem "fakeit_SongName": fakeit.SongName, "fakeit_SongArtist": fakeit.SongArtist, "fakeit_SongGenre": fakeit.SongGenre, + + // Captcha / 2Captcha + // Usage: {{ twocaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "twocaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return twoCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ twocaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "twocaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return twoCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ twocaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "twocaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return twoCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, + + // Captcha / Anti-Captcha + // Usage: {{ anticaptcha_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "anticaptcha_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return antiCaptchaSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ anticaptcha_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "anticaptcha_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return antiCaptchaSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ anticaptcha_HCaptcha "API_KEY" "SITE_KEY" "https://example.com" }} + "anticaptcha_HCaptcha": func(apiKey, websiteKey, websiteURL string) (string, error) { + return antiCaptchaSolveHCaptcha(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ anticaptcha_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "anticaptcha_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return antiCaptchaSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, + + // Captcha / CapSolver + // Usage: {{ capsolver_RecaptchaV2 "API_KEY" "SITE_KEY" "https://example.com" }} + "capsolver_RecaptchaV2": func(apiKey, websiteKey, websiteURL string) (string, error) { + return capSolverSolveRecaptchaV2(apiKey, websiteURL, websiteKey) + }, + // Usage: {{ capsolver_RecaptchaV3 "API_KEY" "SITE_KEY" "https://example.com" "action" }} + "capsolver_RecaptchaV3": func(apiKey, websiteKey, websiteURL, pageAction string) (string, error) { + return capSolverSolveRecaptchaV3(apiKey, websiteURL, websiteKey, pageAction) + }, + // Usage: {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" }} + // {{ capsolver_Turnstile "API_KEY" "SITE_KEY" "https://example.com" "cdata" }} + "capsolver_Turnstile": func(apiKey, websiteKey, websiteURL string, cData ...string) (string, error) { + return capSolverSolveTurnstile(apiKey, websiteURL, websiteKey, firstOrEmpty(cData)) + }, } } diff --git a/internal/types/errors.go b/internal/types/errors.go index 6bc6a76..aed797d 100644 --- a/internal/types/errors.go +++ b/internal/types/errors.go @@ -442,3 +442,65 @@ func NewScriptUnknownEngineError(engineType string) ScriptUnknownEngineError { func (e ScriptUnknownEngineError) Error() string { return "unknown engine type: " + e.EngineType } + +// ======================================== Captcha ======================================== + +var ErrCaptchaKeyEmpty = errors.New("captcha API key cannot be empty") + +type CaptchaAPIError struct { + Endpoint string + Code string + Description string +} + +func NewCaptchaAPIError(endpoint, code, description string) CaptchaAPIError { + return CaptchaAPIError{Endpoint: endpoint, Code: code, Description: description} +} + +func (e CaptchaAPIError) Error() string { + return fmt.Sprintf("captcha %s error: %s (%s)", e.Endpoint, e.Code, e.Description) +} + +type CaptchaRequestError struct { + Endpoint string + Err error +} + +func NewCaptchaRequestError(endpoint string, err error) CaptchaRequestError { + if err == nil { + err = errNoError + } + return CaptchaRequestError{Endpoint: endpoint, Err: err} +} + +func (e CaptchaRequestError) Error() string { + return fmt.Sprintf("captcha %s request failed: %v", e.Endpoint, e.Err) +} + +func (e CaptchaRequestError) Unwrap() error { + return e.Err +} + +type CaptchaTimeoutError struct { + TaskID string +} + +func NewCaptchaTimeoutError(taskID string) CaptchaTimeoutError { + return CaptchaTimeoutError{TaskID: taskID} +} + +func (e CaptchaTimeoutError) Error() string { + return fmt.Sprintf("captcha solving timed out (taskId: %s)", e.TaskID) +} + +type CaptchaSolutionKeyError struct { + Key string +} + +func NewCaptchaSolutionKeyError(key string) CaptchaSolutionKeyError { + return CaptchaSolutionKeyError{Key: key} +} + +func (e CaptchaSolutionKeyError) Error() string { + return fmt.Sprintf("captcha solution missing expected key %q", e.Key) +}