diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f5df19..0c0385c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: jobs: lint-and-test: diff --git a/README.md b/README.md index 78e67a1..10f909d 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ The client offers several configuration options: - **WithAPIKey**: Your Friendly Captcha API key. - **WithSitekey**: Your Friendly Captcha sitekey. - **WithStrictMode**: (Optional) In case the client was not able to verify the captcha response at all (for example if there is a network failure or a mistake in configuration), by default the `VerifyCaptchaResponse` returns `True` regardless. By passing `WithStrictMode(true)`, it will return `false` instead: every response needs to be strictly verified. -- **WithSiteverifyEndpoint**: (Optional) The endpoint URL for the site verification API. Shorthands `eu` or `global` are also accepted. Default is `global`. +- **WithAPIEndpoint**: (Optional) The endpoint for the site verification API. Shorthands `eu` or `global` are also accepted. Default is `global`. ## Development diff --git a/client.go b/client.go index a9d4fd7..21117b8 100644 --- a/client.go +++ b/client.go @@ -3,6 +3,7 @@ package friendlycaptcha import ( "fmt" "net/http" + "net/url" ) // A ClientOption is a function that can be passed to NewClient to configure a new Client. @@ -10,9 +11,9 @@ type ClientOption func(*Client) error // A client for the Friendly Captcha API, see also the API docs at https://developer.friendlycaptcha.com type Client struct { - APIKey string - Sitekey string - SiteverifyEndpoint string + APIKey string + Sitekey string + APIEndpoint string // If Strict is set to true only strictly verified captcha response will be allowed. // For example: if your server can not reach the Friendly Captcha endpoint, it will still advise to accept the response // regardless. @@ -29,19 +30,19 @@ type Client struct { const ResponseFormFieldName = "frc-captcha-response" const ( - globalSiteverifyEndpointURL = "https://global.frcapi.com/api/v2/captcha/siteverify" - euSiteverifyEndpointURL = "https://eu.frcapi.com/api/v2/captcha/siteverify" + globalAPIEndpoint = "https://global.frcapi.com" + euAPIEndpoint = "https://eu.frcapi.com" ) // NewClient creates a new Friendly Captcha client with the given options. func NewClient(opts ...ClientOption) (*Client, error) { const ( - defaultSiteverifyEndpoint = globalSiteverifyEndpointURL + defaultAPIEndpoint = globalAPIEndpoint ) c := &Client{ - HTTPClient: http.DefaultClient, - SiteverifyEndpoint: defaultSiteverifyEndpoint, + HTTPClient: http.DefaultClient, + APIEndpoint: defaultAPIEndpoint, } // Loop through each option @@ -88,17 +89,46 @@ func WithStrictMode(strict bool) ClientOption { } } -// Takes a full URL, or the shorthands `"global"` or `"eu"`. +// WithAPIEndpoint sets the API endpoint domain for the client. +// Takes a domain without path (e.g., "https://global.frcapi.com"), or the shorthands "global" or "eu". +func WithAPIEndpoint(apiEndpoint string) ClientOption { + return func(c *Client) error { + switch apiEndpoint { + case "global": + apiEndpoint = globalAPIEndpoint + case "eu": + apiEndpoint = euAPIEndpoint + case "": + return fmt.Errorf("apiEndpoint must not be empty") + } + c.APIEndpoint = apiEndpoint + return nil + } +} + +// WithSiteverifyEndpoint sets the API endpoint for the client. +// Deprecated: Use WithAPIEndpoint instead. This function strips the path from the URL and calls WithAPIEndpoint. +// Takes a full URL, or the shorthands "global" or "eu". func WithSiteverifyEndpoint(siteverifyEndpoint string) ClientOption { return func(c *Client) error { - if siteverifyEndpoint == "global" { - siteverifyEndpoint = globalSiteverifyEndpointURL - } else if siteverifyEndpoint == "eu" { - siteverifyEndpoint = euSiteverifyEndpointURL - } else if siteverifyEndpoint == "" { + if siteverifyEndpoint == "" { return fmt.Errorf("siteverifyEndpoint must not be empty") } - c.SiteverifyEndpoint = siteverifyEndpoint - return nil + + // Handle shorthands + if siteverifyEndpoint == "global" || siteverifyEndpoint == "eu" { + return WithAPIEndpoint(siteverifyEndpoint)(c) + } + + // Parse URL to extract scheme and host (domain without path) + u, err := url.Parse(siteverifyEndpoint) + if err != nil { + return fmt.Errorf("invalid siteverifyEndpoint URL: %w", err) + } + + // Construct the API endpoint without path + apiEndpoint := u.Scheme + "://" + u.Host + + return WithAPIEndpoint(apiEndpoint)(c) } } diff --git a/client_options_test.go b/client_options_test.go new file mode 100644 index 0000000..b5a4321 --- /dev/null +++ b/client_options_test.go @@ -0,0 +1,170 @@ +package friendlycaptcha + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWithAPIEndpoint(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + expected string + expectError bool + }{ + { + name: "global shorthand", + input: "global", + expected: "https://global.frcapi.com", + }, + { + name: "eu shorthand", + input: "eu", + expected: "https://eu.frcapi.com", + }, + { + name: "full domain https", + input: "https://custom.example.com", + expected: "https://custom.example.com", + }, + { + name: "full domain http", + input: "http://localhost:1090", + expected: "http://localhost:1090", + }, + { + name: "empty string", + input: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := NewClient( + WithAPIKey("test-key"), + WithAPIEndpoint(tt.input), + ) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, client.APIEndpoint) + }) + } +} + +func TestWithSiteverifyEndpoint_Deprecated(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + expectError bool + }{ + { + name: "global shorthand", + input: "global", + expected: "https://global.frcapi.com", + }, + { + name: "eu shorthand", + input: "eu", + expected: "https://eu.frcapi.com", + }, + { + name: "full URL with path - strips path", + input: "https://global.frcapi.com/api/v2/captcha/siteverify", + expected: "https://global.frcapi.com", + }, + { + name: "full URL without path", + input: "https://custom.example.com", + expected: "https://custom.example.com", + }, + { + name: "localhost with path - strips path", + input: "http://localhost:1090/api/v2/captcha/siteverify", + expected: "http://localhost:1090", + }, + { + name: "localhost without path", + input: "http://localhost:1090", + expected: "http://localhost:1090", + }, + { + name: "https with port and path", + input: "https://example.com:8080/some/path", + expected: "https://example.com:8080", + }, + { + name: "empty string", + input: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := NewClient( + WithAPIKey("test-key"), + WithSiteverifyEndpoint(tt.input), + ) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.expected, client.APIEndpoint) + }) + } +} + +func TestWithSiteverifyEndpoint_BackwardCompatibility(t *testing.T) { + t.Parallel() + + // Test that the old usage still works + client, err := NewClient( + WithAPIKey("test-key"), + WithSiteverifyEndpoint("https://eu.frcapi.com/api/v2/captcha/siteverify"), + ) + + assert.NoError(t, err) + assert.Equal(t, "https://eu.frcapi.com", client.APIEndpoint) +} + +func TestWithAPIEndpoint_DefaultValue(t *testing.T) { + // Test that the default is set correctly + client, err := NewClient( + WithAPIKey("test-key"), + ) + + assert.NoError(t, err) + assert.Equal(t, "https://global.frcapi.com", client.APIEndpoint) +} + +func TestWithAPIEndpoint_OverridesWithSiteverifyEndpoint(t *testing.T) { + t.Parallel() + + // Test that when both are provided, the last one wins + client, err := NewClient( + WithAPIKey("test-key"), + WithSiteverifyEndpoint("https://global.frcapi.com/api/v2/captcha/siteverify"), + WithAPIEndpoint("eu"), + ) + + assert.NoError(t, err) + assert.Equal(t, "https://eu.frcapi.com", client.APIEndpoint) +} diff --git a/client_test.go b/client_test.go index cddcf44..b73a440 100644 --- a/client_test.go +++ b/client_test.go @@ -31,9 +31,16 @@ type TestCase struct { Name string `json:"name"` Response string `json:"response"` Expectation struct { - ShouldAccept bool `json:"should_accept"` + ShouldAccept bool `json:"should_accept"` + WasAbleToVerify bool `json:"was_able_to_verify"` + IsClientError bool `json:"is_client_error"` } `json:"expectation"` - Strict bool `json:"strict"` + Strict bool `json:"strict"` + SiteverifyResponse json.RawMessage `json:"siteverify_response"` +} + +type SuccessSiteverifyResponse struct { + Data VerifyResponseData `json:"data"` } func loadTestCasesFromServer() (TestCasesFile, error) { @@ -90,6 +97,72 @@ func TestSDKWithMockServer(t *testing.T) { shouldAccept, fmt.Sprintf("Expected shouldAccept to be: %v, got: %v", expectedShouldAccept, shouldAccept), ) + + assert.Equal( + t, + test.Expectation.WasAbleToVerify, + result.WasAbleToVerify(), + fmt.Sprintf("Expected WasAbleToVerify to be: %v, got: %v", test.Expectation.WasAbleToVerify, result.WasAbleToVerify()), + ) + + assert.Equal( + t, + test.Expectation.IsClientError, + result.IsErrorDueToClientError(), + fmt.Sprintf("Expected IsClientError to be: %v, got: %v", test.Expectation.IsClientError, result.IsErrorDueToClientError()), + ) + + if result.Success { + var expectedResponse SuccessSiteverifyResponse + err := json.Unmarshal(test.SiteverifyResponse, &expectedResponse) + if err != nil { + t.Fatalf("Failed to unmarshal expected siteverify response: %v", err) + } + + exp := expectedResponse.Data + res := result.response.Data + + assert.Equal( + t, + exp.EventID, + res.EventID, + "Event ID does not match expected value", + ) + + assert.Equal( + t, + exp.Challenge, + res.Challenge, + "Challenge data does not match expected value", + ) + + assert.Equal( + t, + exp.RiskIntelligence, + res.RiskIntelligence, + "Risk Intelligence data does not match expected value", + ) + + // Check two specific fields: + assert.Equal( + t, + exp.RiskIntelligence.V.Client.HeaderUserAgent, + res.RiskIntelligence.V.Client.HeaderUserAgent, + ) + assert.Equal( + t, + exp.RiskIntelligence.V.Client.Browser.V.ID, + res.RiskIntelligence.V.Client.Browser.V.ID, + ) + + if exp.RiskIntelligence.Valid { + assert.Contains( + t, + string(res.RiskIntelligenceRaw.V), + "header_user_agent", + ) + } + } }) } } diff --git a/go.mod b/go.mod index 8112618..aa032d3 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/friendlycaptcha/friendly-captcha-go -go 1.18 +go 1.22.12 require github.com/stretchr/testify v1.8.4 require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/guregu/null/v6 v6.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index fa4b6e6..54233e6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ= +github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= diff --git a/verify.go b/verify.go index 3c2899a..0dda758 100644 --- a/verify.go +++ b/verify.go @@ -29,7 +29,7 @@ func (frc *Client) VerifyCaptchaResponse(ctx context.Context, captchaResponse st return result } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, frc.SiteverifyEndpoint, bytes.NewReader(reqBodyJSON)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, frc.APIEndpoint+"/api/v2/captcha/siteverify", bytes.NewReader(reqBodyJSON)) if err != nil { result.err = fmt.Errorf("%w: %v", ErrCreatingVerificationRequest, err) return result diff --git a/wire.go b/wire.go index f2d0c62..83aae38 100644 --- a/wire.go +++ b/wire.go @@ -1,6 +1,11 @@ package friendlycaptcha -import "time" +import ( + "encoding/json" + "time" + + "github.com/guregu/null/v6" +) // VerifyRequest is the request body for the /api/v2/captcha/siteverify endpoint. As a user of the SDK // you generally don't need to create this struct yourself, instead you should use the Client's methods. @@ -20,7 +25,49 @@ type VerifyResponseChallengeData struct { // VerifyResponseData is the data found in the data field of a VerifyResponse. type VerifyResponseData struct { + // EventID is a unique identifier for this siteverify call. + EventID string `json:"event_id"` + + // Challenge contains information about the challenge that was solved. Challenge VerifyResponseChallengeData `json:"challenge"` + + // RiskIntelligenceRaw contains risk information about the solver of the captcha. + // This may be `null` if risk intelligence is not enabled for your Friendly Captcha account. + // + // Note this is the raw JSON data, you probably want to use the RiskIntelligence field instead. This field is + // available in case you need to access fields that are not yet modeled in the SDK. + RiskIntelligenceRaw null.Value[json.RawMessage] `json:"risk_intelligence"` + + // RiskIntelligence contains risk information about the solver of the captcha. + // This may be `null` if risk intelligence is not enabled for your Friendly Captcha account. + RiskIntelligence null.Value[RiskIntelligenceData] `json:"-"` +} + +// UnmarshalJSON implements custom JSON unmarshaling for VerifyResponseData. +// It automatically populates the RiskIntelligence field from RiskIntelligenceRaw. +func (v *VerifyResponseData) UnmarshalJSON(data []byte) error { + // Use an auxiliary struct to avoid infinite recursion + type Alias VerifyResponseData + aux := &struct { + *Alias + }{ + Alias: (*Alias)(v), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Populate RiskIntelligence from RiskIntelligenceRaw + if v.RiskIntelligenceRaw.Valid { + var riskData RiskIntelligenceData + if err := json.Unmarshal(v.RiskIntelligenceRaw.V, &riskData); err != nil { + return err + } + v.RiskIntelligence = null.ValueFrom(riskData) + } + + return nil } // VerifyResponseError is the data found in the error field of a VerifyResponse in case of an error. diff --git a/wire_risk_intelligence.go b/wire_risk_intelligence.go new file mode 100644 index 0000000..4e726e5 --- /dev/null +++ b/wire_risk_intelligence.go @@ -0,0 +1,445 @@ +package friendlycaptcha + +import "github.com/guregu/null/v6" + +// RiskScore represents a risk score value ranging from 1 to 5. +// - 0: Unknown or missing +// - 1: Very low risk +// - 2: Low risk +// - 3: Medium risk +// - 4: High risk +// - 5: Very high risk +type RiskScore uint8 + +const ( + // RiskScoreUnknown represents an unknown or missing risk score. + RiskScoreUnknown RiskScore = 0 + // RiskScoreVeryLow represents a very low risk score (1/5). + RiskScoreVeryLow RiskScore = 1 + // RiskScoreLow represents a low risk score (2/5). + RiskScoreLow RiskScore = 2 + // RiskScoreMedium represents a medium risk score (3/5). + RiskScoreMedium RiskScore = 3 + // RiskScoreHigh represents a high risk score (4/5). + RiskScoreHigh RiskScore = 4 + // RiskScoreVeryHigh represents a very high risk score (5/5). + RiskScoreVeryHigh RiskScore = 5 +) + +// RiskIntelligenceData contains all risk intelligence information. +// +// Field availability depends on enabled modules. +type RiskIntelligenceData struct { + // RiskScores from various signals, these summarize the risk intelligence assessment. + // + // Available when the Risk Scores module is enabled. + // Null when the Risk Scores module is not enabled. + RiskScores null.Value[RiskScoresData] `json:"risk_scores"` + + // Network contains network-related risk intelligence. + Network NetworkData `json:"network"` + + // Client contains client/device risk intelligence. + Client ClientData `json:"client"` +} + +// RiskScoresData summarizes the entire risk intelligence assessment into scores per category. +// +// Available when the Risk Scores module is enabled for your account. +// Null when the Risk Scores module is not enabled for your account. +type RiskScoresData struct { + // Overall risk score combining all signals. + Overall RiskScore `json:"overall"` + + // Network-related risk score. Captures likelihood of automation/malicious activity based on + // IP address, ASN, reputation, geolocation, past abuse from this network, and other network signals. + Network RiskScore `json:"network"` + + // Browser-related risk score. Captures likelihood of automation, malicious activity or browser spoofing based on + // user agent consistency, automation traces, past abuse, and browser characteristics. + Browser RiskScore `json:"browser"` +} + +// NetworkAutonomousSystemData contains information about the AS that owns the IP. +// +// Available when the IP Intelligence module is enabled for your account. +// Null when the IP Intelligence module is not enabled for your account. +type NetworkAutonomousSystemData struct { + // Number is the Autonomous System Number (ASN) identifier. + // Example: 3209 for Vodafone GmbH + Number int `json:"number"` + + // Name of the autonomous system. This is usually a short name or handle. + // Example: "VODANET" + Name string `json:"name"` + + // Company is the organization name that owns the ASN. + // Example: "Vodafone GmbH" + Company string `json:"company"` + + // Description of the company that owns the ASN. + // Example: "Provides mobile and fixed broadband and telecommunication services to consumers and businesses." + Description string `json:"description"` + + // Domain name associated with the ASN. + // Example: "vodafone.de" + Domain string `json:"domain"` + + // Country is the two-letter ISO 3166-1 alpha-2 country code where the ASN is registered. + // Example: "DE" + Country string `json:"country"` + + // RIR is the Regional Internet Registry that allocated the ASN. + // Example: "RIPE" + RIR string `json:"rir"` + + // Route is the IP route associated with the ASN in CIDR notation. + // Example: "88.64.0.0/12" + Route string `json:"route"` + + // Type of the autonomous system. + // Example: "isp" + Type string `json:"type"` +} + +// NetworkGeolocationCountryData contains detailed country data. +type NetworkGeolocationCountryData struct { + // ISO2 is the two-letter ISO 3166-1 alpha-2 country code. + // Example: "DE" + ISO2 string `json:"iso2"` + + // ISO3 is the three-letter ISO 3166-1 alpha-3 country code. + // Example: "DEU" + ISO3 string `json:"iso3"` + + // Name is the English name of the country. + // Example: "Germany" + Name string `json:"name"` + + // NameNative is the native name of the country. + // Example: "Deutschland" + NameNative string `json:"name_native"` + + // Region is the major world region. + // Example: "Europe" + Region string `json:"region"` + + // Subregion is the more specific world region. + // Example: "Western Europe" + Subregion string `json:"subregion"` + + // Currency is the ISO 4217 currency code. + // Example: "EUR" + Currency string `json:"currency"` + + // CurrencyName is the full name of the currency. + // Example: "Euro" + CurrencyName string `json:"currency_name"` + + // PhoneCode is the international dialing code. + // Example: "49" + PhoneCode string `json:"phone_code"` + + // Capital is the name of the capital city. + // Example: "Berlin" + Capital string `json:"capital"` +} + +// NetworkGeolocationData contains geographic location of the IP address. +// +// Available when the IP Intelligence module is enabled. +// Null when the IP Intelligence module is not enabled. +type NetworkGeolocationData struct { + // Country information. + Country NetworkGeolocationCountryData `json:"country"` + + // City name. Empty string if unknown. + // Example: "Eschborn" + City string `json:"city"` + + // State, region, or province. Empty string if unknown. + // Example: "Hessen" + State string `json:"state"` +} + +// NetworkAbuseContactData contains contact details for reporting abuse. +// +// Available when the IP Intelligence module is enabled. +// Null when the IP Intelligence module is not enabled. +type NetworkAbuseContactData struct { + // Address is the postal address of the abuse contact. + // Example: "Vodafone GmbH, Campus Eschborn, Duesseldorfer Strasse 15, D-65760 Eschborn, Germany" + Address string `json:"address"` + + // Name of the abuse contact person or team. + // Example: "Vodafone Germany IP Core Backbone" + Name string `json:"name"` + + // Email is the abuse contact email address. + // Example: "abuse.de@vodafone.com" + Email string `json:"email"` + + // Phone is the abuse contact phone number. + // Example: "+49 6196 52352105" + Phone string `json:"phone"` +} + +// NetworkAnonymizationData contains detection of VPNs, proxies, and anonymization services. +// +// Available when the Anonymization Detection module is enabled. +// Null when the Anonymization Detection module is not enabled. +type NetworkAnonymizationData struct { + // VPNScore is the likelihood that the IP is from a VPN service. + VPNScore RiskScore `json:"vpn_score"` + + // ProxyScore is the likelihood that the IP is from a proxy service. + ProxyScore RiskScore `json:"proxy_score"` + + // Tor indicates whether the IP is a Tor exit node. + Tor bool `json:"tor"` + + // ICloudPrivateRelay indicates whether the IP is from iCloud Private Relay. + ICloudPrivateRelay bool `json:"icloud_private_relay"` +} + +// NetworkData contains information about the network. +type NetworkData struct { + // IP is the IP address used when requesting the challenge. + // Example: "88.64.4.22" + IP string `json:"ip"` + + // AS contains Autonomous System information. + // + // Available when the IP Intelligence module is enabled. + // Null when the IP Intelligence module is not enabled. + AS null.Value[NetworkAutonomousSystemData] `json:"as"` + + // Geolocation information. + // + // Available when the IP Intelligence module is enabled. + // Null when the IP Intelligence module is not enabled. + Geolocation null.Value[NetworkGeolocationData] `json:"geolocation"` + + // AbuseContact is the abuse contact information. + // + // Available when the IP Intelligence module is enabled. + // Null when the IP Intelligence module is not enabled. + AbuseContact null.Value[NetworkAbuseContactData] `json:"abuse_contact"` + + // Anonymization contains IP masking/anonymization information. + // + // Available when the Anonymization Detection module is enabled. + // Null when the Anonymization Detection module is not enabled. + Anonymization null.Value[NetworkAnonymizationData] `json:"anonymization"` +} + +// ClientTimeZoneData contains IANA time zone data. +// +// Available when the Browser Identification module is enabled. +// Null when the Browser Identification module is not enabled. +type ClientTimeZoneData struct { + // Name is the IANA time zone name reported by the browser. + // Example: "America/New_York" or "Europe/Berlin" + Name string `json:"name"` + + // CountryISO2 is the two-letter ISO 3166-1 alpha-2 country code derived from the time zone. + // "XU" if timezone is missing or cannot be mapped to a country (e.g., "Etc/UTC"). + // Example: "US" or "DE" + CountryISO2 string `json:"country_iso2"` +} + +// ClientBrowserData contains detected browser details. +// +// Available when the Browser Identification module is enabled. +// Null when the Browser Identification module is not enabled. +type ClientBrowserData struct { + // ID is the unique browser identifier. Empty string if browser could not be identified. + // Example: "firefox", "chrome", "chrome_android", "edge", "safari", "safari_ios", "webview_ios" + ID string `json:"id"` + + // Name is the human-readable browser name. Empty string if browser could not be identified. + // Example: "Firefox", "Chrome", "Edge", "Safari", "Safari on iOS", "WebView on iOS" + Name string `json:"name"` + + // Version is the browser version name. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + // Example: "146.0" or "16.5" + Version string `json:"version"` + + // ReleaseDate is the release date of the browser version in "YYYY-MM-DD" format. Empty string if unknown. + // Example: "2026-01-28" + ReleaseDate string `json:"release_date"` +} + +// ClientBrowserEngineData contains detected rendering engine details. +// +// Available when the Browser Identification module is enabled. +// Null when the Browser Identification module is not enabled. +type ClientBrowserEngineData struct { + // ID is the unique rendering engine identifier. Empty string if engine could not be identified. + // Example: "gecko", "blink", "webkit" + ID string `json:"id"` + + // Name is the human-readable engine name. Empty string if engine could not be identified. + // Example: "Gecko", "Blink", "WebKit" + Name string `json:"name"` + + // Version is the rendering engine version. Assumed to be the most recent release matching the signature if exact version unknown. Empty if unknown. + // Example: "146.0" or "16.5" + Version string `json:"version"` +} + +// ClientDeviceData contains detected device details. +// +// Available when the Browser Identification module is enabled. +// Null when the Browser Identification module is not enabled. +type ClientDeviceData struct { + // Type is the device type. + // Example: "desktop", "mobile", "tablet" + Type string `json:"type"` + + // Brand is the device brand. + // Example: "Apple", "Samsung", "Google" + Brand string `json:"brand"` + + // Model is the device model name. + // Example: "iPhone 17", "Galaxy S21 (SM-G991B)", "Pixel 10" + Model string `json:"model"` +} + +// ClientOSData contains detected OS details. +// +// Available when the Browser Identification module is enabled. +// Null when the Browser Identification module is not enabled. +type ClientOSData struct { + // ID is the unique operating system identifier. Empty string if OS could not be identified. + // Example: "windows", "macos", "ios", "android", "linux" + ID string `json:"id"` + + // Name is the human-readable operating system name. Empty string if OS could not be identified. + // Example: "Windows", "macOS", "iOS", "Android", "Linux" + Name string `json:"name"` + + // Version is the operating system version. + // Example: "10", "11.2.3", "14.4" + Version string `json:"version"` +} + +// TLSSignatureData contains TLS client hello signatures. +// +// Available when the Bot Detection module is enabled. +// Null when the Bot Detection module is not enabled. +type TLSSignatureData struct { + // JA3 is the JA3 hash. + // Example: "d87a30a5782a73a83c1544bb06332780" + JA3 string `json:"ja3"` + + // JA3N is the JA3N hash. + // Example: "28ecc2d2875b345cecbb632b12d8c1e0" + JA3N string `json:"ja3n"` + + // JA4 is the JA4 signature. + // Example: "t13d1516h2_8daaf6152771_02713d6af862" + JA4 string `json:"ja4"` +} + +// ClientAutomationKnownBotData contains detected known bot details. +type ClientAutomationKnownBotData struct { + // Detected indicates whether a known bot was detected. + Detected bool `json:"detected"` + + // ID is the bot identifier. Empty if no bot detected. + // Example: "googlebot", "bingbot", "chatgpt" + ID string `json:"id"` + + // Name is the human-readable bot name. Empty if no bot detected. + // Example: "Googlebot", "Bingbot", "ChatGPT" + Name string `json:"name"` + + // Type is the bot type classification. Empty if no bot detected. + Type string `json:"type"` + + // URL is the link to bot documentation. Empty if no bot detected. + // Example: "https://developers.google.com/search/docs/crawling-indexing/googlebot" + URL string `json:"url"` +} + +// ClientAutomationToolData contains detected automation tool details. +type ClientAutomationToolData struct { + // Detected indicates whether an automation tool was detected. + Detected bool `json:"detected"` + + // ID is the automation tool identifier. Empty if no tool detected. + // Example: "puppeteer", "selenium", "playwright" + ID string `json:"id"` + + // Name is the human-readable tool name. Empty if no tool detected. + // Example: "Puppeteer", "Selenium WebDriver", "Playwright" + Name string `json:"name"` + + // Type is the automation tool type. Empty if no tool detected. + Type string `json:"type"` +} + +// ClientAutomationData contains information about detected automation. +// +// Available when the Bot Detection module is enabled. +// Null when the Bot Detection module is not enabled. +type ClientAutomationData struct { + // Headless indicates whether the browser was detected as running in headless mode. + Headless bool `json:"headless"` + + // AutomationTool contains detected automation tool information. + AutomationTool ClientAutomationToolData `json:"automation_tool"` + + // KnownBot contains detected known bot information. + KnownBot ClientAutomationKnownBotData `json:"known_bot"` +} + +// ClientData contains information about the user agent and device. +type ClientData struct { + // HeaderUserAgent is the User-Agent HTTP header value. + // Example: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0" + HeaderUserAgent string `json:"header_user_agent"` + + // TimeZone contains time zone information. + // + // Available when the Browser Identification module is enabled. + // Null when the Browser Identification module is not enabled. + TimeZone null.Value[ClientTimeZoneData] `json:"time_zone"` + + // Browser information. + // + // Available when the Browser Identification module is enabled. + // Null when the Browser Identification module is not enabled. + Browser null.Value[ClientBrowserData] `json:"browser"` + + // BrowserEngine information. + // + // Available when the Browser Identification module is enabled. + // Null when the Browser Identification module is not enabled. + BrowserEngine null.Value[ClientBrowserEngineData] `json:"browser_engine"` + + // Device information. + // + // Available when the Browser Identification module is enabled. + // Null when the Browser Identification module is not enabled. + Device null.Value[ClientDeviceData] `json:"device"` + + // OS information. + // + // Available when the Browser Identification module is enabled. + // Null when the Browser Identification module is not enabled. + OS null.Value[ClientOSData] `json:"os"` + + // TLSSignature contains TLS signatures. + // + // Available when the Bot Detection module is enabled. + // Null when the Bot Detection module is not enabled. + TLSSignature null.Value[TLSSignatureData] `json:"tls_signature"` + + // Automation contains automation detection data. + // + // Available when the Bot Detection module is enabled. + // Null when the Bot Detection module is not enabled. + Automation null.Value[ClientAutomationData] `json:"automation"` +}