captcha

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: AGPL-3.0 Imports: 17 Imported by: 0

Documentation

Overview

Package captcha is the honest source of truth for one thing: a hoster (or, for an OAuth-style login gate, an account) demanding something a human has to answer before a download can continue. Nothing in this tree produced that fact before this package existed - see jdsource.go's package comment for how thin that made every plan to build a captcha UI, a solver or a skip button.

This package owns the vocabulary (Challenge, Kind, AbortScope, Source) and one Source implementation backed by a headless JD. It does not poll, does not render, does not store, and does not touch settings - see build-plan.md section 9 package 16 and section 8's Wave 7 note for what those other pieces are and who builds them.

Index

Constants

View Source
const SourceJD = "jd"

SourceJD is Challenge.Source's value for every challenge JDSource produces.

Variables

View Source
var ErrJDNotConfigured = errors.New("captcha: no JD sidecar is configured (KL_JD is unset)")

ErrJDNotConfigured is returned by every JDSource method when jdBase() is empty: no headless JD is configured, an ordinary, common state (KL_JD unset) rather than a failure worth logging on every poll tick. Mirrors internal/hosterauth's own unexported errJDNotConfigured for the identical situation; exported here because this package's caller lives in a different package (internal/app) and needs errors.Is across that boundary.

View Source
var ErrUnsupportedKind = errors.New("captcha: solver only handles KindImage and KindClick")

ErrUnsupportedKind is what every Solve returns for a Kind neither solver in this package can act on - see Solver.Solve's own doc comment.

Functions

This section is empty.

Types

type AbortScope

type AbortScope string

AbortScope is how far a skipped challenge's effect reaches, named for what a KnightLoader user is choosing rather than for JD's own constant spelling - see the mapping table in jdsource.go's jdSkipRequestFor, the way internal/hosterauth documents its own JD-facing mapping decisions rather than assuming a reader can reconstruct them.

const (
	// AbortSkipOnce leaves this one challenge unanswered and moves on to
	// whatever happens next for that single link.
	AbortSkipOnce AbortScope = "skip-once"
	// AbortBlacklistHoster stops this hoster's captchas from being shown
	// again for the rest of the session.
	AbortBlacklistHoster AbortScope = "blacklist-hoster"
	// AbortBlacklistEverywhere stops every hoster's captchas from being shown
	// again for the rest of the session.
	AbortBlacklistEverywhere AbortScope = "blacklist-everywhere"
)

type AntiCaptchaSolver

type AntiCaptchaSolver struct {
	// contains filtered or unexported fields
}

AntiCaptchaSolver is a Solver backed by one Anti-Captcha account.

func NewAntiCaptchaSolver

func NewAntiCaptchaSolver(apiKey string) *AntiCaptchaSolver

NewAntiCaptchaSolver builds a solver for the given account key ("client key" in Anti-Captcha's own vocabulary) - the same value shown on https://anti-captcha.com/clients/settings/apisetup, and what internal/accounts stores under catalogue id "anticaptcha".

func (*AntiCaptchaSolver) Solve

func (s *AntiCaptchaSolver) Solve(ctx context.Context, kind Kind, image, prompt string) (string, error)

Solve implements Solver for Anti-Captcha - see that interface's own doc comment for the contract every caller relies on.

type Challenge

type Challenge struct {
	// ID identifies this challenge to the Source that produced it. Opaque:
	// callers pass it back to Answer/Abort unchanged and must not parse it -
	// the JD-backed Source's ID happens to be a stringified int64, but
	// nothing here promises another Source's will be.
	ID string `json:"id"`
	// Source names which Source produced this challenge (SourceJD today).
	// Carried on the value itself, not only implied by which Source a caller
	// happened to ask, because a consumer holding challenges from more than
	// one Source needs to tell them apart without threading that context
	// through separately.
	Source string `json:"source"`
	// Host is the hoster the challenge is guarding, e.g. "rapidgator.net".
	Host string `json:"host"`
	// TaskID is the KnightLoader task this challenge blocks, when whoever
	// built this Source could work that out. Empty is a real, expected
	// answer, not a bug - see jdsource.go's NewJDSource on why this package
	// cannot resolve it unaided.
	TaskID string `json:"taskId,omitempty"`
	// Kind is how this challenge must be rendered - see Kind's own doc
	// comment.
	Kind Kind `json:"kind"`
	// Prompt is the instructions a human reads, in whatever language the
	// hoster wrote them. Can be empty - not every challenge populates one.
	Prompt string `json:"prompt,omitempty"`
	// Payload is the kind-specific data a solver needs: *ImagePayload for
	// KindImage/KindClick, *WidgetPayload for KindWidget, *UnsupportedPayload
	// for KindUnsupported. A concrete Go type rather than raw JSON so a
	// same-process consumer (this wave's own tests, and 7A's app-level code)
	// can switch on Kind and type-assert without a redundant decode; it still
	// marshals to plain JSON for the eventual HTTP route exactly the same way.
	Payload any `json:"payload,omitempty"`
	// ExpiresAt is when this challenge stops being answerable, computed from
	// the Source's own live countdown at the moment it was listed rather than
	// a deadline fixed at creation - see jdsource.go's doc comment on why
	// that is the honest field to trust. Calling List again can move this
	// later without the challenge having changed identity, exactly as it
	// should when the challenge is still alive. Zero means the Source could
	// not say.
	ExpiresAt time.Time `json:"expiresAt,omitempty"`
}

Challenge is one captcha instance blocking a download (or a login) until a human answers it, dismisses it, or it expires on its own. It is the shape every Source produces and every consumer - the prompt modal, the widget route, the skip action - is built against, so changing it after those start costs all of them a rewrite; see build-plan.md section 9 package 16.

type ClickPayload

type ClickPayload = ImagePayload

ClickPayload is Challenge.Payload for KindClick. It is the identical type to ImagePayload, not a coincidence: the one Source this package ships verified that JD hands back exactly the same image data for a click-to-answer challenge as for a typed-text one (see jdsource.go's classify) and adds no separate click-region metadata over the wire. Kind alone is what tells a renderer to offer a click surface instead of a text box; a future Source with real click-region data would need a new, distinct payload type, not this one widened.

type ImagePayload

type ImagePayload struct {
	DataURL string `json:"dataUrl"`
}

ImagePayload is Challenge.Payload for KindImage: a complete, renderable image. DataURL is always a full "data:image/...;base64,..." string, ready for an <img src> with no further assembly required of the caller - see jdsource.go's normalizeImageDataURL for why that normalization has to happen somewhere, and why here rather than in every consumer.

type JDSource

type JDSource struct {
	// contains filtered or unexported fields
}

JDSource is the Source backed by a headless JD's Deprecated API.

func NewJDSource

func NewJDSource(jdBase func() string, resolveTask func(jdLinkID int64) (taskID string, ok bool)) *JDSource

NewJDSource builds a Source backed by a headless JD, reached at whatever jdBase returns. jdBase is called fresh on every List/Answer/Abort, not captured once - matching internal/hosterauth's Reconciler: a JD address that changes (a recreated container, a KL_JD edit) has to be picked up without reconstructing this value. An empty jdBase() is not a case a caller has to detect first: every method below answers ErrJDNotConfigured for it, the same sentinel internal/hosterauth uses for the same situation, so a poll loop can treat "not configured" as quiet exactly the way hosterauth.Reconciler.Run already does.

resolveTask maps a JD download-link id to the KnightLoader task it blocks, when the caller can say. This package has no access to app-level task state and must not import internal/app to get it - see this wave's file- ownership split (build-plan.md section 8, section 3's Wave 7 table): 7A owns app_captcha.go, where the real index from a JD link to a core.Task.ID lives (internal/resolver/jd.Backend.linkIDs today only maps the other direction, taskID -> JD link ids - 7A's own seam to add the reverse one to, not this package's to reach into). resolveTask may be nil, and every test in this package passes nil: every Challenge.TaskID is then left empty, which is the honest answer for a caller that has not wired the mapping in, not a defect this package needs to work around.

func (*JDSource) Abort

func (s *JDSource) Abort(ctx context.Context, id string, scope AbortScope) error

Abort implements Source.

func (*JDSource) Answer

func (s *JDSource) Answer(ctx context.Context, id string, text string) (bool, error)

Answer implements Source.

func (*JDSource) List

func (s *JDSource) List(ctx context.Context) ([]Challenge, error)

List asks JD for every pending challenge and fetches each one's payload - see build below. One failure fails the whole call rather than silently omitting the one challenge whose payload fetch failed: build-plan.md section 9 package 16 is explicit that "a relay that drops challenges is worse than none", and a captcha List quietly returned short of what JD actually reports is exactly that, indistinguishable from "solved" or "expired" to a caller with no way to tell the difference. Fetches run sequentially, not in parallel: real concurrent captchas are rare enough, and each fetch cheap enough (a local sidecar, a few KB), that the added goroutine-lifetime and error-aggregation complexity would cost more than the latency it saves - the same "simplest safe default" call build-plan.md section 9 package 16 makes for fetching the image into the descriptor at all.

type Kind

type Kind string

Kind is how a Challenge must be rendered. A Source is expected to classify every challenge it produces into one of these; KindUnsupported is the honest fallback for a real challenge this app has no UI for, and it stays informative rather than a shrug - see UnsupportedPayload.

const (
	// KindImage is a picture plus a typed answer - the classic captcha shape.
	// Payload is *ImagePayload.
	KindImage Kind = "image"
	// KindClick is a picture plus one or more clicked points instead of typed
	// text. Payload is *ImagePayload too - see ClickPayload's doc comment for
	// why the same type stands in for both.
	KindClick Kind = "click"
	// KindWidget is a hosted third-party JS challenge (reCAPTCHA v2, hCaptcha)
	// that has to be embedded and solved in a browser context. Payload is
	// *WidgetPayload.
	KindWidget Kind = "widget"
	// KindUnsupported is a real challenge a Source produced but cannot
	// describe as one of the above. Payload is *UnsupportedPayload, which
	// names the real origin rather than leaving the UI to say only
	// "unsupported" - build-plan.md section 9 package 16 asks for this by
	// name.
	KindUnsupported Kind = "unsupported"
)

type Solver

type Solver interface {
	// Solve submits image and returns the exact text to hand to
	// Source.Answer unchanged - recognized text for KindImage, JD's own
	// click-answer JSON for KindClick (see encodeClickAnswer). image is
	// Challenge.Payload's own DataURL field (ImagePayload/ClickPayload - a
	// full "data:image/...;base64,..." string) or a bare base64 string;
	// either is accepted, see decodeSolverImage. prompt is Challenge.Prompt,
	// "" when the challenge carried none - both services accept free-text
	// worker instructions and solve more accurately with one than without.
	//
	// kind must be KindImage or KindClick. Anything else (KindWidget,
	// KindUnsupported) returns ErrUnsupportedKind before any network call is
	// made - see this file's package comment for why widget solving is out
	// of scope here.
	Solve(ctx context.Context, kind Kind, image, prompt string) (string, error)
}

Solver is an automatic captcha-solving backend, tried in a configured order before KnightLoader ever shows a human the prompt modal - see this file's package comment and build-plan.md section 9 package 16. Both TwoCaptchaSolver and AntiCaptchaSolver implement it, so a caller holding a list built from settings.Settings' solver order can try each in turn without a type switch.

type Source

type Source interface {
	// List returns every challenge currently waiting for an answer. An empty
	// slice with a nil error is the ordinary "nothing waiting" case, not a
	// failure - a poll loop must not treat every quiet tick as one.
	List(ctx context.Context) ([]Challenge, error)

	// Answer submits text as the solution to challenge id. stillValid reports
	// whether id was still live when the Source received it - the direct,
	// authoritative answer to "did this arrive too late", read from the
	// backend rather than guessed at from a client-side countdown (see
	// build-plan.md section 9 package 16: "solving an expired id silently
	// does nothing while the user sees sent"). false with a nil error is not
	// itself a failure: it means the challenge expired, or was already
	// answered elsewhere, between List and this call, and the caller should
	// treat that as "refresh and show whatever replaced it", not as
	// something to report to the user as an error.
	//
	// err is reserved for everything else going wrong: a transport failure,
	// an id the Source never issued, an answer shape the challenge rejected
	// outright.
	Answer(ctx context.Context, id string, text string) (stillValid bool, err error)

	// Abort tells the Source the user chose not to answer id, at the given
	// scope - see AbortScope. Aborting a challenge that has already expired
	// or been answered elsewhere is not an error: the end state Abort exists
	// to reach (id no longer pending) already holds, so a Source should
	// return nil for that case rather than making every caller special-case
	// a race it did not cause.
	Abort(ctx context.Context, id string, scope AbortScope) error
}

Source is a producer of captcha challenges and the only way to answer or dismiss one. 7A's poll loop calls List on a schedule; the prompt modal and the skip action call Answer/Abort with an id List already handed them - see build-plan.md section 8's Wave 7 note ("7A - additionally: render from 7F's typed descriptor").

One List, not a stream and not a per-challenge subscription: the only backend this package wraps today answers "everything pending" in a single call (see jdsource.go), and a push-based shape would be speculative for a second Source that does not exist yet.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store is one session's view of every challenge the last List() call answered with. The zero value is not usable; build one with NewStore. Safe for concurrent use.

func NewStore

func NewStore() *Store

NewStore returns an empty Store.

func (*Store) ByTask

func (s *Store) ByTask(taskID string) (Challenge, bool)

ByTask returns the challenge taskID is currently waiting on, if any - the lookup internal/app/app_captcha.go's dispatchLocked seam and onUpdate wiring need to tell a captcha-waiting task apart from a merely queued one, without core.Task carrying a pointer back to a challenge it does not own the lifecycle of.

func (*Store) Get

func (s *Store) Get(id string) (Challenge, bool)

Get returns one challenge by id.

func (*Store) List

func (s *Store) List() []Challenge

List returns every currently active challenge, nearest expiry first.

A zero ExpiresAt - "the Source could not say", per Challenge's own doc comment - sorts LAST, not first: an unknown deadline is not the most urgent challenge to show, it is the least informative one, and a consumer that shows one challenge at a time (the prompt modal) wants the one most likely to lapse first in front of the one most likely to sit quietly. Ties (including two zero deadlines) break on id, so the order is stable from one call to the next when nothing has actually changed.

func (*Store) Remove

func (s *Store) Remove(id string) (Challenge, bool)

Remove drops one challenge out of band, ahead of the next Sync - the caller who just learned the outcome directly (a POST .../answer or .../abort that got its own definitive answer from JD) rather than by noticing an absence on the next poll. It reports whether id was present, and is a no-op otherwise: a challenge already gone is the state Remove exists to reach, the same idempotent-on-gone rule Source.Abort's own doc comment states for the same reason.

func (*Store) Sync

func (s *Store) Sync(current []Challenge) (added, changed, removed []Challenge)

Sync reconciles a fresh List() result against what Store held before, and returns exactly what changed:

  • added is a challenge this Store has never seen (a new id).
  • changed is one already known whose visible fields moved - a later ExpiresAt most often, since JD recomputes it fresh on every list() call from its own live countdown (see jdsource.go's own doc comment on Challenge.ExpiresAt); that is expected motion, not a bug, and callers that only care about it as a live countdown are free to ignore this slice entirely.
  • removed is every challenge that WAS active and is not in current any more, carrying its LAST-KNOWN snapshot rather than only its id - a caller deciding what a disappearance means (solved, timed out, aborted elsewhere) needs to know which task and host it was, and this is the only place that snapshot still exists once JD has stopped mentioning it.

Store's own state is fully replaced by current before returning, so two calls never have to run back to back to converge, and a challenge removed out of band (see Remove) simply does not reappear here - it is already absent from what Sync is diffing against.

type TwoCaptchaSolver

type TwoCaptchaSolver struct {
	// contains filtered or unexported fields
}

TwoCaptchaSolver is a Solver backed by one 2Captcha account.

func NewTwoCaptchaSolver

func NewTwoCaptchaSolver(apiKey string) *TwoCaptchaSolver

NewTwoCaptchaSolver builds a solver for the given account API key - the same key 2Captcha's own dashboard (https://2captcha.com/enterpage, linked from https://2captcha.com/api-docs/quick-start as "obtain your API key from the Dashboard") shows, and what internal/accounts stores under catalogue id "2captcha".

func (*TwoCaptchaSolver) Solve

func (s *TwoCaptchaSolver) Solve(ctx context.Context, kind Kind, image, prompt string) (string, error)

Solve implements Solver for 2Captcha - see that interface's own doc comment for the contract every caller relies on.

type UnsupportedPayload

type UnsupportedPayload struct {
	// Vendor names the real origin of the challenge - for the JD-backed
	// Source, JD's own challenge class name (e.g. "AccountLoginOAuthChallenge"),
	// never a value this package invented or guessed at. See jdsource.go's
	// classify.
	Vendor string `json:"vendor"`
}

UnsupportedPayload is Challenge.Payload for KindUnsupported.

type WidgetPayload

type WidgetPayload struct {
	SiteKey    string `json:"siteKey"`
	SiteURL    string `json:"siteUrl"`
	ContextURL string `json:"contextUrl"`
	// Type is the widget variant ("normal"/"invisible" for reCAPTCHA v2;
	// hCaptcha's own API always reports "normal" - see jdsource.go). Rendered
	// as-is; this package does not interpret it.
	Type string `json:"type,omitempty"`
	// Enterprise and V3Action only ever apply to reCAPTCHA; hCaptcha's own
	// Storable has no such fields and leaves them at the zero value - see
	// jdsource.go's jdWidgetToken.
	Enterprise bool   `json:"enterprise,omitempty"`
	V3Action   string `json:"v3Action,omitempty"`
	// SecureToken is JD's own "stoken". Kept even though the one Source this
	// package ships never observed hCaptcha populate it (its Storable's
	// getStoken() is hardcoded to return nil - see jdsource.go): dropping a
	// field JD's wire format genuinely carries is a silent regression the day
	// a JD build starts sending it, not a simplification.
	SecureToken string `json:"secureToken,omitempty"`
}

WidgetPayload is Challenge.Payload for KindWidget: the sitekey data a hosted reCAPTCHA v2 or hCaptcha JS widget needs to render and solve itself in a browser context - not a screenshot, not a proxied iframe. See jdsource.go's jdWidgetToken for exactly which JD call this is read from and why it is not the default captcha/get response.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL