Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ $config = new ClientConfig();
$config->setAPIKey("<YOUR API KEY>")->setSitekey("<YOUR SITEKEY (optional)>");

// You can also specify which endpoint to use, for example `"global"` or `"eu"`.
// $config->setEndpoint("eu")
// $config->setApiEndpoint("eu")

$captchaClient = new Client($config)
```
Expand Down Expand Up @@ -109,6 +109,18 @@ Time: 36 ms, Memory: 4.00 MB
OK (28 tests, 110 assertions)
```

## Alternative: use Docker
You can also use Docker to run the tests without installing PHP and Composer on your machine. Make sure you have Docker installed, then run the following command in the root of the project:

```shell
docker run --rm -v $(pwd):/app -w /app --network host php:7.4-cli bash -c "
apt-get update -qq &&
apt-get install -y -qq git unzip &&
php bin/composer.phar install &&
vendor/bin/phpunit
"
```

### Optional

Install an old version of PHP (to be sure it works in that version). The oldest PHP version this SDK supports is 7.1.
Expand Down
2 changes: 1 addition & 1 deletion examples/form/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ FRC_APIKEY=YOUR_API_KEY FRC_SITEKEY=YOUR_SITEKEY php -S localhost:8000
Alternatively, you can also specify custom endpoints:

```shell
FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_SITEVERIFY_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha/siteverify FRC_WIDGET_ENDPOINT=https://eu-dev.frcapi.com/api/v2/captcha php -S localhost:8000
FRC_SITEKEY=YOUR_API_KEY FRC_APIKEY=YOUR_SITEKEY FRC_API_ENDPOINT=https://eu.frcapi.com/ FRC_WIDGET_ENDPOINT=https://eu.frcapi.com/api/v2/captcha php -S localhost:8000
```

Now open your browser and navigate to [http://localhost:8000](http://localhost:8000).
10 changes: 5 additions & 5 deletions examples/form/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
$apikey = getenv('FRC_APIKEY');

// Optionally we can pass in custom endpoints to be used, such as "eu".
$siteverifyEndpoint = getenv('FRC_SITEVERIFY_ENDPOINT');
$apiEndpoint = getenv('FRC_API_ENDPOINT');
$widgetEndpoint = getenv('FRC_WIDGET_ENDPOINT');

const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.min.js";
const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.8/site.compat.min.js";; // Compatibility fallback for old browsers.
const MODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.37/site.min.js";
const NOMODULE_SCRIPT_URL = "https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.37/site.compat.min.js"; // Compatibility fallback for old browsers.

if (empty($sitekey) || empty($apikey)) {
die("Please set the FRC_SITEKEY and FRC_APIKEY environment values before running this example.");
Expand Down Expand Up @@ -55,8 +55,8 @@ function generateForm(bool $didSubmit, bool $captchaOK, string $sitekey)
$config = new \FriendlyCaptcha\SDK\ClientConfig();
$config->setAPIKey($apikey);
$config->setSitekey($sitekey);
if (!empty($siteverifyEndpoint)) {
$config->setSiteverifyEndpoint($siteverifyEndpoint); // Optional, it defaults to "global".
if (!empty($apiEndpoint)) {
$config->setApiEndpoint($apiEndpoint); // Optional, it defaults to "global". Use base URL without path.
}

$frcClient = new \FriendlyCaptcha\SDK\Client($config);
Expand Down
12 changes: 7 additions & 5 deletions src/client.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@

use FriendlyCaptcha\SDK\{ClientConfig, VerifyResult, ErrorCodes};

const VERSION = "0.1.2";
const EU_API_ENDPOINT = "https://eu.frcapi.com/api/v2/captcha/siteverify";
const GLOBAL_API_ENDPOINT = "https://global.frcapi.com/api/v2/captcha/siteverify";
const VERSION = "0.2.0";
const EU_API_ENDPOINT = "https://eu.frcapi.com";
const GLOBAL_API_ENDPOINT = "https://global.frcapi.com";
const SITEVERIFY_PATH = "/api/v2/captcha/siteverify";

class Client
{
Expand All @@ -28,15 +29,16 @@ public function __construct(ClientConfig $config)
throw new \Exception("API key is required");
}

$endpoint = $this->config->siteverifyEndpoint;
// Use apiEndpoint (preferred) or fall back to siteverifyEndpoint for backwards compatibility
$endpoint = $this->config->apiEndpoint;

if ($endpoint === "eu") {
$endpoint = EU_API_ENDPOINT;
} elseif ($endpoint === "global") {
$endpoint = GLOBAL_API_ENDPOINT;
}

$this->resolvedSiteverifyEndpoint = $endpoint;
$this->resolvedSiteverifyEndpoint = $endpoint . SITEVERIFY_PATH;
}

public function verifyCaptchaResponse(?string $response): VerifyResult
Expand Down
60 changes: 60 additions & 0 deletions src/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,17 @@ class ClientConfig
public $apiKey = "";
public $sitekey = "";
public $sdkTrailer = "";

/**
* @deprecated Use apiEndpoint instead. This field will be removed in a future version.
*/
public $siteverifyEndpoint = "global";

/**
* The API endpoint URL without path. Accepts shorthands "global" or "eu", or a base URL like "https://api.example.com".
*/
public $apiEndpoint = "global";

public $strict = false;
public $timeout = 30;
public $connectTimeout = 20;
Expand Down Expand Up @@ -54,14 +64,64 @@ public function setSDKTrailer(string $sdkTrailer): self
}

/**
* @deprecated Use setApiEndpoint instead. This method will be removed in a future version.
* @param string $siteverifyEndpoint a full URL, or the shorthands `"global"` or `"eu"`.
*/
public function setSiteverifyEndpoint(string $siteverifyEndpoint): self
{
if ($siteverifyEndpoint != "global" && $siteverifyEndpoint != "eu" && substr($siteverifyEndpoint, 0, 4) != "http") {
throw new Exception("Invalid argument '" . $siteverifyEndpoint . "' to setSiteverifyEndpoint, it must be a full URL or one of the shorthands 'global' or 'eu'.");
}

// Strip the path from the URL if it's a full URL
if (substr($siteverifyEndpoint, 0, 4) == "http") {
$parsed = parse_url($siteverifyEndpoint);
if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) {
throw new Exception("Invalid URL '" . $siteverifyEndpoint . "' provided to setSiteverifyEndpoint.");
}
$siteverifyEndpoint = $parsed['scheme'] . '://' . $parsed['host'];
if (isset($parsed['port'])) {
$siteverifyEndpoint .= ':' . $parsed['port'];
}
}

$this->siteverifyEndpoint = $siteverifyEndpoint;
$this->apiEndpoint = $siteverifyEndpoint;
return $this;
}

/**
* Set the API endpoint URL without path.
*
* @param string $apiEndpoint Base URL without path (e.g., "https://api.example.com") or shorthands "global" or "eu".
*/
public function setApiEndpoint(string $apiEndpoint): self
{
if ($apiEndpoint != "global" && $apiEndpoint != "eu" && substr($apiEndpoint, 0, 4) != "http") {
throw new Exception("Invalid argument '" . $apiEndpoint . "' to setApiEndpoint, it must be a base URL (without path) or one of the shorthands 'global' or 'eu'.");
}

// Validate that it's a base URL without path
if (substr($apiEndpoint, 0, 4) == "http") {
$parsed = parse_url($apiEndpoint);
if ($parsed === false || !isset($parsed['scheme']) || !isset($parsed['host'])) {
throw new Exception("Invalid URL '" . $apiEndpoint . "' provided to setApiEndpoint.");
}

// Check if path is present and not just '/'
if (isset($parsed['path']) && $parsed['path'] !== '' && $parsed['path'] !== '/') {
throw new Exception("API endpoint should not include a path. Got: '" . $apiEndpoint . "'. Please use the base URL only (e.g., 'https://api.example.com').");
}

// Reconstruct URL without path
$apiEndpoint = $parsed['scheme'] . '://' . $parsed['host'];
if (isset($parsed['port'])) {
$apiEndpoint .= ':' . $parsed['port'];
}
}

$this->apiEndpoint = $apiEndpoint;
$this->siteverifyEndpoint = $apiEndpoint;
return $this;
}

Expand Down
71 changes: 65 additions & 6 deletions src/response.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace FriendlyCaptcha\SDK;

use DateTimeImmutable;
use FriendlyCaptcha\SDK\RiskIntelligence;

class VerifyResponseChallengeData
{
Expand All @@ -13,29 +14,55 @@ class VerifyResponseChallengeData
/** @var string */
public $origin;

/**
* Create an empty fallback instance with default values.
* Used when challenge data is missing or malformed.
*/
public static function empty(): VerifyResponseChallengeData
{
$instance = new self();
$instance->timestamp = new DateTimeImmutable('@0');
$instance->origin = '';
return $instance;
}

public static function fromJson($json): ?VerifyResponseChallengeData
{
$data = json_decode($json);
if ($data == null || !is_object($data)) {
return null;
}
$instance = new self();
$instance->timestamp = DateTimeImmutable::createFromFormat("c", $data->timestamp);
try {
$instance->timestamp = new DateTimeImmutable($data->timestamp);
} catch (\Exception $e) {
// This should never happen - indicates malformed API response
error_log("Failed to parse timestamp from API response: " . $e->getMessage() . ". Using Unix epoch as fallback.");
$instance->timestamp = new DateTimeImmutable('@0');
}
$instance->origin = $data->origin;
return $instance;
}

public static function fromStdClass($obj): VerifyResponseChallengeData
{
$instance = new self();
$instance->timestamp = DateTimeImmutable::createFromFormat("c", $obj->timestamp);
try {
$instance->timestamp = new DateTimeImmutable($obj->timestamp);
} catch (\Exception $e) {
// This should never happen - indicates malformed API response
error_log("Failed to parse timestamp from API response: " . $e->getMessage() . ". Using Unix epoch as fallback.");
$instance->timestamp = new DateTimeImmutable('@0');
}
$instance->origin = $obj->origin;
return $instance;
}
}

class VerifyResponseData
{
/** @var string */
public $event_id;
/** @var VerifyResponseChallengeData */
public $challenge;

Expand All @@ -46,14 +73,24 @@ public static function fromJson($json): ?VerifyResponseData
return null;
}
$instance = new self();
$instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge);
$instance->event_id = $data->event_id;
if (isset($data->challenge) && is_object($data->challenge)) {
$instance->challenge = VerifyResponseChallengeData::fromStdClass($data->challenge);
} else {
$instance->challenge = VerifyResponseChallengeData::empty();
}
return $instance;
}

public static function fromStdClass($obj): VerifyResponseData
{
$instance = new self();
$instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge);
$instance->event_id = $obj->event_id;
if (isset($obj->challenge) && is_object($obj->challenge)) {
$instance->challenge = VerifyResponseChallengeData::fromStdClass($obj->challenge);
} else {
$instance->challenge = VerifyResponseChallengeData::empty();
}
return $instance;
}
}
Expand Down Expand Up @@ -94,6 +131,10 @@ class VerifyResponse
public $data;
/** @var VerifyResponseError|null */
public $error;
/** @var RiskIntelligence|null */
public $risk_intelligence;
/** @var object|null Raw untyped risk intelligence data */
private $risk_intelligence_raw;

public static function fromJson($json): ?VerifyResponse
{
Expand All @@ -109,14 +150,32 @@ public static function fromJson($json): ?VerifyResponse
}


if (isset($d->data)) {
if (isset($d->data) && is_object($d->data)) {
$instance->data = VerifyResponseData::fromStdClass($d->data);

// risk_intelligence is part of the data object in the API response
if (isset($d->data->risk_intelligence) && is_object($d->data->risk_intelligence)) {
$instance->risk_intelligence_raw = $d->data->risk_intelligence;
$instance->risk_intelligence = RiskIntelligence::fromStdClass($d->data->risk_intelligence);
}
}

if (isset($d->error)) {
if (isset($d->error) && is_object($d->error)) {
$instance->error = VerifyResponseError::fromStdClass($d->error);
}

return $instance;
}

/**
* Get the raw risk intelligence data as an untyped object.
* This can be useful when you need access to the data in its original form
* or when new fields are added that aren't yet supported by the typed API.
*
* @return object|null The raw risk intelligence data, or null if not present
*/
public function getRawRiskIntelligence()
{
return $this->risk_intelligence_raw;
}
}
Loading