From 697ec2a519f93ce68f2af0e145294b61995a21c9 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:08:44 +0200 Subject: [PATCH 1/5] Add Lists, Privacy and Webhook verification APIs - Lists API: createList, getAllLists, getList, updateList, deleteList, queryList - List Items API: createListItem, createListItems, getListItem, updateListItem, queryListItems, countListItems, archiveListItem, unarchiveListItem - Privacy API: requestUserData, deleteUserData - Webhook signature verification via Castle_Webhook::verify and the Castle_WebhookVerificationError exception - Attach the request context automatically to risk, filter and log requests - Harden API error handling against empty error response bodies --- .gitignore | 1 + CHANGELOG.md | 7 ++ README.md | 72 +++++++++++++ lib/Castle.php | 1 + lib/Castle/Castle.php | 208 +++++++++++++++++++++++++++++++++++- lib/Castle/Errors.php | 5 + lib/Castle/Request.php | 13 ++- lib/Castle/Webhook.php | 48 +++++++++ test/Castle.php | 1 + test/CastleTest.php | 164 ++++++++++++++++++++++++++++ test/RequestContextTest.php | 2 +- test/WebhookTest.php | 48 +++++++++ 12 files changed, 564 insertions(+), 6 deletions(-) create mode 100644 lib/Castle/Webhook.php create mode 100644 test/WebhookTest.php diff --git a/.gitignore b/.gitignore index c315f07..c121360 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ package-lock.json composer.lock /node_modules/ /vendor/ +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 0539f7f..17076e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ # Changelog +## 3.3.0 +* added the Lists API: `Castle::createList`, `Castle::getAllLists`, `Castle::getList`, `Castle::updateList`, `Castle::deleteList`, `Castle::queryList` +* added the List Items API: `Castle::createListItem`, `Castle::createListItems`, `Castle::getListItem`, `Castle::updateListItem`, `Castle::queryListItems`, `Castle::countListItems`, `Castle::archiveListItem`, `Castle::unarchiveListItem` +* added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` +* added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception +* the request context is now attached automatically to `risk`, `filter` and `log` requests + ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index 4ade8b7..ddb3581 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,77 @@ Castle_RequestContext['ip'] = '1.1.1.1' $context = Castle_RequestContext::extractJson(); ``` +## Lists + +Manage [lists](https://docs.castle.io) and their items: + +```php +$list = Castle::createList([ + 'name' => 'Blocklist', + 'color' => '$red', + 'primary_field' => 'user.email', +]); + +$lists = Castle::getAllLists(); +$list = Castle::getList($list['id']); +Castle::updateList($list['id'], ['name' => 'Renamed']); +Castle::deleteList($list['id']); +Castle::queryList(['filters' => [['field' => 'name', 'op' => '$eq', 'value' => 'Blocklist']]]); +``` + +List items: + +```php +$item = Castle::createListItem($list['id'], [ + 'author' => 'user:123', + 'primary_value' => 'user@example.com', +]); + +Castle::getListItem($list['id'], $item['id']); +Castle::updateListItem($list['id'], $item['id'], ['comment' => 'Flagged for review']); +Castle::queryListItems($list['id'], ['filters' => []]); +Castle::countListItems($list['id'], ['filters' => []]); +Castle::archiveListItem($list['id'], $item['id']); +Castle::unarchiveListItem($list['id'], $item['id']); +Castle::createListItems($list['id'], ['items' => [/* ... */]]); +``` + +## Privacy + +Request or delete the data Castle stores for a user: + +```php +Castle::requestUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); + +Castle::deleteUserData([ + 'identifier' => 'user@example.com', + 'identifier_type' => '$email', +]); +``` + +## Webhooks + +Verify the authenticity of incoming Castle webhooks. By default the raw body is +read from `php://input` and the signature from the `X-Castle-Signature` header: + +```php +try { + Castle_Webhook::verify(); + // handle the webhook payload +} catch (Castle_WebhookVerificationError $e) { + http_response_code(404); +} +``` + +The body and signature can also be passed explicitly: + +```php +Castle_Webhook::verify($rawBody, $signatureHeader); +``` + ## Errors Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is thrown to indicate what went wrong. @@ -86,6 +157,7 @@ Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is | `Castle_NotFoundError` | The resource requestd was not found. For example if a session has been revoked. | | `Castle_InvalidParametersError` | One or more of the supplied parameters are incorrect. Check the response for more information. | | `Castle_InvalidRequestTokenError` | The request token parameter is missing or invalid | +| `Castle_WebhookVerificationError` | An incoming webhook could not be verified against the `X-Castle-Signature` header | ## Running test suite Execute `vendor/bin/phpunit test` to run the full test suite diff --git a/lib/Castle.php b/lib/Castle.php index 9acaaa6..d19022a 100755 --- a/lib/Castle.php +++ b/lib/Castle.php @@ -25,3 +25,4 @@ function lcfirst( $str ) { require(dirname(__FILE__) . '/Castle/CurlTransport.php'); require(dirname(__FILE__) . '/Castle/RequestContext.php'); require(dirname(__FILE__) . '/Castle/Request.php'); +require(dirname(__FILE__) . '/Castle/Webhook.php'); diff --git a/lib/Castle/Castle.php b/lib/Castle/Castle.php index 7bbffd6..b62e181 100755 --- a/lib/Castle/Castle.php +++ b/lib/Castle/Castle.php @@ -2,7 +2,7 @@ abstract class Castle { - const VERSION = '3.2.0'; + const VERSION = '3.3.0'; const HEADER_COOKIE = 'Cookie'; const HEADER_USER_AGENT = 'User-Agent'; @@ -171,4 +171,210 @@ public static function risk(Array $attributes) } return new RestModel($response); } + + /** + * Lists API + */ + + /** + * Create a list + * @param Array $attributes 'name', 'color' and 'primary_field' are required + * @return Array + */ + public static function createList(Array $attributes) + { + return self::sendRequest('post', '/lists', $attributes); + } + + /** + * Fetch all lists + * @return Array + */ + public static function getAllLists() + { + return self::sendRequest('get', '/lists'); + } + + /** + * Fetch a single list + * @param String $listId + * @return Array + */ + public static function getList($listId) + { + return self::sendRequest('get', self::listPath($listId)); + } + + /** + * Update a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function updateList($listId, Array $attributes) + { + return self::sendRequest('put', self::listPath($listId), $attributes); + } + + /** + * Delete a list + * @param String $listId + * @return Array + */ + public static function deleteList($listId) + { + return self::sendRequest('delete', self::listPath($listId)); + } + + /** + * Query lists + * @param Array $attributes + * @return Array + */ + public static function queryList(Array $attributes = array()) + { + return self::sendRequest('post', '/lists/query', $attributes); + } + + /** + * List Items API + */ + + /** + * Create a list item + * @param String $listId + * @param Array $attributes 'author' and 'primary_value' are required + * @return Array + */ + public static function createListItem($listId, Array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId), $attributes); + } + + /** + * Create a batch of list items + * @param String $listId + * @param Array $attributes 'items' is required + * @return Array + */ + public static function createListItems($listId, Array $attributes) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/batch', $attributes); + } + + /** + * Fetch a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function getListItem($listId, $itemId) + { + return self::sendRequest('get', self::listItemPath($listId, $itemId)); + } + + /** + * Update a list item + * @param String $listId + * @param String $itemId + * @param Array $attributes 'comment' is required + * @return Array + */ + public static function updateListItem($listId, $itemId, Array $attributes) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId), $attributes); + } + + /** + * Query the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function queryListItems($listId, Array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/query', $attributes); + } + + /** + * Count the items of a list + * @param String $listId + * @param Array $attributes + * @return Array + */ + public static function countListItems($listId, Array $attributes = array()) + { + return self::sendRequest('post', self::listItemsPath($listId) . '/count', $attributes); + } + + /** + * Archive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function archiveListItem($listId, $itemId) + { + return self::sendRequest('delete', self::listItemPath($listId, $itemId) . '/archive'); + } + + /** + * Unarchive a list item + * @param String $listId + * @param String $itemId + * @return Array + */ + public static function unarchiveListItem($listId, $itemId) + { + return self::sendRequest('put', self::listItemPath($listId, $itemId) . '/unarchive'); + } + + /** + * Privacy API + */ + + /** + * Request the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function requestUserData(Array $attributes) + { + return self::sendRequest('post', '/privacy/users', $attributes); + } + + /** + * Delete the data stored for a user + * @param Array $attributes 'identifier' and 'identifier_type' are required + * @return Array + */ + public static function deleteUserData(Array $attributes) + { + return self::sendRequest('delete', '/privacy/users', $attributes); + } + + private static function sendRequest($method, $path, $attributes = null) + { + $request = new Castle_Request(); + list($response, $request) = $request->send($method, $path, $attributes); + if ($request->rStatus == 204) { + $response = array(); + } + return $response; + } + + private static function listPath($listId) + { + return '/lists/' . rawurlencode($listId); + } + + private static function listItemsPath($listId) + { + return self::listPath($listId) . '/items'; + } + + private static function listItemPath($listId, $itemId) + { + return self::listItemsPath($listId) . '/' . rawurlencode($itemId); + } } diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 2284c9a..cb15081 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -59,3 +59,8 @@ class Castle_InvalidRequestTokenError extends Castle_InvalidParametersError { } + +class Castle_WebhookVerificationError extends Castle_Error +{ + +} diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 50fe291..7e29498 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -15,8 +15,9 @@ public static function apiUrl($url='') public function handleApiError($response, $status) { - $type = $response['type']; - $msg = $response['message']; + $response = is_array($response) ? $response : array(); + $type = isset($response['type']) ? $response['type'] : null; + $msg = isset($response['message']) ? $response['message'] : null; switch ($status) { case 400: throw new Castle_BadRequest($msg, $type, $status); @@ -70,7 +71,11 @@ public function preFlightCheck() } } - public function send($method, $url, $payload = 's') { + public function send($method, $url, $payload = array()) { + if (!is_array($payload)) { + $payload = array(); + } + if ( self::shouldHaveContext($url) && !array_key_exists('context', $payload)) { $payload['context'] = Castle_RequestContext::extract(); } @@ -79,7 +84,7 @@ public function send($method, $url, $payload = 's') { } private function shouldHaveContext($url) { - $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate']; + $WITH_CONTEXT = ['/track', '/authenticate', '/impersonate', '/risk', '/filter', '/log']; return in_array($url, $WITH_CONTEXT); } diff --git a/lib/Castle/Webhook.php b/lib/Castle/Webhook.php new file mode 100644 index 0000000..608cb2e --- /dev/null +++ b/lib/Castle/Webhook.php @@ -0,0 +1,48 @@ + '1', 'reset' => true)); $this->assertRequest('delete', '/impersonate'); } + + public function testRiskIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testFilterIncludesContext() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testLogIncludesContext() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('context', $request['params']); + } + + public function testCreateList() + { + Castle_RequestTransport::setResponse(201, '{ "id": "list-id", "name": "blocklist" }'); + $list = Castle::createList(Array( + 'name' => 'blocklist', + 'color' => '$red', + 'primary_field' => 'user.email' + )); + $this->assertRequest('post', '/lists'); + $this->assertEquals('list-id', $list['id']); + } + + public function testGetAllLists() + { + Castle_RequestTransport::setResponse(200, '[{ "id": "list-id" }]'); + $lists = Castle::getAllLists(); + $this->assertRequest('get', '/lists'); + $this->assertEquals('list-id', $lists[0]['id']); + } + + public function testGetList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::getList('list-id'); + $this->assertRequest('get', '/lists/list-id'); + } + + public function testUpdateList() + { + Castle_RequestTransport::setResponse(200, '{ "id": "list-id" }'); + Castle::updateList('list-id', array('name' => 'renamed')); + $this->assertRequest('put', '/lists/list-id'); + } + + public function testDeleteList() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::deleteList('list-id'); + $this->assertRequest('delete', '/lists/list-id'); + } + + public function testQueryList() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryList(array('filters' => array())); + $this->assertRequest('post', '/lists/query'); + } + + public function testCreateListItem() + { + Castle_RequestTransport::setResponse(201, '{ "id": "item-id" }'); + Castle::createListItem('list-id', array( + 'author' => 'user:123', + 'primary_value' => 'user@example.com' + )); + $this->assertRequest('post', '/lists/list-id/items'); + } + + public function testCreateListItems() + { + Castle_RequestTransport::setResponse(201, '{ "items": [] }'); + Castle::createListItems('list-id', array('items' => array())); + $this->assertRequest('post', '/lists/list-id/items/batch'); + } + + public function testGetListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::getListItem('list-id', 'item-id'); + $this->assertRequest('get', '/lists/list-id/items/item-id'); + } + + public function testUpdateListItem() + { + Castle_RequestTransport::setResponse(200, '{ "id": "item-id" }'); + Castle::updateListItem('list-id', 'item-id', array('comment' => 'note')); + $this->assertRequest('put', '/lists/list-id/items/item-id'); + } + + public function testQueryListItems() + { + Castle_RequestTransport::setResponse(200, '{ "total_count": 0, "items": [] }'); + Castle::queryListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/query'); + } + + public function testCountListItems() + { + Castle_RequestTransport::setResponse(200, '{ "count": 0 }'); + Castle::countListItems('list-id', array('filters' => array())); + $this->assertRequest('post', '/lists/list-id/items/count'); + } + + public function testArchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::archiveListItem('list-id', 'item-id'); + $this->assertRequest('delete', '/lists/list-id/items/item-id/archive'); + } + + public function testUnarchiveListItem() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::unarchiveListItem('list-id', 'item-id'); + $this->assertRequest('put', '/lists/list-id/items/item-id/unarchive'); + } + + public function testRequestUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::requestUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('post', '/privacy/users'); + } + + public function testDeleteUserData() + { + Castle_RequestTransport::setResponse(200, '{ "id": "req-id" }'); + Castle::deleteUserData(array( + 'identifier' => 'user@example.com', + 'identifier_type' => '$email' + )); + $this->assertRequest('delete', '/privacy/users'); + } } diff --git a/test/RequestContextTest.php b/test/RequestContextTest.php index 2b20008..fc6206b 100644 --- a/test/RequestContextTest.php +++ b/test/RequestContextTest.php @@ -30,7 +30,7 @@ public function contextProvider() { } public function contextJsonProvider() { - return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"3.2.0"}}')); + return array(array('{"client_id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5","ip":"8.8.8.8","headers":{"User-Agent":"TestAgent","X-Castle-Client-Id":"1ccf8dee-904b-4d20-8a88-55ded468bcc5"},"user_agent":"TestAgent","library":{"name":"castle-php","version":"3.3.0"}}')); } /** diff --git a/test/WebhookTest.php b/test/WebhookTest.php new file mode 100644 index 0000000..385516f --- /dev/null +++ b/test/WebhookTest.php @@ -0,0 +1,48 @@ +apiSecret); + $_SERVER = array(); + } + + private function sign($body) + { + return base64_encode(hash_hmac('sha256', $body, $this->apiSecret, true)); + } + + public function testVerifyValidSignature() + { + $body = '{"type":"$incident.confirmed"}'; + $this->assertTrue(Castle_Webhook::verify($body, $this->sign($body))); + } + + public function testVerifyReadsSignatureFromHeader() + { + $body = '{"type":"$incident.confirmed"}'; + $_SERVER['HTTP_X_CASTLE_SIGNATURE'] = $this->sign($body); + $this->assertTrue(Castle_Webhook::verify($body)); + } + + public function testVerifyInvalidSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}', 'invalid-signature'); + } + + public function testVerifyMissingSignature() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('{"type":"$incident.confirmed"}'); + } + + public function testVerifyEmptyBody() + { + $this->expectException(Castle_WebhookVerificationError::class); + Castle_Webhook::verify('', $this->sign('')); + } +} From 07008c7848169c71aac711866b23ce0c6b1805e9 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:35:15 +0200 Subject: [PATCH 2/5] Declare Castle_ApiError properties for PHP 8 compatibility Avoids the dynamic-property deprecation (which becomes a fatal error in PHP 9). --- CHANGELOG.md | 1 + lib/Castle/Errors.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17076e5..f25b7eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests +* improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index cb15081..886d2f3 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -22,6 +22,9 @@ class Castle_CurlOptionError extends Castle_Error class Castle_ApiError extends Castle_Error { + public $type; + public $httpStatus; + public function __construct($msg, $type = null, $status = null) { parent::__construct($msg); From 457129a71efeb4ec9c6e0e5ac88595cb346b3d31 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:47:54 +0200 Subject: [PATCH 3/5] Modernize tooling: GitHub Actions CI, Composer install, PHP 7.2-8.4 - Replace CircleCI with GitHub Actions: a Specs workflow with a PHP version matrix (7.2-8.4) and a Lint workflow (composer validate + php -l) - Pin phpunit to ^8.5 || ^9.6 and drop the unused php-coveralls dependency - Declare the curl/json extension requirements in composer.json - Simplify phpunit.xml to the modern schema - Update the README to install via Composer and document supported versions --- .circleci/config.yml | 28 ---------------------------- .coveralls.yml | 2 -- .github/workflows/lint.yml | 26 ++++++++++++++++++++++++++ .github/workflows/specs.yml | 32 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 1 + README.md | 26 +++++++++++++++++--------- composer.json | 10 +++++++--- phpunit.xml | 17 ++--------------- 8 files changed, 85 insertions(+), 57 deletions(-) delete mode 100644 .circleci/config.yml delete mode 100644 .coveralls.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/specs.yml diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 33edcf4..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,28 +0,0 @@ ---- -version: 2.1 - -jobs: - build: - docker: - - image: cimg/php:7.2-browsers - steps: - - checkout - - run: sudo apt-get update - - run: sudo apt-get install php7.2-xdebug -y - - restore_cache: - keys: - - v2-dependencies-{{ checksum "composer.json" }} - - v2-dependencies- - - run: composer install --no-interaction - - save_cache: - key: v2-dependencies-{{ checksum "composer.json" }} - paths: - - ./vendor - - run: mkdir -p build/logs - - run: XDEBUG_MODE=coverage ./vendor/bin/phpunit -c phpunit.xml - - run: ./vendor/bin/php-coveralls -v - -workflows: - build_and_test: - jobs: - - build diff --git a/.coveralls.yml b/.coveralls.yml deleted file mode 100644 index 6d00a3d..0000000 --- a/.coveralls.yml +++ /dev/null @@ -1,2 +0,0 @@ -coverage_clover: build/logs/clover.xml -json_path: build/logs/coveralls-upload.json \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3b4bfaf --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,26 @@ +name: Lint + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: none + tools: composer + + - name: Validate composer.json + run: composer validate --strict --no-check-lock + + - name: PHP syntax check + run: find lib test -name '*.php' -print0 | xargs -0 -n1 -P4 php -l diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml new file mode 100644 index 0000000..b788ea1 --- /dev/null +++ b/.github/workflows/specs.yml @@ -0,0 +1,32 @@ +name: Specs + +on: + push: + branches: [master, develop] + pull_request: + +jobs: + specs: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: ['7.2', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP ${{ matrix.php-version }} + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: curl, json + coverage: none + tools: composer + + - name: Install dependencies + run: composer update --no-interaction --no-progress --prefer-dist + + - name: Run tests + run: composer test diff --git a/CHANGELOG.md b/CHANGELOG.md index f25b7eb..6d5262d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies +* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.4 ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index ddb3581..cc7f30b 100644 --- a/README.md +++ b/README.md @@ -12,23 +12,25 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the Castle APIs +## Requirements + +PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested +against PHP 7.2 through 8.4. + ## Getting started -Obtain the latest version of the Castle PHP bindings with: +Install the latest version with [Composer](https://getcomposer.org): ```bash -git clone --single-branch --branch master https://github.com/castle/castle-php +composer require castle/castle-php ``` -To get started, add the following to your PHP script: +Then load Composer's autoloader and configure the library with your Castle API +secret: ```php -require_once("/path/to/castle-php/lib/Castle.php"); -``` +require_once 'vendor/autoload.php'; -Configure the library with your Castle API secret. - -```php Castle::setApiKey('YOUR_API_SECRET'); ``` @@ -160,4 +162,10 @@ Whenever something unexpected happens, an [exception](/lib/Castle/Errors.php) is | `Castle_WebhookVerificationError` | An incoming webhook could not be verified against the `X-Castle-Signature` header | ## Running test suite -Execute `vendor/bin/phpunit test` to run the full test suite + +Install the dev dependencies and run the suite with: + +```bash +composer install +composer test +``` diff --git a/composer.json b/composer.json index 0b1f73b..abba985 100644 --- a/composer.json +++ b/composer.json @@ -20,10 +20,14 @@ ] }, "require": { - "php": ">=7.2.0" + "php": ">=7.2.0", + "ext-curl": "*", + "ext-json": "*" }, "require-dev": { - "phpunit/phpunit": "*", - "php-coveralls/php-coveralls": "*" + "phpunit/phpunit": "^8.5 || ^9.6" + }, + "scripts": { + "test": "phpunit -c phpunit.xml" } } diff --git a/phpunit.xml b/phpunit.xml index 4a519d4..9d4c1d1 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,21 +1,8 @@ - + ./test - - - - - - ./lib/Castle - ./lib/RestModel - - ./lib/Castle/CookieStore.php - ./lib/Castle/CurlTransport.php - - - - \ No newline at end of file + From 86da194d79984e4c9676c1df74feff1991386f0b Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:49:25 +0200 Subject: [PATCH 4/5] CI: drop EOL PHP 7.2 from the test matrix --- .github/workflows/specs.yml | 2 +- CHANGELOG.md | 2 +- README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/specs.yml b/.github/workflows/specs.yml index b788ea1..303d74c 100644 --- a/.github/workflows/specs.yml +++ b/.github/workflows/specs.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['7.2', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d5262d..69e787c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies -* migrated CI to GitHub Actions and now test against PHP 7.2 through 8.4 +* migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 ## 3.2.0 (2022-03-28) * updated ca-certs file diff --git a/README.md b/README.md index cc7f30b..dd1e3ef 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ See the [documentation](https://docs.castle.io) for how to use this SDK with the ## Requirements PHP 7.2 or newer, with the `curl` and `json` extensions. The library is tested -against PHP 7.2 through 8.4. +against PHP 7.4 through 8.4. ## Getting started From 9fbcfb7e4a19a21edadd6569789946956196f4fc Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 8 Jun 2026 16:51:56 +0200 Subject: [PATCH 5/5] Auto-attach sent_at timestamp and harden Castle_ApiError on PHP 8 Add an ISO8601 millisecond-precision sent_at timestamp automatically to risk, filter and log requests, matching the behaviour of the other Castle SDKs (closes #23). Default Castle_ApiError's message to an empty string and coalesce null to avoid the PHP 8.1+ deprecation when an error body is empty. --- CHANGELOG.md | 1 + lib/Castle/Errors.php | 4 ++-- lib/Castle/Request.php | 16 ++++++++++++++++ test/CastleTest.php | 42 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69e787c..3643c94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * added the Privacy API: `Castle::requestUserData`, `Castle::deleteUserData` * added webhook signature verification: `Castle_Webhook::verify` and the `Castle_WebhookVerificationError` exception * the request context is now attached automatically to `risk`, `filter` and `log` requests +* a `sent_at` timestamp is now attached automatically to `risk`, `filter` and `log` requests ([#23](https://github.com/castle/castle-php/issues/23)) * improved PHP 8 compatibility: declared the `Castle_ApiError` properties and made API error handling tolerant of empty response bodies * migrated CI to GitHub Actions and now test against PHP 7.4 through 8.4 diff --git a/lib/Castle/Errors.php b/lib/Castle/Errors.php index 886d2f3..8c391b3 100755 --- a/lib/Castle/Errors.php +++ b/lib/Castle/Errors.php @@ -25,9 +25,9 @@ class Castle_ApiError extends Castle_Error public $type; public $httpStatus; - public function __construct($msg, $type = null, $status = null) + public function __construct($msg = '', $type = null, $status = null) { - parent::__construct($msg); + parent::__construct($msg === null ? '' : $msg); $this->type = $type; $this->httpStatus = $status; } diff --git a/lib/Castle/Request.php b/lib/Castle/Request.php index 7e29498..90029bc 100755 --- a/lib/Castle/Request.php +++ b/lib/Castle/Request.php @@ -80,6 +80,10 @@ public function send($method, $url, $payload = array()) { $payload['context'] = Castle_RequestContext::extract(); } + if ( self::shouldHaveSentAt($url) && !array_key_exists('sent_at', $payload)) { + $payload['sent_at'] = self::generateTimestamp(); + } + return $this->sendWithContext($url, $payload, $method); } @@ -89,6 +93,18 @@ private function shouldHaveContext($url) { return in_array($url, $WITH_CONTEXT); } + private function shouldHaveSentAt($url) { + $WITH_SENT_AT = ['/risk', '/filter', '/log']; + + return in_array($url, $WITH_SENT_AT); + } + + // ISO8601 timestamp (millisecond precision, UTC) marking when the request was sent. + public static function generateTimestamp() { + $date = new DateTime('now', new DateTimeZone('UTC')); + return $date->format('Y-m-d\TH:i:s.v\Z'); + } + public function sendWithContext($url, $payload, $method = 'post') { $this->preFlightCheck(); diff --git a/test/CastleTest.php b/test/CastleTest.php index 9123a2d..e5c06c7 100644 --- a/test/CastleTest.php +++ b/test/CastleTest.php @@ -123,6 +123,48 @@ public function testLogIncludesContext() $this->assertArrayHasKey('context', $request['params']); } + public function testRiskIncludesSentAt() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::risk(Array( + 'request_token' => 'token', + 'name' => '$login', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/risk'); + $this->assertArrayHasKey('sent_at', $request['params']); + $this->assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/', + $request['params']['sent_at'] + ); + } + + public function testLogIncludesSentAt() + { + Castle_RequestTransport::setResponse(204, ''); + Castle::log(Array( + 'request_token' => 'token', + 'name' => '$login', + 'status' => '$succeeded', + 'user' => Array('id' => 'abc') + )); + $request = $this->assertRequest('post', '/log'); + $this->assertArrayHasKey('sent_at', $request['params']); + } + + public function testSentAtIsNotOverwritten() + { + Castle_RequestTransport::setResponse(200, '{}'); + Castle::filter(Array( + 'request_token' => 'token', + 'name' => '$registration', + 'user' => Array('id' => 'abc'), + 'sent_at' => '2020-01-01T00:00:00.000Z' + )); + $request = $this->assertRequest('post', '/filter'); + $this->assertEquals('2020-01-01T00:00:00.000Z', $request['params']['sent_at']); + } + public function testCreateList() { Castle_RequestTransport::setResponse(201, '{ "id": "list-id", "name": "blocklist" }');