diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..114f487 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI +on: + push: + branches: [ master ] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} + +jobs: + test: + name: Lint, analyse & test (PHP ${{ matrix.php }}${{ matrix.label }}) + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + php: ['8.2', '8.3', '8.4'] + extensions: ['json, curl'] + include: + # the stream transport is the fallback for hosts without ext-curl + - php: '8.4' + extensions: 'json, :curl' + label: ', no curl' + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: ${{ matrix.extensions }} + coverage: none + tools: composer:v2 + + - name: Show extensions + run: php -m | grep -iE '^(curl|json|iconv)$' || true + + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + + - name: Lint (php -l) + run: composer run-script lint + + - name: Static analysis (PHPStan) + run: composer run-script phpstan + + - name: Unit tests (PHPUnit) + run: composer run-script test diff --git a/.gitignore b/.gitignore index 57872d0..27eff37 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /vendor/ +/.phpunit.cache/ diff --git a/README.md b/README.md index 46baaba..c5e77a4 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,140 @@ # Open Web Analytics SDK for PHP -The OWA Software Development Kit for PHP makes it easy to use [Open Web Analytics](https://github.com/Open-Web-Analytics/Open-Web-Analytics) in your PHP code enabled you to easily as web analytics tracking to any PHP application. You can get started by installing The SDK via composer or downloading a tarball of our latest release. The SDK is licensed under GLP v2.0. -## Resource +The OWA Software Development Kit for PHP adds [Open Web Analytics](https://github.com/Open-Web-Analytics/Open-Web-Analytics) tracking to any PHP application and talks to OWA's REST API. The SDK is licensed under GPL v2.0. + +## Resources - [User Guide](https://github.com/Open-Web-Analytics/owa-php-sdk/wiki) -- [Issues](https://github.com/Open-Web-Analytics/owa-php-sdk/wiki) +- [Issues](https://github.com/Open-Web-Analytics/owa-php-sdk/issues) ## Getting Help We use Github for managing bugs and feature requests and have limited bandwidth to provide general support. If you do require support [please consider sponsoring the project](https://github.com/sponsors/padams). - -- If you have found a bug please [open a new issue](https://github.com/Open-Web-Analytics/owa-php-sdk/wiki). - ## Opening Issues -If you find a bug in the SDK, please let us know about it. However, before you create the ticket please search through the existing issues to make sure you it's not someting we already know nabout or have encountered in the past. When creating a new issue be sure to include the version of SDK, the PHP version, and operating system you are using. Also include a stack trace and detailed steps to reproduce the bug when appropriate. +If you find a bug in the SDK, please let us know about it. Before you create the ticket please search the existing issues. Include the SDK version, the PHP version, the OWA version and your operating system, plus a stack trace and steps to reproduce when appropriate. -## Getting Started +## Requirements -- **Minimum requirements** - to use the SDK you must be using OWA core v1.7.0 or later as well as PHP 7+. We highly recommend having your PHP compiled with the cURL extension. +- **OWA 1.14** or later. +- **PHP 8.2** or later, with ext-json and ext-iconv. ext-curl is used when it is installed; otherwise the SDK uses PHP streams, which need `allow_url_fopen`. -- **Install the SDK with composer** - composer require open-web-analytics/owa-php-sdk +The SDK has no other runtime dependencies. -- **User Guide** - read the [user guide](https://github.com/Open-Web-Analytics/owa-php-sdk/wiki) in order to learn how to work wit the SDK. +## Installation -## Quick Example +``` +composer require open-web-analytics/owa-php-sdk +``` -### Create a Tracker ### +Without Composer, download a release and `require 'owa-autoloader.php';`. + +## Tracking ```php -$config = [ - 'instance_url' => 'http://your.domain.com/owa/', - 'cookie_domain' => 'your.domain.com' -]; +$sdk = new OwaSdk\sdk( [ + 'instance_url' => 'https://owa.example.com/', // where OWA is installed + 'cookie_domain' => 'example.com', +] ); -$sdk = new OwaSdk\sdk($config); $tracker = $sdk->createTracker(); -$tracker->setSiteId('9ceefbab8a804bc03cb0be196abe12f8'); -$tracker->setPageTitle('Standalone PHP Test Page3'); +$tracker->setSiteId( '9ceefbab8a804bc03cb0be196abe12f8' ); // Administration > Sites +$tracker->setPageTitle( 'Home' ); $tracker->trackPageView(); +``` + +Call the tracker **before any output is sent**: it sets the same `owa_*` cookies as OWA's JavaScript tracker, so a visitor tracked by both keeps one visitor id and one session. + +Tracking methods return `true` when OWA received the request. On failure `$tracker->getLastErrors()` says why, and the failure is written to PHP's error log without credentials or visitor data. + +Other events: `trackAction()`, `trackSyndicationFeed()`, and ecommerce with `addTransaction()`, `addTransactionLineItem()` and `trackTransaction()`. Custom variables take a `page`, `session` or `visitor` scope: `setCustomVar( 1, 'plan', 'pro', 'session' )`. + +### The visitor's IP address and user agent + +OWA reads the IP address, user agent and language from the HTTP request it receives, never from event properties. The SDK therefore forwards them as request headers: + +| Header | Sent from | +|---|---| +| `User-Agent` | `setUserAgent()`, else the visitor's `User-Agent` | +| `X-Forwarded-For` | `setIpAddress()`, else the incoming `X-Forwarded-For` chain plus `REMOTE_ADDR` | +| `Accept-Language` | the visitor's `Accept-Language` | + +OWA takes the leftmost public address in `X-Forwarded-For`, so a visitor can put any address there, as they can when the JavaScript tracker reports to OWA directly. If your application sits behind a proxy and knows the real client address, pass it explicitly: + +```php +$tracker->setIpAddress( $trustedClientIp ); +``` + +`trackStatelessEvent()` (and `trackTransaction()` with a session id) is for events sent later, from a cron job or a payment webhook, so it forwards neither the current request's IP nor its user agent. Set them with `setIpAddress()` / `setUserAgent()` if you stored them. -?> +### Geolocation +OWA resolves location from the IP address above. Turn on the **MaxMind GeoIP** module in OWA under Administration > Modules; the SDK needs no configuration. + +### Do Not Track + +Like the JavaScript tracker, the SDK does not track a browser that sends `DNT: 1`. Set `'respect_do_not_track' => false` to change that. + +## REST API + +```php +$sites = $sdk->createSites( [ + 'credentials' => [ + 'api_key' => '...', // the user's API key: their profile screen in OWA + 'auth_key' => '...', // OWA_AUTH_KEY from the install's owa-config.php + ], +] ); + +$list = $sites->listSites(); +$site = $sites->addSite( [ 'domain' => 'https://shop.example.com', 'name' => 'Shop' ] ); +``` + +Both methods return the response's `data`, or `null` with the reasons in `getLastErrors()`. + +Instead of passing `credentials`, you can set the `OWA_API_KEY` and `OWA_AUTH_KEY` environment variables or constants, or create `~/.owa/credentials`: + +```ini +[defaults] +api_key = 0123456789abcdef0123456789abcdef +auth_key = your-owa-auth-key +``` + +Requests are signed with the full URL, so `instance_url` must use the scheme, host and port that OWA itself sees. + +## Configuration + +| Key | Default | | +|---|---|---| +| `instance_url` | | URL of the OWA install | +| `cookie_domain` | the request host, `www.` removed | domain for the `owa_*` cookies | +| `session_length` | `1800` | seconds of inactivity that end a session | +| `trafficAttributionMode` | `direct` | `direct` or `original` | +| `campaignAttributionWindow` | `60` | days a campaign touch is kept | +| `cookie_persistence` | `true` | `false` makes every cookie a session cookie | +| `respect_do_not_track` | `true` | | +| `timeout` / `connect_timeout` | `5` / `2` | seconds | +| `transport` | curl or streams | an `OwaSdk\Http\Transport` | +| `debug` / `debug_log` | `false` / PHP's error log | debug messages and where they go | + +## Upgrading from 1.0.x + +- PHP 8.2 and OWA 1.14 are required. +- Guzzle is no longer a dependency. `getHttpClient()` returns an `OwaSdk\Http\Transport`, and tracking methods return `bool` instead of a response object. +- Cookies follow OWA 1.14's tracker: the session lives in `owa_s_`, and every cookie is JSON. Existing `owa_s` and `owa_b` cookies are migrated on the visitor's next request. +- `addTransaction()`'s country, state and city are sent as `ct_country`, `ct_state` and `ct_city`. OWA 1.14 discards the old names. +- The SDK no longer computes `dsfs`, `dsps` or referrer-based attribution; OWA derives them on the server. `addOrganicSearchEngine()` does nothing. +- `debug` no longer turns on `display_errors`. + +## Development + +``` +composer install +composer test +composer phpstan ``` -## Realted OWA Products +## Related OWA Products - [Open Web Analytics Core](https://github.com/Open-Web-Analytics/open-web-analytics) - [WordPress Integration Plugin](https://github.com/Open-Web-Analytics/owa-wordpress-plugin) diff --git a/composer.json b/composer.json index c4fa95a..972faf0 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "open-web-analytics/owa-php-sdk", - "description": "The open source analytics framework.", + "description": "PHP SDK for Open Web Analytics: server-side event tracking and the REST API.", "keywords": ["open-web-analytics","owa","web-analytics","analytics"], "homepage": "https://www.openwebanalytics.com", "license": "GPL-2.0", @@ -13,15 +13,31 @@ "config": { "sort-packages": true }, - "require": { - "guzzlehttp/guzzle": "7.5.0", - "symfony/polyfill-intl-idn": "1.15", - "symfony/polyfill-mbstring": "1.20.0" + "php": ">=8.2", + "ext-iconv": "*", + "ext-json": "*" }, - "autoload": { - "psr-4": { - "OwaSdk\\": "src/" - } - } + "suggest": { + "ext-curl": "Used for HTTP requests when available; PHP streams are used otherwise." + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.5" + }, + "autoload": { + "psr-4": { + "OwaSdk\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OwaSdk\\Tests\\": "tests/" + } + }, + "scripts": { + "lint": "find src tests -name '*.php' -print0 | xargs -0 -n1 php -l", + "phpstan": "phpstan analyse --memory-limit=512M", + "test": "phpunit" + } } diff --git a/composer.lock b/composer.lock index 21e5558..20a9191 100644 --- a/composer.lock +++ b/composer.lock @@ -4,853 +4,1736 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6dee29b27195e3cd4f561c974d29d2a6", - "packages": [ + "content-hash": "0da3e532bcadcea7cdea7154bbcd684f", + "packages": [], + "packages-dev": [ { - "name": "guzzlehttp/guzzle", - "version": "7.5.0", + "name": "myclabs/deep-copy", + "version": "1.14.0", "source": { "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba" + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b50a2a1251152e43f6a37f0fa053e730a67d25ba", - "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", "shasum": "" }, "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.9.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/9e33da9553fe7786f0962b35f4e4ecf01be89def", + "reference": "9e33da9553fe7786f0962b35f4e4ecf01be89def", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.9.0" + }, + "time": "2026-09-13T18:51:52+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.3.1", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-10-08T21:09:11+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "10.1.16", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/7e308268858ed6baedc8704a304727d20bc07c77", + "reference": "7e308268858ed6baedc8704a304727d20bc07c77", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^4.19.1 || ^5.1.0", + "php": ">=8.1", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-text-template": "^3.0.1", + "sebastian/code-unit-reverse-lookup": "^3.0.0", + "sebastian/complexity": "^3.2.0", + "sebastian/environment": "^6.1.0", + "sebastian/lines-of-code": "^2.0.2", + "sebastian/version": "^4.0.1", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^10.1" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.1.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/10.1.16" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-22T04:31:57+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", + "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T06:24:48+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "4.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "reference": "f5e568ba02fa5ba0ddd0f618391d5a9ea50b06d7", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^10.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/4.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:56:09+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "reference": "0c7b06ff49e3d5072f057eb1fa59258bf287a748", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/3.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-08-31T14:07:24+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "reference": "e2a2d67966e740530f4a3343fe2e030ffdc1161d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "source": "https://github.com/sebastianbergmann/php-timer/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:57:52+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "10.5.66", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "747c9889613d0b3ffa08b74272e50ed3b8fa6d72" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/747c9889613d0b3ffa08b74272e50ed3b8fa6d72", + "reference": "747c9889613d0b3ffa08b74272e50ed3b8fa6d72", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", "ext-json": "*", - "guzzlehttp/promises": "^1.5", - "guzzlehttp/psr7": "^1.9 || ^2.4", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.1", + "phpunit/php-code-coverage": "^10.1.16", + "phpunit/php-file-iterator": "^4.1.0", + "phpunit/php-invoker": "^4.0.0", + "phpunit/php-text-template": "^3.0.1", + "phpunit/php-timer": "^6.0.0", + "sebastian/cli-parser": "^2.0.1", + "sebastian/code-unit": "^2.0.0", + "sebastian/comparator": "^5.0.5", + "sebastian/diff": "^5.1.1", + "sebastian/environment": "^6.1.0", + "sebastian/exporter": "^5.1.4", + "sebastian/global-state": "^6.0.2", + "sebastian/object-enumerator": "^5.0.0", + "sebastian/recursion-context": "^5.0.2", + "sebastian/type": "^4.0.0", + "sebastian/version": "^4.0.1" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "10.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5.66" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-10-05T05:46:26+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "reference": "c34583b87e7b7a8055bf6c450c2c77ce32a24084", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:12:49+00:00" + }, + { + "name": "sebastian/code-unit", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/a81fee9eef0b7a76af11d121767abc44c104e503", + "reference": "a81fee9eef0b7a76af11d121767abc44c104e503", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "source": "https://github.com/sebastianbergmann/code-unit/tree/2.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:58:43+00:00" + }, + { + "name": "sebastian/code-unit-reverse-lookup", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "reference": "5e3a687f7d8ae33fb362c5c0743794bbb2420a1d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } }, - "provide": { - "psr/http-client-implementation": "1.0" + "autoload": { + "classmap": [ + "src/" + ] }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", - "ext-curl": "*", - "php-http/client-integration-tests": "^3.0", - "phpunit/phpunit": "^8.5.29 || ^9.5.23", - "psr/log": "^1.1 || ^2.0 || ^3.0" + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "support": { + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/3.0.0" }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T06:59:15+00:00" + }, + { + "name": "sebastian/comparator", + "version": "5.0.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "reference": "55dfef806eb7dfeb6e7a6935601fef866f8ca48d", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/diff": "^5.0", + "sebastian/exporter": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, "branch-alias": { - "dev-master": "7.5-dev" + "dev-main": "5.0-dev" } }, "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" + "name": "Volker Dusch", + "email": "github@wallbash.com" }, { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "Guzzle is a PHP HTTP client library", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" + "comparator", + "compare", + "equality" ], "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.5.0" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/5.0.5" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://github.com/Nyholm", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", "type": "tidelift" } ], - "time": "2022-08-28T15:39:27+00:00" + "time": "2026-01-24T09:25:16+00:00" }, { - "name": "guzzlehttp/promises", - "version": "1.5.2", + "name": "sebastian/complexity", + "version": "3.2.0", "source": { "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "b94b2807d85443f9719887892882d0329d1e2598" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "68ff824baeae169ec9f2137158ee529584553799" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598", - "reference": "b94b2807d85443f9719887892882d0329d1e2598", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/68ff824baeae169ec9f2137158ee529584553799", + "reference": "68ff824baeae169ec9f2137158ee529584553799", "shasum": "" }, "require": { - "php": ">=5.5" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" }, "require-dev": { - "symfony/phpunit-bridge": "^4.4 || ^5.1" + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.5-dev" + "dev-main": "3.2-dev" } }, "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/3.2.0" + }, + "funding": [ { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:37:17+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "Guzzle promises library", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", "keywords": [ - "promise" + "diff", + "udiff", + "unidiff", + "unified diff" ], "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/1.5.2" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" } ], - "time": "2022-08-28T14:55:35+00:00" + "time": "2024-03-02T07:15:17+00:00" }, { - "name": "guzzlehttp/psr7", - "version": "2.4.3", + "name": "sebastian/environment", + "version": "6.1.0", "source": { "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "67c26b443f348a51926030c83481b85718457d3d" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/67c26b443f348a51926030c83481b85718457d3d", - "reference": "67c26b443f348a51926030c83481b85718457d3d", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/8074dbcd93529b357029f5cc5058fd3e43666984", + "reference": "8074dbcd93529b357029f5cc5058fd3e43666984", "shasum": "" }, "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.0", - "ralouphie/getallheaders": "^3.0" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" + "php": ">=8.1" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.1", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.29 || ^9.5.23" + "phpunit/phpunit": "^10.0" }, "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + "ext-posix": "*" }, "type": "library", "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - }, "branch-alias": { - "dev-master": "2.4-dev" + "dev-main": "6.1-dev" } }, "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/6.1.0" + }, + "funding": [ { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-23T08:47:14+00:00" + }, + { + "name": "sebastian/exporter", + "version": "5.1.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "0735b90f4da94969541dac1da743446e276defa6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/0735b90f4da94969541dac1da743446e276defa6", + "reference": "0735b90f4da94969541dac1da743446e276defa6", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.1", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" + "name": "Volker Dusch", + "email": "github@wallbash.com" }, { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" + "name": "Adam Harvey", + "email": "aharvey@php.net" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "PSR-7 message implementation that also provides common utility methods", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" + "export", + "exporter" ], "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.4.3" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/5.1.4" }, "funding": [ { - "url": "https://github.com/GrahamCampbell", + "url": "https://github.com/sebastianbergmann", "type": "github" }, { - "url": "https://github.com/Nyholm", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" }, { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", "type": "tidelift" } ], - "time": "2022-10-26T14:07:24+00:00" + "time": "2025-09-24T06:09:11+00:00" }, { - "name": "psr/http-client", - "version": "1.0.1", + "name": "sebastian/global-state", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", - "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", + "reference": "987bafff24ecc4c9ac418cab1145b96dd6e9cbd9", "shasum": "" }, "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0" + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "6.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "http", - "http-client", - "psr", - "psr-18" + "global state" ], "support": { - "source": "https://github.com/php-fig/http-client/tree/master" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/6.0.2" }, - "time": "2020-06-29T06:28:15+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:19:19+00:00" }, { - "name": "psr/http-factory", - "version": "1.0.1", + "name": "sebastian/lines-of-code", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", - "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/856e7f6a75a84e339195d48c556f23be2ebf75d0", + "reference": "856e7f6a75a84e339195d48c556f23be2ebf75d0", "shasum": "" }, "require": { - "php": ">=7.0.0", - "psr/http-message": "^1.0" + "nikic/php-parser": "^4.18 || ^5.0", + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "2.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "source": "https://github.com/php-fig/http-factory/tree/master" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/2.0.2" }, - "time": "2019-04-30T12:38:16+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-12-21T08:38:20+00:00" }, { - "name": "psr/http-message", - "version": "1.0.1", + "name": "sebastian/object-enumerator", + "version": "5.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363", - "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/202d0e344a580d7f7d04b3fafce6933e59dae906", + "reference": "202d0e344a580d7f7d04b3fafce6933e59dae906", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.1", + "sebastian/object-reflector": "^3.0", + "sebastian/recursion-context": "^5.0" + }, + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0.x-dev" + "dev-main": "5.0-dev" } }, "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "source": "https://github.com/php-fig/http-message/tree/master" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/5.0.0" }, - "time": "2016-08-06T14:39:51+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:08:32+00:00" }, { - "name": "ralouphie/getallheaders", - "version": "3.0.3", + "name": "sebastian/object-reflector", + "version": "3.0.0", "source": { "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/24ed13d98130f0e7122df55d06c5c4942a577957", + "reference": "24ed13d98130f0e7122df55d06c5c4942a577957", "shasum": "" }, "require": { - "php": ">=5.6" + "php": ">=8.1" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" + "phpunit/phpunit": "^10.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.0-dev" + } + }, "autoload": { - "files": [ - "src/getallheaders.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" } ], - "description": "A polyfill for getallheaders.", + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/3.0.0" }, - "time": "2019-03-08T08:55:37+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2023-02-03T07:06:18+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v2.5.2", + "name": "sebastian/recursion-context", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5d32fe257a9b39cb63146924d6b4e32a22d4502a", + "reference": "5d32fe257a9b39cb63146924d6b4e32a22d4502a", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "dev-main": "5.0-dev" } }, "autoload": { - "files": [ - "function.php" + "classmap": [ + "src/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" }, { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" } ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.2" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/5.0.2" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" + "url": "https://github.com/sebastianbergmann", + "type": "github" }, { - "url": "https://github.com/fabpot", - "type": "github" + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" }, { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", "type": "tidelift" } ], - "time": "2022-01-02T09:53:40+00:00" + "time": "2026-08-11T05:27:39+00:00" }, { - "name": "symfony/polyfill-intl-idn", - "version": "v1.15.0", + "name": "sebastian/type", + "version": "4.0.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf", - "reference": "47bd6aa45beb1cd7c6a16b7d1810133b728bdfcf", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/462699a16464c3944eefc02ebdd77882bd3925bf", + "reference": "462699a16464c3944eefc02ebdd77882bd3925bf", "shasum": "" }, "require": { - "php": ">=5.3.3", - "symfony/polyfill-mbstring": "^1.3", - "symfony/polyfill-php72": "^1.10" + "php": ">=8.1" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "phpunit/phpunit": "^10.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.15-dev" + "dev-main": "4.0-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Idn\\": "" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Laurent Bassin", - "email": "laurent@bassin.info" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "idn", - "intl", - "polyfill", - "portable", - "shim" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.15.0" + "issues": "https://github.com/sebastianbergmann/type/issues", + "source": "https://github.com/sebastianbergmann/type/tree/4.0.0" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2020-03-09T19:04:49+00:00" + "time": "2023-02-03T07:10:45+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.20.0", + "name": "sebastian/version", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "39d483bdf39be819deabf04ec872eb0b2410b531" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/39d483bdf39be819deabf04ec872eb0b2410b531", - "reference": "39d483bdf39be819deabf04ec872eb0b2410b531", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c51fa83a5d8f43f1402e3f32a005e6262244ef17", + "reference": "c51fa83a5d8f43f1402e3f32a005e6262244ef17", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-mbstring": "For best performance" + "php": ">=8.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.20-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "dev-main": "4.0-dev" } }, "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.20.0" + "issues": "https://github.com/sebastianbergmann/version/issues", + "source": "https://github.com/sebastianbergmann/version/tree/4.0.1" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2020-10-23T14:02:19+00:00" + "time": "2023-02-07T11:34:05+00:00" }, { - "name": "symfony/polyfill-php72", - "version": "v1.27.0", + "name": "theseer/tokenizer", + "version": "1.3.1", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "869329b1e9894268a8a61dabb69153029b7a8c97" + "url": "https://github.com/theseer/tokenizer.git", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/869329b1e9894268a8a61dabb69153029b7a8c97", - "reference": "869329b1e9894268a8a61dabb69153029b7a8c97", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c", + "reference": "b7489ce515e168639d17feec34b8847c326b0b3c", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^7.2 || ^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.27-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - } + "classmap": [ + "src/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" } ], - "description": "Symfony polyfill backporting some PHP 7.2+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.27.0" + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/1.3.1" }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/theseer", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2022-11-03T14:55:06+00:00" + "time": "2025-11-17T20:03:58+00:00" } ], - "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], - "plugin-api-version": "2.2.0" + "platform": { + "php": ">=8.2", + "ext-iconv": "*", + "ext-json": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" } diff --git a/owa-autoloader.php b/owa-autoloader.php index 303e2ec..e53c2cd 100644 --- a/owa-autoloader.php +++ b/owa-autoloader.php @@ -1,24 +1,27 @@ __DIR__ . '/src/sdk.php', - 'OwaSdk\OwaClient' => __DIR__ . '/src/OwaClient.php', - 'OwaSdk\Tracker\TrackerClient' => __DIR__ . '/src/Tracker/TrackerClient.php', - 'OwaSdk\Tracker\State' => __DIR__ . '/src/Tracker/State.php', - 'OwaSdk\Tracker\TrackingEvent' => __DIR__ . '/src/Tracker/TrackingEvent.php' - -]; - - -spl_autoload_register(function ($class) use ($mapping) { - - if (isset($mapping[$class])) { - - require $mapping[$class]; +/** + * Autoloader for installs that do not use Composer's own autoloader. + * + * Projects that installed the SDK with Composer should include + * vendor/autoload.php instead. The SDK has no runtime dependencies, so this + * file is all a manual install needs. + */ + +spl_autoload_register( function ( $class ) { + + $prefix = 'OwaSdk\\'; + + if ( ! str_starts_with( $class, $prefix ) ) { + + return; } - -}, true); -require_once( __DIR__ . '/vendor/autoload.php' ); + $file = __DIR__ . '/src/' . str_replace( '\\', '/', substr( $class, strlen( $prefix ) ) ) . '.php'; + + if ( is_file( $file ) ) { + + require $file; + } -?> \ No newline at end of file +}, true ); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..64f8d86 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,8 @@ +parameters: + level: 6 + paths: + - src + treatPhpDocTypesAsCertain: false + ignoreErrors: + # config arrays are free-form by design + - identifier: missingType.iterableValue diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..95aca66 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,30 @@ + + + + + + + + + tests + + + + + src + + + diff --git a/src/Http/CurlTransport.php b/src/Http/CurlTransport.php new file mode 100644 index 0000000..c36b69d --- /dev/null +++ b/src/Http/CurlTransport.php @@ -0,0 +1,52 @@ + $value ) { + + $header_lines[] = $name . ': ' . $value; + } + + $response_headers = []; + + curl_setopt_array( $ch, [ + CURLOPT_URL => $url, + CURLOPT_CUSTOMREQUEST => strtoupper( $method ), + CURLOPT_HTTPHEADER => $header_lines, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_TIMEOUT_MS => (int) ( ( $options['timeout'] ?? 5 ) * 1000 ), + CURLOPT_CONNECTTIMEOUT_MS => (int) ( ( $options['connect_timeout'] ?? 2 ) * 1000 ), + CURLOPT_HEADERFUNCTION => function ( $ch, $line ) use ( &$response_headers ) { + $response_headers[] = rtrim( $line, "\r\n" ); + return strlen( $line ); + }, + ] ); + + if ( $body !== null ) { + + curl_setopt( $ch, CURLOPT_POSTFIELDS, $body ); + } + + $result = curl_exec( $ch ); + + if ( $result === false ) { + + $error = curl_error( $ch ); + + return new Response( 0, '', [], $error ?: 'Request failed.' ); + } + + $status = (int) curl_getinfo( $ch, CURLINFO_RESPONSE_CODE ); + + return new Response( $status, (string) $result, Response::parseHeaderLines( $response_headers ) ); + } +} diff --git a/src/Http/Response.php b/src/Http/Response.php new file mode 100644 index 0000000..03bd058 --- /dev/null +++ b/src/Http/Response.php @@ -0,0 +1,75 @@ + $headers lower-cased header names + */ + public function __construct( + public readonly int $status, + public readonly string $body = '', + public readonly array $headers = [], + public readonly string $error = '' + ) {} + + /** + * True for a 2xx response. + */ + public function isSuccess(): bool { + + return $this->status >= 200 && $this->status < 300; + } + + public function getHeader( string $name ): ?string { + + return $this->headers[ strtolower( $name ) ] ?? null; + } + + /** + * The decoded JSON body, or null when the body is not JSON. + */ + public function json(): mixed { + + if ( $this->body === '' ) { + + return null; + } + + $decoded = json_decode( $this->body, true ); + + return json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } + + /** + * Parses raw "Name: value" header lines. Later duplicates win. + * + * @param list $lines + * @return array + */ + public static function parseHeaderLines( array $lines ): array { + + $headers = []; + + foreach ( $lines as $line ) { + + $pos = strpos( $line, ':' ); + + if ( $pos === false ) { + + // a status line starts a new response (redirects, 100 Continue) + if ( str_starts_with( $line, 'HTTP/' ) ) { + + $headers = []; + } + + continue; + } + + $headers[ strtolower( trim( substr( $line, 0, $pos ) ) ) ] = trim( substr( $line, $pos + 1 ) ); + } + + return $headers; + } +} diff --git a/src/Http/StreamTransport.php b/src/Http/StreamTransport.php new file mode 100644 index 0000000..638951f --- /dev/null +++ b/src/Http/StreamTransport.php @@ -0,0 +1,81 @@ + $value ) { + + $header_lines[] = $name . ': ' . $value; + } + + $http = [ + 'method' => strtoupper( $method ), + 'header' => implode( "\r\n", $header_lines ), + 'timeout' => (float) ( $options['timeout'] ?? 5 ), + 'ignore_errors' => true, + 'follow_location' => 0, + 'protocol_version' => 1.1, + ]; + + if ( $body !== null ) { + + $http['content'] = $body; + } + + $context = stream_context_create( [ 'http' => $http ] ); + + $error = ''; + + set_error_handler( function ( $errno, $errstr ) use ( &$error ) { + $error = $errstr; + return true; + } ); + + $result = false; + $raw_headers = []; + + try { + + $fp = fopen( $url, 'rb', false, $context ); + + if ( $fp ) { + + $meta = stream_get_meta_data( $fp ); + $raw_headers = is_array( $meta['wrapper_data'] ?? null ) ? $meta['wrapper_data'] : []; + $result = stream_get_contents( $fp ); + fclose( $fp ); + } + + } finally { + + restore_error_handler(); + } + + if ( $result === false || ! $raw_headers ) { + + return new Response( 0, '', [], $error ?: 'Request failed.' ); + } + + $status = 0; + + // the last status line is the final response + foreach ( $raw_headers as $line ) { + + if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', $line, $m ) ) { + + $status = (int) $m[1]; + } + } + + return new Response( $status, (string) $result, Response::parseHeaderLines( $raw_headers ) ); + } +} diff --git a/src/Http/Transport.php b/src/Http/Transport.php new file mode 100644 index 0000000..b0f87f6 --- /dev/null +++ b/src/Http/Transport.php @@ -0,0 +1,18 @@ + $headers + * @param array{timeout?: float, connect_timeout?: float} $options + */ + public function send( string $method, string $url, array $headers = [], ?string $body = null, array $options = [] ): Response; +} diff --git a/src/OwaClient.php b/src/OwaClient.php index a327060..2f16e10 100644 --- a/src/OwaClient.php +++ b/src/OwaClient.php @@ -1,278 +1,385 @@ config = [ - - 'instance_url' => '', - 'ns' => 'owa_', - 'endpoint' => 'api', - 'credentials' => [], - 'debug' => false - ]; - - // override default config with config array passed in. - $this->config = array_merge( $this->config, $config ); - - } - - public function getSetting( $name ) { - - if ( array_key_exists( $name, $this->config ) ) { - - return $this->config[ $name ]; - } - } - - public function setSetting( $name, $value ) { - - $this->config[ $name ] = $value; - } - - public function getCredentials() { - - if ( empty ( $this->config['credentials'] ) ) { - - $credentials = [ - - 'api_key' => '', - 'auth_key' => '' - ]; - - - // check environment variables - $api_key = getenv('OWA_API_KEY'); - - // check constant - if ( $api_key ) { - - $credentials['api_key'] = $api_key; - - } else { - - if ( defined('OWA_API_KEY') ) { - - $credentials['api_key'] = OWA_API_KEY; - } - } - - // check environment variables - $auth_key = getenv('OWA_AUTH_KEY'); - - // check constant - if ( $auth_key ) { - - $credentials['auth_key'] = $api_key; - - } else { - - if ( defined('OWA_AUTH_KEY') ) { - - $credentials['auth_key'] = OWA_AUTH_KEY; - } - } - - //check credentials file in home dir. - if (! $credentials['api_key'] && ! $credentials['auth_key'] ) { - - $file = $this->getHomeDirectory() . '/.owa/credentials'; - - if ( file_exists( $file ) ){ - - $file_contents = parse_ini_file( $file ); - $file_contents = $file_contents['defaults']; - - if ( is_array($file_contents) && array_key_exists( 'api_key', $file_contents ) && array_key_exists( 'auth_key', $file_contents )) { - - $credentials = $file_contents; - } - } - } - - $this->config['credentials'] = $credentials; - } - - return $this->config['credentials']; - } - - private function getHomeDirectory() { - + + const USER_AGENT = 'OWA SDK Client'; + + /** @var array */ + public array $config = []; + + public ?Response $last_response = null; + + /** + * Errors from the last request: a transport failure, or the messages in + * the REST envelope's 'error' member. + * + * @var list + */ + public array $last_errors = []; + + private ?Transport $transport = null; + + /** + * @param array $config + */ + public function __construct( array $config = [] ) { + + $this->config = [ + + 'instance_url' => '', + 'ns' => 'owa_', + 'endpoint' => 'api', + 'credentials' => [], + 'debug' => false, + 'timeout' => 5, + 'connect_timeout' => 2, + // an OwaSdk\Http\Transport; null picks curl, or streams without ext-curl + 'transport' => null, + ]; + + // override default config with config array passed in. + $this->config = array_merge( $this->config, $config ); + + $this->config['instance_url'] = self::normalizeInstanceUrl( (string) $this->config['instance_url'] ); + + if ( $this->config['transport'] instanceof Transport ) { + + $this->transport = $this->config['transport']; + } + } + + public function getSetting( string $name ): mixed { + + return $this->config[ $name ] ?? null; + } + + public function setSetting( string $name, mixed $value ): void { + + $this->config[ $name ] = $value; + } + + /** + * Credentials for the REST API. + * + * api_key is the user's API key (their profile screen in OWA). auth_key is + * the install's OWA_AUTH_KEY constant from owa-config.php. + * + * Looked up in order: the 'credentials' config array, the OWA_API_KEY / + * OWA_AUTH_KEY environment variables, constants of the same names, then + * the [defaults] section of ~/.owa/credentials. + * + * @return array{api_key: string, auth_key: string} + */ + public function getCredentials(): array { + + $credentials = $this->config['credentials']; + + if ( is_array( $credentials ) && ! empty( $credentials['api_key'] ) && ! empty( $credentials['auth_key'] ) ) { + + return [ 'api_key' => (string) $credentials['api_key'], 'auth_key' => (string) $credentials['auth_key'] ]; + } + + $credentials = [ + 'api_key' => self::envOrConstant( 'OWA_API_KEY' ), + 'auth_key' => self::envOrConstant( 'OWA_AUTH_KEY' ), + ]; + + if ( ! $credentials['api_key'] && ! $credentials['auth_key'] ) { + + $credentials = $this->readCredentialsFile() ?? $credentials; + } + + $this->config['credentials'] = $credentials; + + return $credentials; + } + + private static function envOrConstant( string $name ): string { + + $value = getenv( $name ); + + if ( $value !== false && $value !== '' ) { + + return $value; + } + + return defined( $name ) ? (string) constant( $name ) : ''; + } + + /** + * @return array{api_key: string, auth_key: string}|null + */ + private function readCredentialsFile(): ?array { + + $home = $this->getHomeDirectory(); + + if ( ! $home ) { + + return null; + } + + $file = $home . '/.owa/credentials'; + + if ( ! is_readable( $file ) ) { + + return null; + } + + $ini = parse_ini_file( $file, true, INI_SCANNER_RAW ); + + // [defaults] section, or bare keys + $section = is_array( $ini ) ? ( $ini['defaults'] ?? $ini ) : null; + + if ( is_array( $section ) && isset( $section['api_key'], $section['auth_key'] ) ) { + + return [ 'api_key' => (string) $section['api_key'], 'auth_key' => (string) $section['auth_key'] ]; + } + + return null; + } + + private function getHomeDirectory(): ?string { + // On Unix systems, use the HOME environment variable - if ($home = getenv( 'HOME' ) ) { - + if ( $home = getenv( 'HOME' ) ) { + return $home; } // Get the HOMEDRIVE and HOMEPATH values for Windows hosts - $home_drive = getenv('HOMEDRIVE'); - $home_path = getenv('HOMEPATH'); - - return ($home_drive && $home_path) ? $home_drive . $home_path : null; + $home_drive = getenv( 'HOMEDRIVE' ); + $home_path = getenv( 'HOMEPATH' ); + + return ( $home_drive && $home_path ) ? $home_drive . $home_path : null; } - - public function getHttpClient( $params = [] ) { - - return new Client( $params ); - - } - - public function generateRequestSignature( $request, $credentials = [] ) { - - if ( ! $credentials ) { - - $credentials = $this->getCredentials(); - } - - $url = $this->getSetting( 'instance_url' ) . $this->getSetting( 'endpoint' ) . $request['uri']; - - if ( array_key_exists('query', $request ) ) { - - $url .= '?' . http_build_query( $request['query'] ); - } - - // get the current time zone - $tz = date_default_timezone_get(); - - // switch to UTC for signature check - date_default_timezone_set('UTC'); - - // generate date - $date = date( 'Ymd', time() ); - - // return the time zone to prior state - date_default_timezone_set($tz); - - return base64_encode( hash('sha256', 'OWASIGNATURE' . $credentials['api_key'] . $url . $date . $credentials['auth_key'] ) ); - } - - public function makeRequest( $params ) { - - $conf = [ - - 'base_uri' => $this->getSetting('instance_url'), - - ]; - - $http = $this->getHttpClient( $conf ); - - //$http->setUserAgent('OWA SDK Client', true); - - $uri = $this->getSetting('endpoint') . $params['uri']; - - $request_options = []; - - if ( array_key_exists('query', $params) && $params[ 'query' ] ) { - - $request_options[ 'query' ] = $params[ 'query' ]; - } - - if ( array_key_exists('form_params', $params) && $params[ 'form_params' ] ) { - - $request_options[ 'form_params' ] = $this->ns( $params[ 'form_params' ] ); - } - - $credentials = $this->getCredentials(); - - $request_options[ 'headers' ] = [ - - 'X-SIGNATURE' => $this->generateRequestSignature( $params, $credentials ), - 'X-API-KEY' => $credentials['api_key'], - 'User-Agent' => 'OWA SDK Client', - 'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' - - ]; - - $res = null; - - try{ - - $res = $http->request( $params['http_method'], $uri, $request_options ); - - - } - - catch( \GuzzleHttp\Exception\RequestException | \GuzzleHttp\Exception\ConnectException | \GuzzleHttp\Exception\ClientException $e ) { - - $r = $e->getRequest(); - $res = null; - - error_log( print_r( $r, true ) ); - - if ( $e->hasResponse() ) { - - $res = $e->getResponse(); - - error_log( print_r( $res, true ) ); - } - - if ( $this->getSetting( 'debug' ) ) { - - print_r($r); - print_r($res); - } - } - - if ( $res ) { - - $this->last_response = $res ; - - $b = $res->getBody(); - - if ( $b ) { - - $b = json_decode( $b, true); - - if ( $b && array_key_exists( 'data', $b ) ) { - - return $b['data']; - } - } - } + + public function getHttpClient(): Transport { + + if ( ! $this->transport ) { + + $this->transport = extension_loaded( 'curl' ) ? new CurlTransport() : new StreamTransport(); + } + + return $this->transport; } - - public static function setDefaultParams( $defaults, $params ) { - - if ( is_array( $defaults ) && is_array( $params ) ) { - - return array_merge( $defaults, array_filter( $params ) ); - } + + /** + * Full URL of a REST request, exactly as it is sent and signed. + * + * @param array{uri: string, query?: array} $request + */ + public function buildRequestUrl( array $request ): string { + + $url = $this->getSetting( 'instance_url' ) . $this->getSetting( 'endpoint' ) . $request['uri']; + + if ( ! empty( $request['query'] ) ) { + + $url .= '?' . http_build_query( $request['query'], '', '&', PHP_QUERY_RFC3986 ); + } + + return $url; } - - public function ns( $value ) { - - $ns = $this->getSetting('ns'); - - if ( is_array( $value ) ) { - - $new = []; - - foreach ( $value as $k => $v ) { - - $new[ $ns . $k ] = $v; - - } - - return $new; - } - - if ( is_string( $value ) ) { - - return $ns . trim( $value ); - } + + /** + * The X-SIGNATURE value OWA expects for a URL. + * + * base64( sha256_hex( 'OWASIGNATURE' . api_key . url . Ymd(UTC) . auth_key ) ). + * OWA rebuilds the URL from the scheme, Host and REQUEST_URI it receives, so + * instance_url must use the same scheme, host and port the server sees. + * + * @param string|array{uri: string, query?: array} $url the URL, or a request array as makeRequest() takes + * @param array{api_key: string, auth_key: string}|array{} $credentials + */ + public function generateRequestSignature( string|array $url, array $credentials = [] ): string { + + if ( is_array( $url ) ) { + + $url = $this->buildRequestUrl( $url ); + } + + if ( ! $credentials ) { + + $credentials = $this->getCredentials(); + } + + $date = gmdate( 'Ymd' ); + + return base64_encode( hash( 'sha256', 'OWASIGNATURE' . $credentials['api_key'] . $url . $date . $credentials['auth_key'] ) ); + } + + /** + * Sends a signed REST request. + * + * @param array{http_method: string, uri: string, query?: array, form_params?: array} $params + * @return mixed the envelope's 'data' member, or null on failure (see getLastErrors()) + */ + public function makeRequest( array $params ): mixed { + + $this->last_errors = []; + + $url = $this->buildRequestUrl( $params ); + $credentials = $this->getCredentials(); + + $headers = [ + 'X-API-KEY' => $credentials['api_key'], + 'X-SIGNATURE' => $this->generateRequestSignature( $url, $credentials ), + 'User-Agent' => self::USER_AGENT, + 'Accept' => 'application/json', + ]; + + $body = null; + + if ( ! empty( $params['form_params'] ) ) { + + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + $body = http_build_query( $this->ns( $params['form_params'] ), '', '&', PHP_QUERY_RFC3986 ); + } + + $res = $this->send( $params['http_method'], $url, $headers, $body ); + + if ( $res->status === 0 ) { + + return null; + } + + $json = $res->json(); + + if ( is_array( $json ) && ! empty( $json['error'] ) ) { + + $this->last_errors = self::flattenErrors( $json['error'] ); + } + + if ( ! $res->isSuccess() ) { + + if ( ! $this->last_errors ) { + + $this->last_errors[] = "HTTP {$res->status}"; + } + + $this->logFailure( $params['http_method'], $url, $res ); + + return null; + } + + return is_array( $json ) && array_key_exists( 'data', $json ) ? $json['data'] : null; + } + + /** + * @param array $headers + */ + protected function send( string $method, string $url, array $headers, ?string $body = null ): Response { + + $res = $this->getHttpClient()->send( $method, $url, $headers, $body, [ + 'timeout' => (float) $this->getSetting( 'timeout' ), + 'connect_timeout' => (float) $this->getSetting( 'connect_timeout' ), + ] ); + + $this->last_response = $res; + + if ( $res->status === 0 ) { + + $this->last_errors = [ $res->error ]; + $this->logFailure( $method, $url, $res ); + } + + return $res; + } + + /** + * Logs a failed request by method and path only: headers carry + * credentials and a log.php query string carries visitor data. + */ + protected function logFailure( string $method, string $url, Response $res ): void { + + $what = $res->status === 0 ? $res->error : "HTTP {$res->status}"; + + error_log( sprintf( 'OWA SDK: %s %s failed: %s', $method, strtok( $url, '?' ), $what ) ); + + sdk::debug( 'Response body: %s', $res->body ); + } + + /** + * @return list + */ + private static function flattenErrors( mixed $error ): array { + + $out = []; + + array_walk_recursive( $error, function ( $v ) use ( &$out ) { + if ( is_scalar( $v ) && (string) $v !== '' ) { + $out[] = (string) $v; + } + } ); + + return $out; + } + + public function getLastResponse(): ?Response { + + return $this->last_response; + } + + /** + * @return list + */ + public function getLastErrors(): array { + + return $this->last_errors; + } + + /** + * @param array $defaults + * @param array $params + * @return array + */ + public static function setDefaultParams( array $defaults, array $params ): array { + + return array_merge( $defaults, array_filter( $params, fn( $v ) => $v !== null && $v !== '' ) ); + } + + /** + * Prefixes a name, or every key of an array, with the namespace. + * + * @param array|string $value + * @return array|string + */ + public function ns( array|string $value ): array|string { + + $ns = $this->getSetting( 'ns' ); + + if ( is_array( $value ) ) { + + $new = []; + + foreach ( $value as $k => $v ) { + + $new[ $ns . $k ] = $v; + } + + return $new; + } + + return $ns . trim( $value ); + } + + /** + * Ensures a trailing slash, so 'https://host/owa' and 'https://host/owa/' + * both resolve log.php and api/ under the install. + */ + public static function normalizeInstanceUrl( string $url ): string { + + if ( $url === '' ) { + + return ''; + } + + return rtrim( $url, '/' ) . '/'; } - -} - -?> \ No newline at end of file +} diff --git a/src/Sites/SitesClient.php b/src/Sites/SitesClient.php index 308f6bb..91573ef 100644 --- a/src/Sites/SitesClient.php +++ b/src/Sites/SitesClient.php @@ -2,72 +2,73 @@ namespace OwaSdk\Sites; use OwaSdk\OwaClient; -use OwaSdk\sdk as sdk; /** * Open Web Analytics - An Open Source Web Analytics Framework * Licensed under GPL v2.0 http://www.gnu.org/copyleft/gpl.html * */ - + /** - * Tracker Class - * + * Sites REST client + * * @author Peter Adams * @license http://www.gnu.org/copyleft/gpl.html GPL v2.0 */ - class SitesClient extends OwaClient { - - var $config; - - public function __construct($config = null) { - - // parent will override the default config with passed values. - parent::__construct( $config ); - - } - - public function listSites() { - - $request = [ - - 'http_method' => 'GET', - 'uri' => '/base/v1/sites' - ]; - - $res = $this->makeRequest( $request ); - - return $res; - - + + /** + * GET /base/v1/sites. Needs the view_site_list capability. + * + * @return array|null the sites, or null on failure + */ + public function listSites(): ?array { + + $request = [ + + 'http_method' => 'GET', + 'uri' => '/base/v1/sites' + ]; + + $res = $this->makeRequest( $request ); + + return is_array( $res ) ? $res : null; } - - public function addSite( $params ) { - - $defaults = [ - - 'domain' => '', - 'name' => '', - 'description' => '', - 'site_family' => '' - - ]; - - $params = self::setDefaultParams( $defaults, $params ); - - $request = [ - - 'http_method' => 'POST', - 'uri' => '/base/v1/sites', - 'form_params' => $params - ]; - - $res = $this->makeRequest( $request ); - - return $res; + + /** + * POST /base/v1/sites. Needs the edit_sites capability. + * + * Accepted params: domain (required for web streams), name (required + * unless propertyId names an existing property), description, + * site_family, propertyId, streamType, appId (required for app streams). + * + * OWA 1.14 does not reject a duplicate domain, so each call creates a site. + * + * @param array $params + * @return array|null the created site, or null on failure (see getLastErrors()) + */ + public function addSite( array $params ): ?array { + + $defaults = [ + + 'domain' => '', + 'name' => '', + 'description' => '', + 'site_family' => '' + + ]; + + $params = self::setDefaultParams( $defaults, $params ); + + $request = [ + + 'http_method' => 'POST', + 'uri' => '/base/v1/sites', + 'form_params' => $params + ]; + + $res = $this->makeRequest( $request ); + + return is_array( $res ) ? $res : null; } - } - -?> \ No newline at end of file diff --git a/src/Tracker/State.php b/src/Tracker/State.php index 4db866e..001cb21 100644 --- a/src/Tracker/State.php +++ b/src/Tracker/State.php @@ -1,4 +1,4 @@ - + * + * Reads and writes the same cookies as the OWA 1.14 JavaScript tracker + * (modules/Base/src/common/StateManager.js), so a visitor tracked by both + * keeps one visitor id and one session. + * + * Cookie contract: + * - name: , e.g. owa_v, owa_c, owa_s_ + * - value: JSON object, encoded with JavaScript escape() semantics + * - every object carries 'sv' (store version) and 'cdh' (cookie domain hash) + * - a cookie with a newer 'sv', or a missing or foreign 'cdh', is ignored + * - legacy 'k=>v|||k=>v' values are still read and rewritten as JSON + * + * @author Peter Adams */ +class State { + const STORE_VERSION = 1; -class State { - - private $config; - private $stores; - private $stores_meta; - private $is_dirty; - private $dirty_stores; - private $default_store_type; - private $stores_with_cdh; - private $initial_state; - private $cookies; - - function __construct( $config = []) { - - $this->config = [ - - 'cookie_domain' => '' - ]; - - // merge incoming config params - $this->config = array_merge( $this->config, $config ); - - - $this->cookies = []; - $this->stores = []; - $this->stores_meta = []; - $this->is_dirty; - $this->dirty_stores; - $this->default_store_type = 'cookie'; - $this->stores_with_cdh = []; - $this->initial_state = []; - - $this->initializeStores(); - } + private array $config; + + /** @var array|string> in-memory stores */ + private array $stores = []; - private function getSetting( $name ) { - - if ( array_key_exists( $name, $this->config ) ) { - - return $this->config[ $name ]; - } + /** @var array */ + private array $meta = []; - } + /** @var array stores whose cookie has been merged into memory */ + private array $loaded = []; - function registerStore( $name, $expiration, $length = '', $format = 'json', $type = 'cookie', $cdh = null ) { + /** @var array> raw cookie values by full name, in header order */ + private array $cookies; - $this->stores_meta[$name] = array( - 'expiration' => $expiration, - 'length' => $length, - 'format' => $format, - 'type' => $type, - 'cdh_required' => $cdh + /** @var callable(string, string, array): void */ + private $cookieWriter; + + /** + * @param array $config cookie_domain, ns, cookie_persistence, cookie_secure, hash_cookies_to_domain + * @param array|string>|null $cookies raw (still escaped) values by cookie name, one or a list of duplicates; null reads the request + * @param callable|null $cookieWriter fn( string $name, string $raw_value, array $options ); null uses setrawcookie() + */ + public function __construct( array $config = [], ?array $cookies = null, ?callable $cookieWriter = null ) { + + $this->config = array_merge( [ + 'cookie_domain' => '', + 'ns' => 'owa_', + 'cookie_persistence' => true, + 'cookie_secure' => null, + 'hash_cookies_to_domain' => true, + ], $config ); + + $this->cookies = array_map( + fn( $v ) => is_array( $v ) ? array_values( $v ) : [ (string) $v ], + $cookies ?? self::readRequestCookies() ); + $this->cookieWriter = $cookieWriter ?? [ self::class, 'sendCookie' ]; + } - if ( $cdh ) { - $this->stores_with_cdh[] = $name; - } + public function getSetting( string $name ): mixed { + + return $this->config[ $name ] ?? null; } + public function setSetting( string $name, mixed $value ): void { + + $this->config[ $name ] = $value; + } + + /** + * @param int|float|null $days cookie lifetime; null makes a session cookie + * @param bool $deferred the cookie is not merged on first read and not written on set; + * the owner calls settle() and persist() (the JS session store) + * @param bool $persist false keeps the store in memory only + */ + public function registerStore( string $name, int|float|null $days, bool $deferred = false, bool $persist = true ): void { + + $this->meta[ $name ] = [ 'days' => $days, 'deferred' => $deferred, 'persist' => $persist ]; + } + + public function isRegistered( string $name ): bool { + + return isset( $this->meta[ $name ] ); + } + + /** + * Value of one key, or the whole store when $key is empty. Null when absent. + */ + public function get( string $store, string $key = '' ): mixed { + + $this->load( $store ); + + if ( ! array_key_exists( $store, $this->stores ) ) { + return null; + } - public function get($store, $name = '') { + if ( $key === '' ) { - sdk::debug("Getting state - store: ".$store.' key: '.$name); - //owa_coreAPI::debug("existing stores: ".print_r($this->stores, true)); - if ( ! isset($this->stores[$store] ) ) { - $this->loadState($store); + return $this->stores[ $store ]; } - if (array_key_exists($store, $this->stores)) { + return is_array( $this->stores[ $store ] ) ? ( $this->stores[ $store ][ $key ] ?? null ) : null; + } - if (!empty($name)) { - // check to ensure this is an array, could be a string. - if (is_array($this->stores[$store]) && array_key_exists($name, $this->stores[$store])) { + /** + * What the cookie holds, without merging it into memory. + */ + public function getPersisted( string $store, string $key = '' ): mixed { - return $this->stores[$store][$name]; - } else { - return false; - } - } else { + $value = $this->readPersisted( $store ); - return $this->stores[$store]; - } - } else { + if ( $key === '' ) { - return false; + return $value; } + + return is_array( $value ) ? ( $value[ $key ] ?? null ) : null; } - function setState($store, $name, $value, $store_type = '', $is_perminent = false) { + public function set( string $store, string $key, mixed $value ): void { - sdk::debug(sprintf('populating state for store: %s, name: %s, value: %s, store type: %s, is_perm: %s', $store, $name, print_r($value, true), $store_type, $is_perminent)); + $this->load( $store ); - // set values - if (empty($name)) { - $this->stores[$store] = $value; - //owa_coreAPI::debug('setstate: '.print_r($this->stores, true)); - } else { - //just in case the store was set first as a string instead of as an array. - if ( array_key_exists($store, $this->stores)) { + if ( ! isset( $this->stores[ $store ] ) || ! is_array( $this->stores[ $store ] ) ) { - if ( ! is_array( $this->stores[$store] ) ) { - $new_store = array(); - // check to see if we need ot ad a cdh - if ( $this->isCdhRequired($store) ) { - $new_store['cdh'] = $this->getCookieDomainHash(); - } + $this->stores[ $store ] = []; + } - $new_store[$name] = $value; - $this->stores[$store] = $new_store; + $this->stores[ $store ][ $key ] = $value; - } else { - $this->stores[$store][$name] = $value; - } - // if the store does not exist then maybe add a cdh and the value - } else { + $this->autoPersist( $store ); + } - if ( $this->isCdhRequired($store) ) { - $this->stores[$store]['cdh'] = $this->getCookieDomainHash(); - } + /** + * Replaces a whole store. + * + * @param array|string $value + */ + public function replace( string $store, array|string $value ): void { - $this->stores[$store][$name] = $value; - } + $this->loaded[ $store ] = true; + $this->stores[ $store ] = $value; + + $this->autoPersist( $store ); + } + + /** + * Removes one key, or the whole store and its cookie when $key is empty. + */ + public function clear( string $store, string $key = '' ): void { + + $this->load( $store ); + + if ( $key === '' ) { + unset( $this->stores[ $store ] ); + $this->loaded[ $store ] = true; + $this->erase( $store ); + return; } - $this->dirty_stores[] = $store; - //owa_coreAPI::debug(print_r($this->stores, true)); + if ( isset( $this->stores[ $store ] ) && is_array( $this->stores[ $store ] ) && array_key_exists( $key, $this->stores[ $store ] ) ) { + + unset( $this->stores[ $store ][ $key ] ); + $this->autoPersist( $store ); + } } - function isCdhRequired($store_name) { + /** + * Settles a deferred store once its owner has decided whether the + * persisted values still apply. + * + * $discard true: the cookie describes something that has ended, so it is + * erased and only what was set in memory this request is kept. + * $discard false: the cookie is merged in behind memory; memory wins. + */ + public function settle( string $store, bool $discard ): void { + + if ( $discard ) { - if ( isset( $this->stores_meta[$store_name] ) ) { - return $this->stores_meta[$store_name]['cdh_required']; + $this->loaded[ $store ] = true; + $this->erase( $store ); + return; } + + $this->merge( $store ); } - function set($store, $name, $value, $store_type = '', $is_perminent = false) { + /** + * Writes a store to its cookie. + */ + public function persist( string $store ): void { + + $meta = $this->meta[ $store ] ?? null; + + if ( ! $meta || ! $meta['persist'] ) { - if ( ! isset($this->stores[$store] ) ) { - $this->loadState($store); + return; } - $this->setState($store, $name, $value, $store_type, $is_perminent); + if ( ! isset( $this->stores[ $store ] ) ) { + + sdk::debug( "Cannot persist state. Store $store is empty." ); + return; + } - // persist immeadiately if the store type is cookie - if ($this->stores_meta[$store]['type'] === 'cookie') { + $value = $this->stores[ $store ]; - $this->persistState($store); + if ( is_array( $value ) ) { + + $value = $this->stamp( $value ); + $this->stores[ $store ] = $value; + $value = json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); } + + $this->writeStoreCookie( $store, (string) $value ); } - function persistState( $store ) { + /** + * Moves a legacy cookie's values into another store's cookie and erases + * the legacy cookie. + * + * $collapse true: the legacy keys are merged over the target's (owa_b into + * the session store). false: the legacy cookie is copied only when the + * target has none (owa_s renamed to owa_s_). + */ + public function migrateCookie( string $from, string $to, bool $collapse ): void { - //check to see that store exists. - if ( isset( $this->stores[ $store ] ) ) { - sdk::debug('Persisting state store: '. $store . ' with: '. print_r($this->stores[ $store ], true)); - // transform state array into a string using proper format - if ( is_array( $this->stores[$store] ) ) { - switch ( $this->stores_meta[$store]['type'] ) { + $legacy = $this->readPersisted( $from ); - case 'cookie': + if ( ! is_array( $legacy ) ) { - // check for old style assoc format - // @todo eliminiate assoc style cookie format. - if ( $this->stores_meta[$store]['format'] === 'assoc' ) { - $cookie_value = $this->implode_assoc('=>', '|||', $this->stores[ $store ] ); - } else { - $cookie_value = json_encode( $this->stores[ $store ] ); - } + return; + } - break; + $target = $this->readPersisted( $to ); - default: + if ( ! $collapse && $target !== null ) { - } - } else { - $cookie_value = $this->stores[ $store ]; + $this->erase( $from ); + return; + } + + $merged = is_array( $target ) ? $target : []; + + foreach ( $legacy as $k => $v ) { + + if ( $k !== 'cdh' && $k !== 'sv' ) { + + $merged[ $k ] = $v; } - // get expiration time - $time = $this->stores_meta[$store]['expiration']; - //set cookie - $this->createCookie( $store, $cookie_value, $time, "/", $this->getSetting( 'cookie_domain' ) ); + } + + $raw = self::jsEscape( (string) json_encode( $this->stamp( $merged ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ); + + $this->writeStoreCookie( $to, $raw, false ); + $this->cookies[ $this->getSetting( 'ns' ) . $to ] = [ $raw ]; + $this->erase( $from ); + + sdk::debug( 'Migrated legacy store %s into %s.', $from, $to ); + } + + /** + * @param array $value + * @return array + */ + private function stamp( array $value ): array { + + $value['sv'] = self::STORE_VERSION; - } else { + if ( $this->getSetting( 'hash_cookies_to_domain' ) ) { - sdk::debug("Cannot persist state. No store registered with name $store"); + $value['cdh'] = $this->getCookieDomainHash(); } + + return $value; } - function setInitialState($store, $value, $store_type = '') { + private function writeStoreCookie( string $store, string $value, bool $escape = true ): void { + + $days = $this->meta[ $store ]['days'] ?? null; + $expires = 0; - if ($value) { - $this->initial_state[$store] = $value; + if ( $days && $this->getSetting( 'cookie_persistence' ) ) { + + $expires = time() + (int) round( $days * 86400 ); } + + $this->writeCookie( $store, $escape ? self::jsEscape( $value ) : $value, $expires ); } - function loadState($store, $name = '', $value = '', $store_type = 'cookie') { + public function erase( string $store ): void { + + $name = $this->getSetting( 'ns' ) . $store; + + // nothing to erase, and erasing would send a needless header + if ( empty( $this->cookies[ $name ] ) ) { - //get possible values - if ( ! $value && isset( $this->initial_state[$store] ) ) { - $possible_values = $this->initial_state[$store]; - } else { return; } + $this->writeCookie( $store, '', time() - 86400 * 2 ); - //count values - $count = count($possible_values); - // loop throught values looking for a domain hash match or just using the last value. - foreach ($possible_values as $k => $value) { - // check format of value + unset( $this->cookies[ $name ] ); + } - if ( strpos( $value, "|||" ) ) { - $value = $this->assocFromString($value); - } elseif ( strpos( $value, ":" ) ) { - $value = json_decode($value); - $value = (array) $value; - } else { - $value = $value; - } + public function getCookieDomainHash( string $domain = '' ): string { - if ( in_array( $store, $this->stores_with_cdh ) ) { - - if ( is_array( $value ) && isset( $value['cdh'] ) ) { - - $runtime_cdh = $this->getCookieDomainHash(); - $cdh_from_state = $value['cdh']; - - // return as the cdh's do not match - if ( $cdh_from_state === $runtime_cdh ) { - sdk::debug("cdh match: $cdh_from_state and $runtime_cdh"); - return $this->setState($store, $name, $value, $store_type); - } else { - // cookie domains do not match so we need to delete the cookie in the offending domain - // which is always likely to be a sub.domain.com and thus HTTP_HOST. - // if cookie is not deleted then new cookies set on .domain.com will never be seen by PHP - // as only the sub domain cookies are available. - sdk::debug("Not loading state store: $store. Domain hashes do not match - runtime: $runtime_cdh, cookie: $cdh_from_state"); - //owa_coreAPI::debug("deleting cookie: owa_$store"); - //owa_coreAPI::deleteCookie($store,'/', $_SERVER['HTTP_HOST']); - //unset($this->initial_state[$store]); - //return; - } - } else { - - sdk::debug("Not loading state store: $store. No domain hash found."); - return; - } + if ( ! $domain ) { - } else { - // just set the state with the last value - if ( $k === $count - 1 ) { - sdk::debug("loading last value in initial state container for store: $store"); - return $this->setState($store, $name, $value, $store_type); - } - } + $domain = (string) $this->getSetting( 'cookie_domain' ); } + + return self::crc32AsHex( $domain ); } - function clear($store, $name = '') { + /** + * Unsigned lower-case hex CRC32, as Util.dechex(Util.crc32()) in the JS tracker. + */ + public static function crc32AsHex( string $string ): string { - if ( ! isset($this->stores[$store] ) ) { - $this->loadState($store); - } + return dechex( crc32( $string ) ); + } + + /** + * Cookie values by name, keeping duplicates, in header order. + * + * Read from the raw Cookie header because the same name can be set on + * several domains (example.com and .example.com) and $_COOKIE keeps only + * one, and because $_COOKIE url-decodes '+' to a space, which escape() + * does not. + * + * @return array> + */ + public static function readRequestCookies(): array { - if ( array_key_exists( $store, $this->stores ) ) { + $cookies = []; - if ( ! $name ) { + if ( ! empty( $_SERVER['HTTP_COOKIE'] ) && is_string( $_SERVER['HTTP_COOKIE'] ) ) { - unset( $this->stores[ $store ] ); + foreach ( explode( ';', $_SERVER['HTTP_COOKIE'] ) as $pair ) { - if ($this->stores_meta[$store]['type'] === 'cookie') { + $pair = trim( $pair ); - return $this->deleteCookie($store); + if ( $pair === '' || ! str_contains( $pair, '=' ) ) { + + continue; } - } else { + [ $name, $value ] = explode( '=', $pair, 2 ); + $cookies[ trim( $name ) ][] = trim( $value ); + } - if ( array_key_exists( $name, $this->stores[ $store ] ) ) { - unset( $this->stores[ $store ][ $name ] ); + return $cookies; + } - if ($this->stores_meta[$store]['type'] === 'cookie') { + foreach ( $_COOKIE as $name => $value ) { - return $this->persistState( $store ); - } - } + if ( is_string( $value ) ) { + + $cookies[ $name ][] = $value; } } + + return $cookies; } - function getPermExpiration() { + /** + * JavaScript escape(): A-Z a-z 0-9 @*_+-./ stay as they are, other code + * units below 256 become %XX, the rest %uXXXX. + */ + public static function jsEscape( string $value ): string { - $time = time()+3600*24*365*15; - return $time; - } + $out = ''; + $units = unpack( 'n*', (string) iconv( 'UTF-8', 'UTF-16BE//IGNORE', $value ) ) ?: []; + + foreach ( $units as $u ) { + + if ( $u < 128 && preg_match( '#[A-Za-z0-9@*_+\-./]#', chr( $u ) ) ) { + + $out .= chr( $u ); - function addStores($array) { + } elseif ( $u < 256 ) { - $this->stores = array_merge($this->stores, $array); - return; + $out .= sprintf( '%%%02X', $u ); + + } else { + + $out .= sprintf( '%%u%04X', $u ); + } + } + + return $out; } - function getCookieDomainHash($domain = '') { + /** + * JavaScript unescape(): %XX and %uXXXX are code units, '+' stays '+'. + */ + public static function jsUnescape( string $value ): string { - if ( ! $domain ) { - $domain = $this->getSetting( 'cookie_domain' ); + if ( ! str_contains( $value, '%' ) ) { + + return $value; + } + + $utf16 = ''; + $len = strlen( $value ); + + for ( $i = 0; $i < $len; $i++ ) { + + $c = $value[ $i ]; + + if ( $c === '%' ) { + + if ( $i + 5 < $len && ( $value[ $i + 1 ] === 'u' || $value[ $i + 1 ] === 'U' ) && ctype_xdigit( substr( $value, $i + 2, 4 ) ) ) { + + $utf16 .= pack( 'n', hexdec( substr( $value, $i + 2, 4 ) ) ); + $i += 5; + continue; + } + + if ( $i + 2 < $len && ctype_xdigit( substr( $value, $i + 1, 2 ) ) ) { + + $utf16 .= pack( 'n', hexdec( substr( $value, $i + 1, 2 ) ) ); + $i += 2; + continue; + } + } + + $utf16 .= pack( 'n', ord( $c ) ); } - return $this->crc32AsHex($domain); + return (string) iconv( 'UTF-16BE', 'UTF-8//IGNORE', $utf16 ); } - + /** - * Convert Associative Array to String + * Decodes one cookie value: JSON when it starts with '{', else the legacy + * assoc format, else the plain string. * - * @param string $inner_glue - * @param string $outer_glue - * @param array $array - * @return string + * @return array|string */ - public static function implode_assoc($inner_glue, $outer_glue, $array) { - $output = array(); - foreach( $array as $key => $item ) { - $output[] = $key . $inner_glue . $item; + public static function decodeValue( string $raw ): array|string { + + $value = self::jsUnescape( $raw ); + + if ( str_starts_with( $value, '{' ) ) { + + $decoded = json_decode( $value, true ); + + return is_array( $decoded ) ? $decoded : ''; } - return implode($outer_glue, $output); - } - - private function createCookie($cookie_name, $cookie_value, $expires = 0, $path = '/', $domain = '') { - - $samesite = 'lax'; - $secure = false; - $httponly = false; - - if (! $domain ) { - - $domain = $this->getSetting( 'cookie_domain' ); - } - - if (is_array($cookie_value)) { - - $cookie_value = $this->implode_assoc('=>', '|||', $cookie_value); - } - - // add namespace - $cookie_name = sprintf('%s%s', $this->getSetting('ns'), $cookie_name); - - // debug - sdk::debug(sprintf('Setting cookie %s with values: %s under domain: %s', $cookie_name, $cookie_value, $domain)); - - // makes cookie to session cookie only - if ( ! $this->getSetting( 'cookie_persistence' ) ) { - $expires = 0; - } - - // check for php version to set samesite attribute. - //php 7.2 - if (PHP_VERSION_ID < 70300) { - - $path .= '; SameSite='.$samesite; - setcookie($cookie_name, $cookie_value, $expires, $path, $domain); - - } else { - //php 7.3+ - setcookie($cookie_name, $cookie_value, [ - 'expires' => $expires, - 'path' => $path, - 'domain' => $domain, - 'samesite' => $samesite, - 'secure' => $secure, - 'httponly' => $httponly, - ]); + if ( str_contains( $value, '|||' ) || str_contains( $value, '=>' ) ) { + + $state = []; + + foreach ( explode( '|||', $value ) as $pair ) { + + $parts = explode( '=>', $pair, 2 ); + + if ( count( $parts ) === 2 ) { + + $state[ $parts[0] ] = $parts[1]; + } + } + + return $state; } + + return $value; } - private function deleteCookie($cookie_name, $path = '/', $domain = '') { + private function load( string $store ): void { - return $this->createCookie($cookie_name, false, time()-3600*25, $path, $domain); - } - - private function crc32AsHex($string) { - - $crc = crc32($string); - //$crc += 0x100000000; - if ($crc < 0) { - $crc = 0xFFFFFFFF + $crc + 1; - } - return dechex($crc); + if ( isset( $this->loaded[ $store ] ) ) { + + return; + } + + // a deferred store is merged only by settle() + if ( ! empty( $this->meta[ $store ]['deferred'] ) ) { + + return; + } + + $this->merge( $store ); } - private function assocFromString($string_state, $inner = '=>', $outer = '|||') { + private function merge( string $store ): void { - if (!empty($string_state)): + $this->loaded[ $store ] = true; - if (strpos($string_state, $outer) === false): + $persisted = $this->readPersisted( $store ); - return $string_state; + if ( $persisted === null ) { - else: + return; + } - $array = explode($outer, $string_state); + $memory = $this->stores[ $store ] ?? null; - $state = array(); + if ( is_array( $persisted ) && ( is_array( $memory ) || $memory === null ) ) { - foreach ($array as $key => $value) { + $this->stores[ $store ] = array_merge( $persisted, $memory ?? [] ); - list($realkey, $realvalue) = explode($inner, $value); - $state[$realkey] = $realvalue; + } elseif ( $memory === null ) { - } + $this->stores[ $store ] = $persisted; + } + } - endif; + /** + * @return array|string|null + */ + private function readPersisted( string $store ): array|string|null { - endif; + $values = $this->cookies[ $this->getSetting( 'ns' ) . $store ] ?? []; + $hash = $this->getCookieDomainHash(); + $state = null; - return $state; + foreach ( $values as $i => $raw ) { + $value = self::decodeValue( $raw ); - } - - private function initializeStores() { - - // look for access to the raw HTTP cookie string. This is needed becuause OWA can set settings cookies - // with the same name under different subdomains. Multiple cookies with the same name are not - // available under $_COOKIE. Therefor OWA's cookie conainter must be an array of arrays. - if ( isset( $_SERVER['HTTP_COOKIE'] ) && strpos( $_SERVER['HTTP_COOKIE'], ';') ) { + if ( $value === '' || $value === [] ) { - $raw_cookie_array = explode(';', $_SERVER['HTTP_COOKIE']); + continue; + } - foreach($raw_cookie_array as $raw_cookie ) { + if ( is_array( $value ) && isset( $value['sv'] ) && (int) $value['sv'] > self::STORE_VERSION ) { - $nvp = explode( '=', trim( $raw_cookie ) ); - $this->cookies[ $nvp[0] ][] = urldecode($nvp[1]); + sdk::debug( 'Cookie %s index %s was written by a newer tracker. Not loading.', $store, $i ); + continue; } - } else { - // just use the normal cookie global - if ( $_COOKIE && is_array($_COOKIE) ) { + if ( $this->getSetting( 'hash_cookies_to_domain' ) ) { - foreach ($_COOKIE as $n => $v) { - // hack against other frameworks sanitizing cookie data and blowing away our '>' delimiter - // this should be removed once all cookies are using json format. - if (strpos($v, '>')) { - $v = str_replace(">", ">", $v); - } + if ( is_array( $value ) && isset( $value['cdh'] ) && (string) $value['cdh'] === $hash ) { - $cookies[ $n ][] = $v; + $state = $value; + break; } - } - } - // populate owa_cookie container with just the cookies that have the owa namespace. - $this->cookies = $this->stripParams( $this->cookies, $this->getSetting( 'ns' ) ); - - // merges cookies - foreach ( $this->cookies as $k => $owa_cookie ) { + sdk::debug( 'Cookie %s index %s has no matching domain hash. Not loading.', $store, $i ); + continue; + } - $this->setInitialState( $k, $owa_cookie ); + // without domain hashing the last cookie of that name wins + $state = $value; } + + return $state; } - - private function stripParams($params, $ns = '') { - $striped_params = array(); + private function autoPersist( string $store ): void { - if (!empty($ns)) { + $meta = $this->meta[ $store ] ?? null; - $len = strlen($ns); + if ( $meta && $meta['persist'] && ! $meta['deferred'] ) { - foreach ($params as $n => $v) { + $this->persist( $store ); + } + } - // if namespace is present in param - if (strstr($n, $ns)) { - // strip the namespace value - $striped_n = substr($n, $len); - //add to striped array - $striped_params[$striped_n] = $v; + private function writeCookie( string $store, string $raw_value, int $expires ): void { - } + $name = $this->getSetting( 'ns' ) . $store; - } + $options = [ + 'expires' => $expires, + 'path' => '/', + 'domain' => (string) $this->getSetting( 'cookie_domain' ), + 'samesite' => 'Lax', + 'secure' => $this->getSetting( 'cookie_secure' ) ?? self::isHttps(), + 'httponly' => false, + ]; - return $striped_params; + sdk::debug( 'Setting cookie %s with value: %s under domain: %s', $name, $raw_value, $options['domain'] ); - } else { + ( $this->cookieWriter )( $name, $raw_value, $options ); + } - return $params; + /** + * Default cookie writer. + */ + public static function sendCookie( string $name, string $raw_value, array $options ): void { + + if ( headers_sent( $file, $line ) ) { + + sdk::debug( "Cannot set cookie $name: output already started at $file:$line." ); + return; } + setrawcookie( $name, $raw_value, $options ); } + private static function isHttps(): bool { -} + $https = $_SERVER['HTTPS'] ?? ''; + + if ( $https && strtolower( (string) $https ) !== 'off' ) { + + return true; + } -?> \ No newline at end of file + return strtolower( (string) ( $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '' ) ) === 'https'; + } +} diff --git a/src/Tracker/TrackerClient.php b/src/Tracker/TrackerClient.php index 590b2b4..8a419d5 100644 --- a/src/Tracker/TrackerClient.php +++ b/src/Tracker/TrackerClient.php @@ -11,40 +11,79 @@ * Licensed under GPL v2.0 http://www.gnu.org/copyleft/gpl.html * */ - + /** * Tracker Class - * + * + * Server-side counterpart of the OWA 1.14 JavaScript tracker + * (modules/Base/src/tracker/Tracker.js). It derives the same visitor, + * session and attribution state from the same cookies and sends the same + * properties to log.php, so a visitor tracked from both PHP and JS keeps one + * visitor id and one session. + * * @author Peter Adams * @license http://www.gnu.org/copyleft/gpl.html GPL v2.0 */ - class TrackerClient extends OwaClient { - - var $config; - - var $state; - - var $commerce_event; - var $pageview_event; + const LOG_ENDPOINT = 'log.php'; + + /** + * Longer log.php URLs are sent as a POST body, as the JS tracker does. + */ + const GET_CHARACTER_LIMIT = 2000; + + /** + * Campaign keys (logical name => key in the owa_c attribution history). + */ + const CAMPAIGN_KEYS = [ + 'medium' => 'md', + 'campaign' => 'cn', + 'source' => 'sr', + 'search_terms' => 'tr', + 'ad' => 'ad', + 'ad_type' => 'at', + ]; + + public State $state; + + public ?TrackingEvent $commerce_event = null; + + public TrackingEvent $pageview_event; + + /** @var array */ + public array $global_event_properties = []; - var $global_event_properties = array(); + public bool $stateInit = false; - var $stateInit; + public string $site_id = ''; - var $organicSearchEngines; + /** + * Page-scoped state: the JS tracker's 'd' store. Never persisted. + * + * @var array + */ + private array $page_state = []; + + private bool $isNewSessionFlag = false; + + private bool $pendingSessionStart = false; + + private bool $pendingVisitorCreated = false; + + private ?string $ip_address = null; - // set one traffic has been attributed. - var $isTrafficAttributed; + private ?string $user_agent = null; - public function __construct($config = null) { + /** + * @param array $config + */ + public function __construct( array $config = [] ) { - $defaults = [ - - 'instance_url' => '', - 'visitor_param' => 'v', - 'campaign_params' => [ + $defaults = [ + + 'instance_url' => '', + 'campaign_params' => [ 'campaign' => 'owa_campaign', 'medium' => 'owa_medium', 'source' => 'owa_source', @@ -52,317 +91,335 @@ public function __construct($config = null) { 'ad' => 'owa_ad', 'ad_type' => 'owa_ad_type' ], 'trafficAttributionMode' => 'direct', + // lifetime of the owa_c attribution history, in days 'campaignAttributionWindow' => 60, - 'site_session_param' => 'ss', + // inactivity, in seconds, after which the next request starts a new session 'session_length' => 1800, 'maxCustomVars' => 5, - 'max_prior_campaigns' => 5, - 'trafficAttributionMode' => 'direct', - 'campaignAttributionWindow' => 60, - 'ns' => 'owa_', - 'cookie_persistence' => true, // Controls persistence of cookies, only for use in europe needed - ]; - - $client_config = array_merge( $defaults, $config ); - - // parent will override the default config with passed values. - parent::__construct($client_config); - - $domain = ''; - - if (array_key_exists( 'cookie_domain', $config) ) { - - $domain = $config['cookie_domain']; - } - - // set the cookie domain - $this->setCookieDomain( $domain ); - - // inititiate state store - $state_config = [ - 'cookie_domain' => $this->getSetting( 'cookie_domain' ), - 'ns' => $this->getSetting( 'ns' ), - 'cookie_persistence' => $this->getSetting( 'cookie_persistence' ) - ]; - - $this->state = new State($state_config); - - $this->pageview_event = $this->makeEvent(); - $this->pageview_event->setEventType('base.page_request'); - // Set the page url from environmental vars - $this->setGlobalEventProperty( 'page_url', $this->getCurrentUrl() ); - $this->registerStateStore('v', time()+3600*24*365*10, '', 'assoc', 'cookie', true); - $this->registerStateStore('s', time()+3600*24*365*10, '', 'assoc', 'cookie', true); - $this->registerStateStore('b', null, '', 'json', 'cookie', true); - $cwindow = $this->getSetting( 'campaignAttributionWindow' ); - $this->registerStateStore('c', time() + 3600 * 24 * $cwindow , '', 'json', 'cookie', true); - - $this->organicSearchEngines = [ - ['d' => 'google', 'q' => 'q'], - ['d' => 'yahoo', 'q' => 'p'], - ['d' => 'msn', 'q' => 'q'], - ['d' => 'bing', 'q' => 'q'], - ['d' => 'images.google', 'q' => 'q'], - ['d' => 'images.search.yahoo.com', 'q' => 'p'], - ['d' => 'aol', 'q' => 'query'], - ['d' => 'aol', 'q' => 'encquery'], - ['d' => 'aol', 'q' => 'q'], - ['d' => 'lycos', 'q' => 'query'], - ['d' => 'ask', 'q' => 'q'], - ['d' => 'altavista', 'q' => 'q'], - ['d' => 'netscape', 'q' => 'query'], - ['d' => 'cnn', 'q' => 'query'], - ['d' => 'about', 'q' => 'terms'], - ['d' => 'mamma', 'q' => 'q'], - ['d' => 'daum', 'q' => 'q'], - ['d' => 'eniro', 'q' => 'search_word'], - ['d' => 'naver', 'q' => 'query'], - ['d' => 'pchome', 'q' => 'q'], - ['d' => 'alltheweb', 'q' => 'q'], - ['d' => 'voila', 'q' => 'rdata'], - ['d' => 'virgilio', 'q' => 'qs'], - ['d' => 'live', 'q' => 'q'], - ['d' => 'baidu', 'q' => 'wd'], - ['d' => 'alice', 'q' => 'qs'], - ['d' => 'yandex', 'q' => 'text'], - ['d' => 'najdi', 'q' => 'q'], - ['d' => 'mama', 'q' => 'query'], - ['d' => 'seznam', 'q' => 'q'], - ['d' => 'search', 'q' => 'q'], - ['d' => 'wp', 'q' => 'szukaj'], - ['d' => 'onet', 'q' => 'qt'], - ['d' => 'szukacz', 'q' => 'q'], - ['d' => 'yam', 'q' => 'k'], - ['d' => 'kvasir', 'q' => 'q'], - ['d' => 'sesam', 'q' => 'q'], - ['d' => 'ozu', 'q' => 'q'], - ['d' => 'terra', 'q' => 'query'], - ['d' => 'mynet', 'q' => 'q'], - ['d' => 'ekolay', 'q' => 'q'], - ['d' => 'rambler', 'q' => 'query'], - ['d' => 'rambler', 'q' => 'words'], - ['d' => 'duckduckgo', 'q' => 'q'], + 'max_prior_campaigns' => 5, + 'ns' => 'owa_', + 'cookie_domain' => '', + // false makes every cookie a session cookie + 'cookie_persistence' => true, + 'hash_cookies_to_domain' => true, + // the JS tracker does not run for a browser sending DNT: 1 + 'respect_do_not_track' => true, + // for tests: raw cookies by name, and a fn( $name, $value, $options ) cookie writer + 'cookies' => null, + 'cookie_writer' => null, ]; + + // parent will override the default config with passed values. + parent::__construct( array_merge( $defaults, $config ) ); + + $this->state = new State( + [ + 'cookie_domain' => self::resolveCookieDomain( (string) $this->getSetting( 'cookie_domain' ), (string) $this->getServerParam( 'HTTP_HOST' ) ), + 'ns' => $this->getSetting( 'ns' ), + 'cookie_persistence' => $this->getSetting( 'cookie_persistence' ), + 'hash_cookies_to_domain' => $this->getSetting( 'hash_cookies_to_domain' ), + ], + $this->getSetting( 'cookies' ), + $this->getSetting( 'cookie_writer' ) + ); + + $this->setSetting( 'cookie_domain', $this->state->getSetting( 'cookie_domain' ) ); + + $this->state->registerStore( 'v', 364 ); + $this->state->registerStore( 'c', (int) $this->getSetting( 'campaignAttributionWindow' ) ); + // legacy stores, read only to migrate them + $this->state->registerStore( 's', 364 ); + $this->state->registerStore( 'b', null ); + + $this->pageview_event = $this->makeEvent(); + $this->pageview_event->setEventType( 'base.page_request' ); } - public function setPageTitle($value) { - $this->pageview_event->set('page_title', $value); + /** + * The cookie domain, resolved as the JS tracker's setCookieDomain() does: + * a leading '.', and 'www.' stripped only when the domain was not passed + * explicitly. + */ + public static function resolveCookieDomain( string $domain, string $http_host = '' ): string { + + $explicit = $domain !== ''; + + if ( ! $explicit ) { + + // HTTP_HOST may carry a port; document.domain never does + $domain = (string) preg_replace( '/:\d+$/', '', $http_host ); + } + + $domain = ltrim( $domain, '.' ); + + if ( $domain === '' ) { + + return ''; + } + + if ( ! $explicit && str_starts_with( $domain, 'www.' ) ) { + + $domain = substr( $domain, 4 ); + } + + return '.' . $domain; } - public function setPageType($value) { - $this->pageview_event->set('page_type', $value); + public function setPageTitle( string $value ): void { + + $this->page_state['page_title'] = $value; } - public function setProperty($name, $value) { - $this->setGlobalEventProperty($name, $value); + public function setPageType( string $value ): void { + + $this->page_state['page_type'] = $value; } - private function setGlobalEventProperty($name, $value) { + public function setProperty( string $name, mixed $value ): void { - $this->global_event_properties[$name] = $value; + $this->setGlobalEventProperty( $name, $value ); } - private function getGlobalEventProperty($name) { + /** + * The visitor's IP address, forwarded to OWA in X-Forwarded-For. + * + * OWA ignores an ip_address event property and reads the address from the + * request headers. Without this the SDK forwards the incoming + * X-Forwarded-For chain plus REMOTE_ADDR, and OWA takes the leftmost + * public address, as it would for the JS tracker. Set it when your own + * proxy setup tells you the real address. + */ + public function setIpAddress( string $ip ): void { - if ( array_key_exists($name, $this->global_event_properties) ) { - return $this->global_event_properties[$name]; - } + $this->ip_address = $ip; } - private function deleteGlobalEventProperty( $name ) { + /** + * The visitor's user agent, forwarded to OWA as the User-Agent header. + * Defaults to the incoming request's User-Agent. + */ + public function setUserAgent( string $user_agent ): void { - if ( array_key_exists($name, $this->global_event_properties) ) { - unset($this->global_event_properties[$name]); + $this->user_agent = $user_agent; + } + + private function setGlobalEventProperty( string $name, mixed $value ): void { + + $this->global_event_properties[ $name ] = $value; + } + + private function getGlobalEventProperty( string $name ): mixed { + + return $this->global_event_properties[ $name ] ?? null; + } + + private function deleteGlobalEventProperty( string $name ): void { + + unset( $this->global_event_properties[ $name ] ); + } + + /** + * Name of this site's session store: s_. + */ + private function sessionStore(): string { + + return 's_' . $this->getSiteId(); + } + + /** + * Registers the per-site session store and runs the JS tracker's + * migrations: legacy owa_s becomes owa_s_, and owa_b's + * session-scoped custom variables are folded into it. + */ + private function initSessionStore(): void { + + $s = $this->sessionStore(); + + if ( $this->state->isRegistered( $s ) ) { + + return; } + + $this->state->registerStore( $s, 364, true ); + $this->state->migrateCookie( 's', $s, false ); + $this->state->migrateCookie( 'b', $s, true ); + $this->state->registerStore( 'ss_' . $this->getSiteId(), null ); + } + + /** + * A value from the pre-1.x ss_ store: 's' (session id) or 'last_req'. + */ + private function legacySessionValue( string $key ): mixed { + + return $this->state->get( 'ss_' . $this->getSiteId(), $key ); } - private function manageState( &$event ) { + private function manageState( TrackingEvent $event ): void { if ( ! $this->stateInit ) { - $this->setVisitorId( $event ); - $this->setFirstSessionTimestamp( $event ); - $this->setLastRequestTime( $event ); - $this->setSessionId( $event ); - $this->setNumberPriorSessions( $event ); - $this->setDaysSinceLastSession( $event ); + $this->initSessionStore(); + + $now = (int) ( $event->get( 'timestamp' ) ?: time() ); + + $this->setVisitorId(); + $this->setFirstSessionTimestamp( $now ); + // the session decision is made against the previous request, + // before the last-request time is advanced below + $this->setSessionId( $now ); + $this->setNumberPriorSessions(); $this->setTrafficAttribution( $event ); $this->stateInit = true; } + + // advanced for every event, so the session timeout measures inactivity + $this->state->set( $this->sessionStore(), 'last_req', (int) ( $event->get( 'timestamp' ) ?: time() ) ); } - private function setVisitorId( &$event ) { + private function setVisitorId(): void { - $visitor_id = $this->state->get( 'v', 'vid' ); + $visitor_id = $this->state->get( 'v', 'vid' ); if ( ! $visitor_id ) { - $visitor_id = $this->state->get( 'v' ); - $this->state->clear( 'v' ); - $this->state->set( 'v', 'vid', $visitor_id, 'cookie', true ); - } + // very old trackers stored the bare id as the whole cookie + $old = $this->state->get( 'v' ); - if ( ! $visitor_id ) { - $visitor_id = $event->getSiteSpecificGuid( $this->getSiteId() ); - $this->setGlobalEventProperty( 'is_new_visitor', true ); - $this->state->set( 'v', 'vid', $visitor_id, 'cookie', true ); + if ( is_string( $old ) && $old !== '' ) { + + $visitor_id = $old; + $this->state->replace( 'v', [] ); + } } - // set property on event object - $this->setGlobalEventProperty( 'visitor_id', $visitor_id ); - } - private function setNumberPriorSessions( &$event ) { - // if check for nps value in vistor cookie. - $nps = $this->state->get('v', 'nps'); + if ( ! $visitor_id ) { - // if new session, increment visit count and persist to state store - if ( $this->getGlobalEventProperty('is_new_session' ) ) { + $visitor_id = TrackingEvent::generateRandomGuid(); - // set value to 0 if not found. - if ( ! $nps ) { + // session state: this session is the visitor's first + $this->state->set( $this->sessionStore(), 'is_new_visitor', true ); + $this->pendingVisitorCreated = true; + sdk::debug( 'Creating new visitor id' ); + } - $nps = "0"; + $this->state->set( 'v', 'vid', (string) $visitor_id ); + } - } else { + private function setFirstSessionTimestamp( int $now ): void { - $nps = $nps + 1; - } + if ( ! $this->state->get( 'v', 'fsts' ) ) { - $this->state->set('v', 'nps', $nps, 'cookie', true); + $this->state->set( 'v', 'fsts', $now ); } - - // set property on the event object - $this->setGlobalEventProperty('nps', $nps); } - private function setFirstSessionTimestamp( &$event ) { + private function setSessionId( int $now ): void { - $fsts = $this->state->get( 'v', 'fsts' ); + $s = $this->sessionStore(); - if ( ! $fsts ) { - $fsts = $event->get('timestamp'); - $this->state->set($this->getSetting('visitor_param'), 'fsts', $fsts , 'cookie', true); - } + $previous_request = $this->state->get( $s, 'last_req' ) + ?? $this->state->getPersisted( $s, 'last_req' ) + ?? $this->legacySessionValue( 'last_req' ); - $this->setGlobalEventProperty( 'fsts', $fsts ); + $is_new_session = $this->isNewSession( $now, (int) $previous_request ); - // calc days since first session - $dsfs = round( ( $fsts - $event->get( 'timestamp' ) ) / ( 3600 * 24 ) ) ; - $this->state->set($this->getSetting( 'visitor_param' ), 'dsfs', $dsfs , 'cookie', true); - $this->setGlobalEventProperty( 'dsfs', $dsfs ); - } + if ( $is_new_session ) { - private function setDaysSinceLastSession( &$event ) { + $prior_session_start = $this->state->getPersisted( $s, 'sts' ); - sdk::debug('setting days since last session.'); - $dsps = ''; - if ( $this->getGlobalEventProperty( 'is_new_session' ) ) { - sdk::debug( 'timestamp: ' . $event->get( 'timestamp' ) ); - $last_req = $this->getGlobalEventProperty( 'last_req' ); - if ( ! $last_req ) { - $last_req = $event->get( 'timestamp' ); + if ( $prior_session_start ) { + + $this->state->set( $s, 'psts', $prior_session_start ); } - sdk::debug( 'last_req: ' . $last_req ); - $dsps = round( ( $event->get( 'timestamp' ) - $last_req ) / ( 3600*24 ) ); - $this->state->set('s', 'dsps', $dsps , 'cookie', true); - } - if ( ! $dsps ) { - $dsps = $this->state->get( 's', 'dsps' ); + $this->state->set( $s, 'sts', $now ); - if ( ! $dsps ) { - $dsps = 0; + $prior_session_id = $this->state->getPersisted( $s, 'sid' ) ?: $this->legacySessionValue( 's' ); + + if ( $prior_session_id ) { + + $this->state->set( $s, 'prior_session_id', (string) $prior_session_id ); } } - $this->setGlobalEventProperty( 'dsps', $dsps ); - } - - private function setSessionId( &$event ) { - - $is_new_session = $this->isNewSession( $event->get( 'timestamp' ), $this->getGlobalEventProperty( 'last_req' ) ); - - // if its a new seession based on time or if there is no prior session ID found, make a new session - if ( $is_new_session || ! $this->getSessionId() ) { - - sdk::debug("is new session"); - - //set prior_session_id - $prior_session_id = $this->getSessionId(); - - if ( ! $prior_session_id ) { - - $state_store_name = sprintf('%s_%s', $this->getSetting('site_session_param'), $this->getSiteId() ); - - $prior_session_id = $this->state->get($state_store_name, 's'); - - } else { - - $this->setGlobalEventProperty( 'prior_session_id', $prior_session_id ); - } + // a new session discards what the cookie holds; a continuing one merges it in + $this->state->settle( $s, $is_new_session ); - $this->resetSessionState(); - - // generate new session id - $session_id = $event->getSiteSpecificGuid( $this->getSiteId() ); + if ( $is_new_session ) { + + sdk::debug( 'This request is the start of a new session.' ); + $session_id = TrackingEvent::generateRandomGuid(); + $this->markNewSession(); - //mark new session flag on current request - $this->setGlobalEventProperty( 'is_new_session', true ); - } else { - - sdk::debug("is existing session"); - - // Must be an active session so just pull the session id from the state store - $session_id = $this->getSessionId(); + sdk::debug( 'This request is part of an active session.' ); + $session_id = $this->state->get( $s, 'sid' ) ?: $this->legacySessionValue( 's' ); + + // fail-safe: an active session without an id + if ( ! $session_id ) { + + $session_id = TrackingEvent::generateRandomGuid(); + $this->markNewSession(); + + if ( ! $this->state->get( $s, 'sts' ) ) { + + $this->state->set( $s, 'sts', $now ); + } + } } - // set global event property - $this->setGlobalEventProperty( 'session_id', $session_id ); - // set sid state - $this->state->set( 's', 'sid', $session_id, 'cookie', true ); + $this->state->set( $s, 'sid', (string) $session_id ); + + // The previous request's time, sent as last_req. Captured once: on a + // later page of the same session it hydrates from the cookie. + if ( $this->state->get( $s, 'prior_last_req' ) === null ) { + + $this->state->set( $s, 'prior_last_req', $previous_request ?? '' ); + } } - - public function getSessionId() { - - return $this->state->get('s', 'sid'); + + private function markNewSession(): void { + + $this->page_state['is_new_session'] = true; + $this->pendingSessionStart = true; + $this->isNewSessionFlag = true; } - private function setLastRequestTime( &$event ) { + public function getSessionId(): ?string { - $last_req = $this->state->get('s', 'last_req'); + $s = $this->sessionStore(); + $sid = $this->state->get( $s, 'sid' ) ?? $this->state->getPersisted( $s, 'sid' ); - // set property on event object - $this->setGlobalEventProperty( 'last_req', $last_req ); - sdk::debug("setting last_req value of $last_req as global event property."); - // store new state value - $this->state->set( 's', 'last_req', $event->get( 'timestamp' ), 'cookie', true ); + return $sid === null ? null : (string) $sid; } /** * Check to see if request is a new or active session - * - * @return boolean */ - private function isNewSession($timestamp = '', $last_req = 0) { - - $is_new_session = false; + private function isNewSession( int $timestamp = 0, int $last_req = 0 ): bool { if ( ! $timestamp ) { + $timestamp = time(); } - $time_since_lastreq = $timestamp - $last_req; - $len = $this->getSetting( 'session_length' ); - if ( $time_since_lastreq < $len ) { - sdk::debug("This request is part of an active session."); - return false; - } else { - //NEW SESSION. prev session expired, because no requests since some time. - sdk::debug("This request is the start of a new session. Prior session expired."); - return true; + return ( $timestamp - $last_req ) >= (int) $this->getSetting( 'session_length' ); + } + + private function setNumberPriorSessions(): void { + + if ( ! $this->isNewSessionFlag ) { + + return; } + + $nps = $this->state->get( 'v', 'nps' ); + + // "0" on the first session: a string, as the JS tracker stores it + $nps = ( $nps === null || $nps === '' ) ? '0' : (int) $nps + 1; + + $this->state->set( 'v', 'nps', $nps ); } /** @@ -371,140 +428,276 @@ private function isNewSession($timestamp = '', $last_req = 0) { * This function processes a tracking event by adding full state and global properties to it * Typically used by syncronous events such as Pageviews * - * @param object $event - * @return boolean + * @return bool true when OWA accepted the request */ - public function trackEvent( $event ) { + public function trackEvent( TrackingEvent $event ): bool { - // do not track anything if user is in overlay mode - if ($this->state->get('overlay')) { + if ( ! $this->getSiteId() ) { + + sdk::debug( 'No site id set. Call setSiteId() before tracking.' ); return false; } - $this->setGlobalEventProperty( 'HTTP_REFERER', $this->getServerParam('HTTP_REFERER') ); - - $this->setGlobalEventProperty( 'HTTP_USER_AGENT', $this->getServerParam('HTTP_USER_AGENT') ); + if ( $this->getSetting( 'respect_do_not_track' ) && $this->getServerParam( 'HTTP_DNT' ) === '1' ) { + + sdk::debug( 'Not tracking: the browser sent DNT: 1.' ); + return false; + } // set various state properties. $this->manageState( $event ); $event = $this->setAllGlobalEventProperties( $event ); - // log event to server - return $this->logEvent($event->getEventType(), $event); + $accepted = $this->logEvent( $event, true ); + + // the session store reaches the cookie only once a request carrying it is accepted + if ( $accepted ) { + + $this->state->persist( $this->sessionStore() ); + } + + return $accepted; } - + /** * Tracks Event without state or globals * * This function processes a raw tracking event without any state or globals added to it - * Typically used by asyncronous events such as Delayed commerce transactions or programaticly derived events + * Typically used by asyncronous events such as Delayed commerce transactions or programaticly derived events. + * The current request's IP and user agent are not forwarded, since they are + * not the visitor's; use setIpAddress() / setUserAgent() to supply them. * - * @param object $event - * @return boolean + * @return bool true when OWA accepted the request */ - public function trackStatelessEvent( $event ) { - - $event->set( 'site_id', $this->getSiteId() ); - - // log event to server. - return $this->logEvent( $event->getEventType(), $event ); - } - - /** + public function trackStatelessEvent( TrackingEvent $event ): bool { + + $event->set( 'site_id', $this->getSiteId() ); + + // log event to server. + return $this->logEvent( $event, false ); + } + + /** * Add Global properties to Tracking Event * - * This function add global properties to the tracking event - * - * @param object $event - * @return $event + * Precedence: a property set on the event, then global properties, then + * page, state and custom variable properties. */ - public function setAllGlobalEventProperties( $event ) { + public function setAllGlobalEventProperties( TrackingEvent $event ): TrackingEvent { + + if ( ! $event->get( 'site_id' ) ) { - if ( ! $event->get('site_id') ) { $event->set( 'site_id', $this->getSiteId() ); } - // add custom variables to global properties if not there already - for ( $i=1; $i <= $this->getSetting( 'maxCustomVars' ); $i++ ) { - $cv_param_name = 'cv' . $i; - $cv_value = ''; - - // if the custom var is not already a global property - if ( ! $this->getGlobalEventProperty( $cv_param_name ) ) { - // check to see if it exists - $cv_value = $this->getCustomVar( $i ); - // if so add it - if ( $cv_value ) { - $this->setGlobalEventProperty( $cv_param_name, $cv_value ); - } + // one-event flags + if ( $this->pendingSessionStart ) { + + $this->pendingSessionStart = false; + + if ( ! $event->isSet( 'is_new_session_start' ) ) { + + $event->set( 'is_new_session_start', true ); + } + } + + if ( $this->pendingVisitorCreated ) { + + $this->pendingVisitorCreated = false; + + if ( ! $event->isSet( 'is_new_visitor_created' ) ) { + + $event->set( 'is_new_visitor_created', true ); } } - // merge global event properties $event->setNewProperties( $this->global_event_properties ); + $event->setNewProperties( $this->collectPageProperties() + $this->collectStateProperties() + $this->collectCustomVars() ); return $event; - } - public function getAllEventProperties( $event ) { + /** + * @return array + */ + public function getAllEventProperties( TrackingEvent $event ): array { $event = $this->setAllGlobalEventProperties( $event ); return $event->getProperties(); } - public function trackPageview($event = '') { + /** + * @return array + */ + private function collectPageProperties(): array { + + $collected = [ + 'page_url' => $this->getCurrentUrl(), + 'HTTP_REFERER' => (string) $this->getServerParam( 'HTTP_REFERER' ), + ]; + + foreach ( $this->page_state as $k => $v ) { + + if ( ! preg_match( '/^cv[0-9]+$/', $k ) ) { + + $collected[ $k ] = $v; + } + } + + return $collected; + } + + /** + * Properties read from the state stores, as the JS tracker's + * collectStateProperties(). + * + * @return array + */ + private function collectStateProperties(): array { + + if ( ! $this->stateInit ) { + + return []; + } + + $s = $this->sessionStore(); + + $map = [ + [ 'v', 'vid', 'visitor_id' ], + [ 'v', 'nps', 'nps' ], + [ $s, 'sid', 'session_id' ], + [ $s, 'referer', 'session_referer' ], + [ $s, 'landing_url', 'landing_url' ], + [ $s, 'prior_session_id', 'prior_session_id' ], + [ $s, 'is_new_visitor', 'is_new_visitor' ], + [ $s, 'psts', 'psts' ], + [ $s, 'sts', 'sts' ], + ]; + + $collected = []; + + foreach ( $map as [ $store, $key, $name ] ) { + + $value = $this->state->get( $store, $key ); + + if ( $value !== null && $value !== '' ) { + + $collected[ $name ] = $value; + } + } + + // present as '' on a visitor's first request + $prior_last_req = $this->state->get( $s, 'prior_last_req' ); + + if ( $prior_last_req !== null ) { + + $collected['last_req'] = $prior_last_req; + } + + $attribs = $this->getCampaignState(); + + if ( $attribs ) { + + $collected['attribs'] = json_encode( $attribs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + } + + $fsts = $this->state->get( 'v', 'fsts' ); + + if ( $fsts ) { + + $collected['fsts'] = $fsts; + } + + $user_name = $this->state->get( 'v', 'user_name' ); + + if ( $user_name ) { + + $collected['user_name'] = $user_name; + } + + return $collected; + } + + /** + * Custom variables from every scope; the narrowest wins. + * + * @return array + */ + private function collectCustomVars(): array { + + $collected = []; + $max = (int) $this->getSetting( 'maxCustomVars' ); + + for ( $slot = 1; $slot <= $max; $slot++ ) { + + $value = $this->getCustomVar( $slot ); + + if ( $value ) { + + $collected[ 'cv' . $slot ] = $value; + } + } + + return $collected; + } + + public function trackPageview( ?TrackingEvent $event = null ): bool { - if ($event) { - $event->setEventType('base.page_request'); + if ( $event ) { + + $event->setEventType( 'base.page_request' ); $this->pageview_event = $event; } - return $this->trackEvent($this->pageview_event); + + return $this->trackEvent( $this->pageview_event ); } - - public function trackSyndicationFeed( $feed_format = 'rss', $feed_subscription_id = '') { - + + public function trackSyndicationFeed( string $feed_format = 'rss', string $feed_subscription_id = '' ): bool { + $event = $this->makeEvent(); - - $event->setEventType('base.feed_request'); + + $event->setEventType( 'base.feed_request' ); $event->set( 'feed_format', $feed_format ); $event->set( 'feed_subscription_id', $feed_subscription_id ); - + // track - return $this->trackEvent( $event ); + return $this->trackEvent( $event ); } - public function trackAction($action_name, $action_group = '', $action_label = '', $numeric_value = 0) { + public function trackAction( string $action_name, string $action_group = '', string $action_label = '', int|float|string $numeric_value = 0 ): bool { $event = $this->makeEvent(); - $event->setEventType('track.action'); - $event->set('action_group', $action_group); - $event->set('action_name', $action_name); - $event->set('action_label', $action_label); - $event->set('numeric_value', $numeric_value); - $event->set('site_id', $this->getSiteId()); - return $this->trackEvent($event); + $event->setEventType( 'track.action' ); + $event->set( 'action_group', $action_group ); + $event->set( 'action_name', $action_name ); + $event->set( 'action_label', $action_label ); + $event->set( 'numeric_value', $numeric_value ); + $event->set( 'site_id', $this->getSiteId() ); + return $this->trackEvent( $event ); } /** * Creates a ecommerce Transaction event * - * Creates a parent commerce.transaction event + * Creates a parent commerce.transaction event. $country, $state and $city + * are the billing address, sent as ct_country / ct_state / ct_city. + * Pass $session_id to attribute a delayed transaction to the session that + * placed the order; it is then tracked without the current request's state. */ public function addTransaction( - $order_id, - $order_source = '', - $total = 0, - $tax = 0, - $shipping = 0, - $gateway = '', - $country = '', - $state = '', - $city = '', - $page_url = '', - $session_id = '' - ) { + string|int $order_id, + string $order_source = '', + int|float|string $total = 0, + int|float|string $tax = 0, + int|float|string $shipping = 0, + string $gateway = '', + string $country = '', + string $state = '', + string $city = '', + string $page_url = '', + string|int $session_id = '' + ): void { $this->commerce_event = $this->makeEvent(); $this->commerce_event->setEventType( 'ecommerce.transaction' ); @@ -514,14 +707,18 @@ public function addTransaction( $this->commerce_event->set( 'ct_tax', $tax ); $this->commerce_event->set( 'ct_shipping', $shipping ); $this->commerce_event->set( 'ct_gateway', $gateway ); - $this->commerce_event->set( 'page_url', $page_url ); - $this->commerce_event->set( 'ct_line_items', array() ); - $this->commerce_event->set( 'country', $country ); - $this->commerce_event->set( 'state', $state ); - $this->commerce_event->set( 'city', $city ); - + $this->commerce_event->set( 'ct_line_items', [] ); + $this->commerce_event->set( 'ct_country', $country ); + $this->commerce_event->set( 'ct_state', $state ); + $this->commerce_event->set( 'ct_city', $city ); + + if ( $page_url ) { + + $this->commerce_event->set( 'page_url', $page_url ); + } + if ( $session_id ) { - + $this->commerce_event->set( 'original_session_id', $session_id ); } } @@ -531,22 +728,22 @@ public function addTransaction( * * Creates and a commerce.line_item event and adds it to the parent transaction event */ - public function addTransactionLineItem($order_id, $sku = '', $product_name = '', $category = '', $unit_price = 0, $quantity = 0) { + public function addTransactionLineItem( string|int $order_id, string|int $sku = '', string $product_name = '', string $category = '', int|float|string $unit_price = 0, int|float|string $quantity = 0 ): void { if ( empty( $this->commerce_event ) ) { - $this->addTransaction('none set'); - } - $li = array(); - $li['li_order_id'] = $order_id ; - $li['li_sku'] = $sku ; - $li['li_product_name'] = $product_name ; - $li['li_category'] = $category ; - $li['li_unit_price'] = $unit_price ; - $li['li_quantity'] = $quantity ; + $this->addTransaction( 'none set' ); + } $items = $this->commerce_event->get( 'ct_line_items' ); - $items[] = $li; + $items[] = [ + 'li_order_id' => $order_id, + 'li_sku' => $sku, + 'li_product_name' => $product_name, + 'li_category' => $category, + 'li_unit_price' => $unit_price, + 'li_quantity' => $quantity, + ]; $this->commerce_event->set( 'ct_line_items', $items ); } @@ -555,361 +752,287 @@ public function addTransactionLineItem($order_id, $sku = '', $product_name = '', * * Tracks a parent transaction event by sending it to the event queue */ - public function trackTransaction() { - - if ( ! empty( $this->commerce_event ) ) { - - if ( $this->commerce_event->get( 'original_session_id' ) ) { - - $this->trackStatelessEvent( $this->commerce_event ); - - } else { - - $this->trackEvent( $this->commerce_event ); - } - $this->commerce_event = ''; + public function trackTransaction(): bool { + + if ( empty( $this->commerce_event ) ) { + + return false; + } + + $event = $this->commerce_event; + $this->commerce_event = null; + + if ( $event->get( 'original_session_id' ) ) { + + return $this->trackStatelessEvent( $event ); } + + return $this->trackEvent( $event ); } - public function createSiteId($value) { + public function createSiteId( string $value ): string { - return md5($value); + return md5( $value ); } - public function setCampaignNameKey( $key ) { + private function setCampaignKey( string $name, string $key ): void { $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'campaign' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $campaign_params[ $name ] = $key; + $this->setSetting( 'campaign_params', $campaign_params ); } - public function setCampaignMediumKey( $key ) { + public function setCampaignNameKey( string $key ): void { - $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'medium' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $this->setCampaignKey( 'campaign', $key ); } - public function setCampaignSourceKey( $key ) { + public function setCampaignMediumKey( string $key ): void { - $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'source' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $this->setCampaignKey( 'medium', $key ); } - public function setCampaignSearchTermsKey( $key ) { + public function setCampaignSourceKey( string $key ): void { - $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'search_terms' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $this->setCampaignKey( 'source', $key ); } - public function setCampaignAdKey( $key ) { + public function setCampaignSearchTermsKey( string $key ): void { - $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'ad' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $this->setCampaignKey( 'search_terms', $key ); } - public function setCampaignAdTypeKey( $key ) { + public function setCampaignAdKey( string $key ): void { - $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_params[ 'ad_type' ] = $key; - $this->setSetting('campaign_params', $campaign_params); + $this->setCampaignKey( 'ad', $key ); } - public function setUserName( $value ) { + public function setCampaignAdTypeKey( string $key ): void { - $this->setGlobalEventProperty( 'user_name', $value ); + $this->setCampaignKey( 'ad_type', $key ); } - - public function setUserEmail( $value ) { + + /** + * Stored on the visitor, so later requests carry it too. + */ + public function setUserName( string $value ): void { + + $this->state->set( 'v', 'user_name', $value ); + } + + public function setUserEmail( string $value ): void { $this->setGlobalEventProperty( 'user_email', $value ); } - function getCampaignProperties( $event ) { + /** + * Campaign parameters on the event or the page URL, under their + * attribution-history keys (md, cn, sr, tr, ad, at). + * + * @return array + */ + public function getCampaignProperties( TrackingEvent $event ): array { $campaign_params = $this->getSetting( 'campaign_params' ); - $campaign_properties = array(); - $campaign_state = array(); - - foreach ( $campaign_params as $k => $param ) { + $query = $this->getPageQueryParams(); + $campaign_properties = []; + + foreach ( self::CAMPAIGN_KEYS as $name => $short ) { + + if ( empty( $campaign_params[ $name ] ) ) { + + continue; + } + + $param = strtolower( $campaign_params[ $name ] ); + //look for property on the event $property = $event->get( $param ); - // look for property on the request scope. - if ( ! $property ) { - $property = $this->getRequestParam( $param ); + // then on the page URL + if ( ! $property && isset( $query[ $param ] ) && is_string( $query[ $param ] ) ) { + + $property = $query[ $param ]; } - if ( $property ) { - $campaign_properties[ $k ] = $property; + + if ( $property !== false && $property !== '' ) { + + $campaign_properties[ $short ] = (string) $property; } } // backfill values for incomplete param combos + if ( isset( $campaign_properties['at'] ) && ! isset( $campaign_properties['ad'] ) ) { - if (array_key_exists('ad_type', $campaign_properties) && !array_key_exists('ad', $campaign_properties)) { $campaign_properties['ad'] = '(not set)'; } - if (array_key_exists('ad', $campaign_properties) && !array_key_exists('ad_type', $campaign_properties)) { - $campaign_properties['ad_type'] = '(not set)'; - } + if ( isset( $campaign_properties['ad'] ) && ! isset( $campaign_properties['at'] ) ) { - if (!empty($campaign_properties)) { - //$campaign_properties['ts'] = $event->get('timestamp'); + $campaign_properties['at'] = '(not set)'; } - sdk::debug('campaign properties: '. print_r($campaign_properties, true)); - return $campaign_properties; } - private function setCampaignSessionState( $properties ) { + /** + * Query parameters of the page being tracked, keys lower-cased as the + * JS tracker's parseUrlParams() does. + * + * @return array + */ + private function getPageQueryParams(): array { - $campaign_params = $this->getSetting( 'campaign_params' ); - foreach ($campaign_params as $k => $v) { + $url = $this->getGlobalEventProperty( 'page_url' ) ?: $this->getCurrentUrl(); + $query = parse_url( (string) $url, PHP_URL_QUERY ); - if (array_key_exists( $k, $properties ) ) { + if ( ! $query ) { - $this->state->set( 's', $k, $properties[$k] ); - } + return []; } - } - - function directAttributionModel( &$campaign_properties ) { - - // add new campaign info to existing campaign cookie. - if ( $campaign_properties ) { - - // get prior campaing touches from c cookie - $campaign_state = $this->getCampaignState(); - // add new campaign into state array - $campaign_state[] = (object) $campaign_properties; + parse_str( $query, $params ); - // if more than x slice the first one off to make room - $count = count( $campaign_state ); - $max = $this->getSetting( 'max_prior_campaigns' ); - if ($count > $max ) { - array_shift( $campaign_state ); - } + return array_change_key_case( $params, CASE_LOWER ); + } - // reset state - $this->setCampaignCookie($campaign_state); + /** + * @param array $campaign_properties + */ + private function directAttributionModel( array $campaign_properties ): void { - // set flag - $this->isTrafficAttributed = true; + if ( ! $campaign_properties ) { - // persist state to session store - $this->setCampaignSessionState( $campaign_properties ); + return; } - } - - function originalAttributionModel( &$campaign_properties ) { - $campaign_state = $this->getCampaignState(); - // orignal touch was set previously. jus use that. - if (!empty($campaign_state)) { - // do nothing - // set the attributes from the first campaign touch - $campaign_properties = $campaign_state[0]; - $this->isTrafficAttributed = true; - - // no orginal touch, set one if it's a new campaign touch - } else { + $campaign_state[] = $campaign_properties; - if (!empty($campaign_properties)) { - // add timestamp - //$campaign_properties['ts'] = $event->get('timestamp'); - sdk::debug('Setting original Campaign attrbution.'); - $campaign_state[] = $campaign_properties; - // set cookie - $this->setCampaignCookie($campaign_state); - $this->isTrafficAttributed = true; - } + // drop the oldest touches to make room + while ( count( $campaign_state ) > (int) $this->getSetting( 'max_prior_campaigns' ) ) { + + array_shift( $campaign_state ); } - // persist state to session store - $this->setCampaignSessionState( $campaign_properties ); + $this->setCampaignCookie( $campaign_state ); } - function getCampaignState() { + /** + * @param array $campaign_properties + */ + private function originalAttributionModel( array $campaign_properties ): void { - $campaign_state = $this->state->get( 'c', 'attribs' ); - if ( ! $campaign_state ) { + // the original touch was recorded previously; keep it + if ( $this->getCampaignState() || ! $campaign_properties ) { - $campaign_state = array(); + return; } - return $campaign_state; + $this->setCampaignCookie( [ $campaign_properties ] ); } - function setTrafficAttribution( &$event ) { + /** + * Prior campaign touches from the owa_c cookie. + * + * Touches written by SDK 1.0.x used long key names and a timestamp; they + * are read under the current keys. + * + * @return list> + */ + public function getCampaignState(): array { - // if not then look for individual campaign params on the request. - // this happens when the client is php and the params are on the url - $campaign_properties = $this->getCampaignProperties( $event ); - if ( $campaign_properties ) { - $campaign_properties['ts'] = $event->get('timestamp'); - } + $campaign_state = $this->state->get( 'c', 'attribs' ); - // choose attribution model. - $model = $this->getSetting( 'trafficAttributionMode' ); - switch ( $model ) { + if ( ! is_array( $campaign_state ) ) { - case 'direct': - sdk::debug( 'Applying "Direct" Traffic Attribution Model' ); - $this->directAttributionModel( $campaign_properties ); - break; - case 'original': - sdk::debug( 'Applying "Original" Traffic Attribution Model' ); - $this->originalAttributionModel( $campaign_properties ); - break; - default: - sdk::debug( 'Applying Default (Direct) Traffic Attribution Model' ); - $this->directAttributionModel( $campaign_properties ); + return []; } - // if one of the attribution methods attributes the traffic them - // set attribution properties on the event object - if ( $this->isTrafficAttributed ) { - - sdk::debug( 'Attributing Traffic to: %s', print_r($campaign_properties, true ) ); - - } else { - // infer the attribution from the referer - // if the request is the start of a new session - if ( $this->getGlobalEventProperty( 'is_new_session' ) ) { - sdk::debug( 'Inferring traffic attribution.' ); - $this->inferTrafficAttribution(); - } - } + $touches = []; - // apply traffic attribution realted properties to events - // all properties should be set in the state store by this point. - $campaign_params = $this->getSetting('campaign_params'); - foreach( $campaign_params as $k => $v ) { + foreach ( $campaign_state as $touch ) { - $value = $this->state->get( 's', $k ); + if ( ! is_array( $touch ) ) { - if ( $value ) { - $this->setGlobalEventProperty( $k, $value ); + continue; } - } - // set sesion referer - $session_referer = $this->state->get('s', 'referer'); - if ( $session_referer ) { + $normalized = []; - $this->setGlobalEventProperty( 'session_referer', $session_referer ); - } + foreach ( $touch as $k => $v ) { - // set campaign touches - $campaign_state = $this->state->get('c', 'attribs'); - if ( $campaign_state ) { + if ( $k === 'ts' ) { - $this->setGlobalEventProperty( 'attribs', json_encode( $campaign_state )); - } - } - - private function inferTrafficAttribution() { - - $ref = $this->getServerParam('HTTP_REFERER'); - $medium = 'direct'; - $source = '(none)'; - $search_terms = '(none)'; - $session_referer = '(none)'; - - if ( $ref ) { - $uri = $this->parse_url( $ref ); - - // check for external referer - $host = $this->getServerParam('HTTP_HOST'); - if ( $host != $uri['host'] ) { - - $medium = 'referral'; - $source = $this->stripWwwFromDomain( $uri['host'] ); - $engine = $this->isRefererSearchEngine( $uri ); - $session_referer = $ref; - if ( $engine ) { - $medium = 'organic-search'; - $search_terms = $engine['t']; + continue; } + + $normalized[ self::CAMPAIGN_KEYS[ $k ] ?? $k ] = $v; } + + $touches[] = $normalized; } - $this->state->set('s', 'referer', $session_referer); - $this->state->set('s', 'medium', $medium); - $this->state->set('s', 'source', $source); - $this->state->set('s', 'search_terms', $search_terms); + return $touches; } - - private function isRefererSearchEngine( $uri ) { + private function setTrafficAttribution( TrackingEvent $event ): void { - if ( !isset( $uri['host'] ) ) { - return null; - } - - $host = $uri['host']; + $campaign_properties = $this->getCampaignProperties( $event ); - $searchEngine = []; + // choose attribution model. + if ( $this->getSetting( 'trafficAttributionMode' ) === 'original' ) { - foreach ( $this->organicSearchEngines as $engine ) { - $domain = $engine['d']; + $this->originalAttributionModel( $campaign_properties ); - if (strpos($host, $domain) === false) { - continue; - } - - $query_param = $engine['q']; - $term = ''; + } else { - if (isset($uri['query_params'][$query_param])) { - $term = $uri['query_params'][$query_param]; - } + $this->directAttributionModel( $campaign_properties ); + } - sdk::debug( 'Found search engine: %s with query param %s:, query term: %s', $domain, $query_param, $term); + // the server parses campaign tags out of the landing URL + if ( $this->isNewSessionFlag ) { - $searchEngine = ['d' => $domain, 'q' => $query_param, 't' => $term]; - break; + $this->state->set( $this->sessionStore(), 'referer', (string) $this->getServerParam( 'HTTP_REFERER' ) ); + $this->state->set( $this->sessionStore(), 'landing_url', $this->getGlobalEventProperty( 'page_url' ) ?: $this->getCurrentUrl() ); } - - return $searchEngine; } - function setCampaignCookie($values) { - // reset state - $this->state->set('c', 'attribs', $values); + /** + * @param list> $values + */ + public function setCampaignCookie( array $values ): void { + + $this->state->set( 'c', 'attribs', $values ); } // sets cookies domain - function setCookieDomain($domain) { + public function setCookieDomain( string $domain ): void { + + if ( $domain !== '' ) { - if (!empty($domain)) { - - $this->setCookieDomainName($domain); + $domain = self::resolveCookieDomain( $domain ); + $this->setSetting( 'cookie_domain', $domain ); + $this->state->setSetting( 'cookie_domain', $domain ); } } /** * Set a custom variable * - * @param slot int the identifying number for the custom variable. 1-5. - * @param name string the key of the custom variable. - * @param value string the value of the varible - * @param scope string the scope of the variable. can be page, session, or visitor + * @param int $slot the identifying number for the custom variable. 1-5. + * @param string $name the key of the custom variable. + * @param string $value the value of the varible + * @param string $scope page (the default), session, or visitor */ - public function setCustomVar( $slot, $name, $value, $scope = '' ) { + public function setCustomVar( int $slot, string $name, string $value, string $scope = 'page' ): void { $cv_param_name = 'cv' . $slot; $cv_param_value = $name . '=' . $value; if ( strlen( $cv_param_value ) > 65 ) { - sdk::debug('Custom variable name + value is too large. Must be less than 64 characters.'); + + sdk::debug( 'Custom variable name + value is too large. Must be less than 64 characters.' ); return; } @@ -917,329 +1040,280 @@ public function setCustomVar( $slot, $name, $value, $scope = '' ) { case 'session': - // store in session cookie - $this->state->set( 'b', $cv_param_name, $cv_param_value ); - sdk::debug( 'just set custom var on session.' ); + if ( ! $this->getSiteId() ) { + + sdk::debug( 'Set the site id before setting a session-scoped custom variable.' ); + return; + } + + $this->initSessionStore(); + $this->state->set( $this->sessionStore(), $cv_param_name, $cv_param_value ); + unset( $this->page_state[ $cv_param_name ] ); + $this->state->clear( 'b', $cv_param_name ); break; case 'visitor': - // store in visitor cookie $this->state->set( 'v', $cv_param_name, $cv_param_value ); - // remove slot from session level cookie + unset( $this->page_state[ $cv_param_name ] ); + + if ( $this->getSiteId() ) { + + $this->initSessionStore(); + $this->state->clear( $this->sessionStore(), $cv_param_name ); + } + $this->state->clear( 'b', $cv_param_name ); break; - } - $this->setGlobalEventProperty( $cv_param_name, $cv_param_value ); + default: + + $this->page_state[ $cv_param_name ] = $cv_param_value; + } } - public function getCustomVar( $slot ) { + public function getCustomVar( int $slot ): string { $cv_param_name = 'cv' . $slot; - $cv = ''; + // check request/page level - $cv = $this->getGlobalEventProperty( $cv_param_name ); + $cv = $this->getGlobalEventProperty( $cv_param_name ) ?: ( $this->page_state[ $cv_param_name ] ?? null ); + //check session store - if ( ! $cv ) { - $cv = $this->state->get( 'b', $cv_param_name ); + if ( ! $cv && $this->getSiteId() ) { + + $cv = $this->state->get( $this->sessionStore(), $cv_param_name ); } + // check visitor store if ( ! $cv ) { + $cv = $this->state->get( 'v', $cv_param_name ); } - return $cv; - + return (string) $cv; } - public function deleteCustomVar( $slot ) { + public function deleteCustomVar( int $slot ): void { $cv_param_name = 'cv' . $slot; - //clear session level + unset( $this->page_state[ $cv_param_name ] ); + + if ( $this->getSiteId() ) { + + $this->state->clear( $this->sessionStore(), $cv_param_name ); + } + $this->state->clear( 'b', $cv_param_name ); //clear visitor level $this->state->clear( 'v', $cv_param_name ); // clear page level $this->deleteGlobalEventProperty( $cv_param_name ); - sdk::debug("Deleting custom variable named $cv_param_name in slot $slot."); + sdk::debug( "Deleting custom variable named $cv_param_name in slot $slot." ); } - private function resetSessionState() { - - $last_req = $this->state->get( 's', 'last_req' ); - $this->state->clear( 's' ); - $this->state->set( 's', 'last_req', $last_req); + /** + * @deprecated OWA infers organic search on the server; the tracker no longer does. + */ + public function addOrganicSearchEngine( string $domain, string $query_param, bool $prepend = false ): void { } - public function addOrganicSearchEngine( $domain, $query_param, $prepend = '' ) { + /** + * Use this function to parse out the url and query array element from + * a url. + * + * @return array + */ + public static function parse_url( string $url ): array { - $engine = array('d' => $domain, 'q' => $query_param); - if ( $prepend) { - array_unshift($this->organicSearchEngines, $engine ); - } else { - $this->organicSearchEngines[] = $engine; + $parts = parse_url( $url ); + + if ( ! is_array( $parts ) ) { + + return []; + } + + if ( isset( $parts['query'] ) ) { + + parse_str( html_entity_decode( $parts['query'] ), $params ); + $parts['query_params'] = $params; } + + return $parts; } - - private function registerStateStore( $name, $expiration, $length = '', $format = '', $type = 'cookie', $cdh_required = '' ) { - - return $this->state->registerStore( $name, $expiration, $length, $format, $type, $cdh_required ); + public function getServerParam( string $key ): mixed { + + return $_SERVER[ $key ] ?? null; } - + /** - * sets and checks the cookie domain setting - * - * @param unknown_type $domain + * Assembles the current URL from request params */ - private function setCookieDomainName ($domain = '') { + private function getCurrentUrl(): string { + + $host = $this->getServerParam( 'HTTP_HOST' ); - $explicit = false; + if ( ! $host ) { - if ( ! $domain ) { - $domain = $this->getServerParam('HTTP_HOST'); - $explicit = true; + $name = $this->getServerParam( 'SERVER_NAME' ); + + if ( ! $name ) { + + return ''; + } + + $port = (int) $this->getServerParam( 'SERVER_PORT' ); + $host = $name . ( $port && $port !== 80 && $port !== 443 ? ':' . $port : '' ); } - // strip port, add leading period etc. - $domain = $this->sanitizeCookieDomain($domain); + $https = strtolower( (string) $this->getServerParam( 'HTTPS' ) ); + $secure = ( $https && $https !== 'off' ) + || (int) $this->getServerParam( 'SERVER_PORT' ) === 443 + || strtolower( (string) $this->getServerParam( 'HTTP_X_FORWARDED_PROTO' ) ) === 'https'; + + return ( $secure ? 'https' : 'http' ) . '://' . $host . ( $this->getServerParam( 'REQUEST_URI' ) ?? '/' ); + } + + /** + * Headers that let OWA attribute a request to the visitor rather than to + * this server: OWA reads the IP, user agent and language from the request, + * never from event properties. + * + * @return array + */ + private function visitorHeaders( bool $from_request ): array { - // Set the cookie domain only if the domain name is a Fully qualified domain name (FQDN) - // i.e. avoid attempts to set cookie domain for e.g. "localhost" as that is not valid + $headers = []; - //check for two dots in the domain name - $twodots = substr_count($domain, '.'); + $ua = $this->user_agent ?? ( $from_request ? $this->getServerParam( 'HTTP_USER_AGENT' ) : null ); + $headers['User-Agent'] = $ua ? (string) $ua : self::USER_AGENT; - if ( $twodots >= 2 ) { + $ip = $this->ip_address; - // unless www.domain.com is passed explicitly - // strip the www from the domain. - if ( ! $explicit ) { - $part = substr( $domain, 0, 5 ); - if ($part === '.www.') { - //strip .www. - $domain = substr( $domain, 5); - // add back the leading period - $domain = '.'.$domain; - } - } + if ( $ip === null && $from_request ) { - $this->setSetting( 'cookie_domain', $domain ); - sdk::debug("Setting cookie domain to $domain"); - } else { - sdk::debug("Not setting cookie domain as $domain is not a FQDN."); - } - } - - private function sanitizeCookieDomain($domain) { - - // Remove port information. - $port = strpos( $domain, ':' ); - if ( $port ) { - $domain = substr( $domain, 0, $port ); - } + $chain = array_filter( [ + trim( (string) $this->getServerParam( 'HTTP_X_FORWARDED_FOR' ) ), + trim( (string) $this->getServerParam( 'REMOTE_ADDR' ) ), + ] ); - // check for leading period, add if missing - $period = substr( $domain, 0, 1); - if ( $period != '.' ) { - $domain = '.'.$domain; + $ip = $chain ? implode( ', ', $chain ) : null; } - return $domain; - } - - private function stripWWWFromDomain($domain) { + if ( $ip ) { - $done = false; - $part = substr( $domain, 0, 5 ); - if ($part === '.www.') { - //strip .www. - $domain = substr( $domain, 5); - // add back the leading period - $domain = '.'.$domain; - $done = true; + $headers['X-Forwarded-For'] = $ip; } - if ( ! $done ) { - $part = substr( $domain, 0, 4 ); - if ($part === 'www.') { - //strip .www. - $domain = substr( $domain, 4); - $done = true; - } + $language = $from_request ? $this->getServerParam( 'HTTP_ACCEPT_LANGUAGE' ) : null; + if ( $language ) { + + $headers['Accept-Language'] = (string) $language; } - return $domain; + return $headers; } /** - * Use this function to parse out the url and query array element from - * a url. + * Sends an event to log.php. + * + * GET normally; a POST form body when the URL would exceed + * GET_CHARACTER_LIMIT. log.php answers before processing the event (a GIF + * for GET, a redirect for POST), so acceptance means the request was + * received, not that the event was stored. */ - public static function parse_url( $url ) { + private function logEvent( TrackingEvent $event, bool $from_request ): bool { - $url = parse_url($url); + $params = $event->getProperties(); + $params['event_type'] = $event->getEventType(); - if ( isset( $url['query'] ) ) { - $var = $url['query']; + $params = $this->ns( self::wireValues( $params ) ); + $query = http_build_query( $params, '', '&', PHP_QUERY_RFC3986 ); - $var = html_entity_decode($var); - $var = explode('&', $var); - $arr = array(); + $url = $this->getSetting( 'instance_url' ) . self::LOG_ENDPOINT; - foreach( $var as $val ) { + $headers = $this->visitorHeaders( $from_request ) + [ + 'Accept' => 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + ]; - if ( strpos($val, '=') ) { - $x = explode('=', $val); + $this->last_errors = []; - if ( isset( $x[1] ) ) { - $arr[$x[0]] = urldecode($x[1]); - } - } else { - $arr[$val] = ''; - } - } - unset($val, $x, $var); + if ( strlen( $url ) + 1 + strlen( $query ) > self::GET_CHARACTER_LIMIT ) { + + $method = 'POST'; + $headers['Content-Type'] = 'application/x-www-form-urlencoded'; + $res = $this->send( $method, $url, $headers, $query ); - $url['query_params'] = $arr; + } else { + $method = 'GET'; + $res = $this->send( $method, $url . '?' . $query, $headers ); } - return $url; - } - - public function getServerParam( $key ) { - - if ( isset( $_SERVER ) && array_key_exists( $key, $_SERVER ) ) { - - return $_SERVER[ $key ]; - } + $accepted = $res->status >= 200 && $res->status < 400; + + if ( ! $accepted && $res->status !== 0 ) { + + $this->last_errors = [ "HTTP {$res->status}" ]; + $this->logFailure( $method, $url, $res ); + } + + return $accepted; } - /** - * Assembles the current URL from request params + /** + * Wire encoding as the JS tracker sends it: true as 'true', false and null + * left out. * - * @return string + * @param array $params + * @return array */ - private function getCurrentUrl() { - - $url = 'http'; - - // check for https - if( isset( $_SERVER['HTTPS'] ) && strtolower( $_SERVER['HTTPS'] ) == 'on') { - $url.= 's'; - } elseif ( isset( $_SERVER['SERVER_PORT'] ) && $_SERVER['SERVER_PORT'] == 443 ) { - $url.= 's'; - } elseif ( isset( $_SERVER['HTTP_ORIGIN'] ) && substr( $_SERVER['HTTP_ORIGIN'], 0, 5 ) === 'https' ) { - $url.= 's'; - } elseif ( isset( $_SERVER['HTTP_REFERER'] ) && substr( $_SERVER['HTTP_REFERER'], 0, 5 ) === 'https' ) { - $url.= 's'; - } + private static function wireValues( array $params ): array { - if ( isset( $_SERVER['HTTP_HOST'] ) ) { - // contains port number - $domain = $_SERVER['HTTP_HOST']; - } else { - // does not contain port number. - $domain = $_SERVER['SERVER_NAME']; - if( $_SERVER['SERVER_PORT'] != 80 ) { - $domain .= ':' . $_SERVER['SERVER_PORT']; + $out = []; + + foreach ( $params as $k => $v ) { + + if ( $v === null || $v === false ) { + + continue; } - } - $url .= '://'.$domain; - - $url .= $_SERVER['REQUEST_URI']; - - return $url; - } - - private function logEvent($event_type, $event) { - - sdk::debug('implement logEvent method'); - - $conf = [ - - 'base_uri' => $this->getSetting('instance_url') - ]; - - $http = $this->getHttpClient( $conf ); - $params = $event->getProperties(); - $params['event_type'] = $event->getEventType(); - - $params = $this->applyNamespaceToKeys( $params ); - - try { - $res = $http->request( - 'GET', 'log.php', - [ - 'query' => $params, - 'headers' => [ - 'User-Agent' => 'OWA SDK Client', - 'accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' - ] - ] - ); - } catch( \GuzzleHttp\Exception\RequestException | \GuzzleHttp\Exception\ConnectException | \GuzzleHttp\Exception\ClientException $e ) { - - $r = $e->getRequest(); - $res = null; - - error_log( print_r( $r, true ) ); - - if ( $e->hasResponse() ) { - - $res = $e->getResponse(); - - error_log( print_r( $res, true ) ); - } - - if ( $this->getSetting( 'debug' ) ) { - - print_r($r); - print_r($res); + if ( $v === true ) { + + $v = 'true'; + + } elseif ( is_array( $v ) ) { + + $v = self::wireValues( $v ); } + + $out[ $k ] = $v; } - return $res; - } - - private function applyNameSpaceToKeys( $params ) { - - $nparams = []; - $ns = $this->getSetting('ns'); - foreach ( $params as $k => $v ) { - - $nparams[ $ns.$k ] = $v; - } - - return $nparams; - } - - private function getRequestParam($name) { - - if (array_key_exists($name, $_GET)) { - - return htmlentities( strip_tags( $_GET[$name] ) ); - } + + return $out; } - - private function makeEvent() { - - return new TrackingEvent; + + private function makeEvent(): TrackingEvent { + + return new TrackingEvent; } - - function setSiteId( $site_id ) { - + + public function setSiteId( string $site_id ): void { + + if ( $this->stateInit && $site_id !== $this->site_id ) { + + sdk::debug( 'Site id changed after state was initialized; session state stays with the first site.' ); + } + $this->site_id = $site_id; } - - function getSiteId() { - + + public function getSiteId(): string { + return $this->site_id; } } - -?> \ No newline at end of file diff --git a/src/Tracker/TrackingEvent.php b/src/Tracker/TrackingEvent.php index dd61f08..d374cda 100644 --- a/src/Tracker/TrackingEvent.php +++ b/src/Tracker/TrackingEvent.php @@ -14,153 +14,122 @@ // See the License for the specific language governing permissions and // limitations under the License. // -// $Id$ -// /** * Tracking Event Class - * + * * @author Peter Adams * */ - class TrackingEvent { /** * Event Properties * - * @var array - */ - var $properties = array(); - - /** - * State - * - * @var string + * @var array */ - //var $state; + public array $properties = []; - var $eventType; + public string $eventType = ''; /** * Event guid - * - * @var string */ - var $guid; + public string $guid; /** * Creation Timestamp in UNIX EPOC UTC - * - * @var int */ - var $timestamp; + public int $timestamp; - /** - * Constructor - * @access public - */ - function __construct() { + public function __construct() { - // Set GUID for event - $this->guid = $this->set_guid(); + $this->guid = self::generateRandomGuid(); $this->timestamp = time(); - //needed? - $this->set('guid', $this->guid); - $this->set('timestamp', $this->timestamp ); + $this->set( 'guid', $this->guid ); + $this->set( 'timestamp', $this->timestamp ); } - function getTimestamp() { + public function getTimestamp(): int { return $this->timestamp; } - function set($name, $value) { + public function set( string $name, mixed $value ): void { - $this->properties[$name] = $value; + $this->properties[ $name ] = $value; } - function get($name) { + /** + * The property's value, or false when it is not set. + */ + public function get( string $name ): mixed { - if(array_key_exists($name, $this->properties)) { - //print_r($this->properties[$name]); - return $this->properties[$name]; - } else { - return false; - } + return array_key_exists( $name, $this->properties ) ? $this->properties[ $name ] : false; + } + + public function isSet( string $name ): bool { + + return array_key_exists( $name, $this->properties ); } /** - * Adds new properties to the eventt without overwriting values + * Adds new properties to the event without overwriting values * for properties that are already set. * - * @param array $properties + * @param array $properties */ - function setNewProperties( $properties = array() ) { - - $this->properties = array_merge($properties, $this->properties); + public function setNewProperties( array $properties = [] ): void { + $this->properties = array_merge( $properties, $this->properties ); } /** - * Create guid from process id - * - * @return integer - * @access private + * @return array */ - function set_guid() { + public function getProperties(): array { - return $this->generateRandomUid(); + return $this->properties; } - - private function generateRandomUid($seed='') { - $time = (string) time(); - $random = $this->zeroFill( mt_rand( 0, 999999 ), 6 ); - - $server = substr( getmypid(), 0, 3); - + public function getEventType(): string { - return $time.$random.$server; - } - - private function zeroFill( $number, $char_length ) { + if ( $this->eventType !== '' ) { - return str_pad( (int) $number, $char_length, "0", STR_PAD_LEFT ); - } + return $this->eventType; + } - function getProperties() { + $type = $this->get( 'event_type' ); - return $this->properties; + return $type ? (string) $type : 'unknown_event_type'; } - function getEventType() { - - if (!empty($this->eventType)) { - return $this->eventType; - } elseif ($this->get('event_type')) { - return $this->get('event_type'); - } else { - - return 'unknown_event_type'; - } - } + public function setEventType( string $value ): void { - function setEventType($value) { $this->eventType = $value; } - function getGuid() { + public function getGuid(): string { return $this->guid; } - - // move this to the tracker - function getSiteSpecificGuid($site_id) { - return $this->generateRandomUid( $site_id ); + /** + * @deprecated ids take no salt; use generateRandomGuid(). + */ + public function getSiteSpecificGuid( mixed $site_id = '' ): string { + + return self::generateRandomGuid(); } -} + /** + * A numeric id: 10 digits of unix seconds followed by 9 random digits. + * + * The same construction as Util.generateRandomGuid() in the JS tracker. + * It must fit a signed BIGINT, so it takes no salt. + */ + public static function generateRandomGuid(): string { -?> \ No newline at end of file + return time() . str_pad( (string) random_int( 0, 999999 ), 6, '0', STR_PAD_LEFT ) . str_pad( (string) random_int( 0, 999 ), 3, '0', STR_PAD_LEFT ); + } +} diff --git a/src/data/manifest.php b/src/data/manifest.php index f6d2dfa..93cf4f9 100644 --- a/src/data/manifest.php +++ b/src/data/manifest.php @@ -1,10 +1,8 @@ ['namespace' => 'Tracker'], - 'sites' => ['namespace' => 'Sites'] - -]; -?> \ No newline at end of file + 'tracker' => ['namespace' => 'Tracker'], + 'sites' => ['namespace' => 'Sites'] + +]; diff --git a/src/sdk.php b/src/sdk.php index 6e6a1f3..c71a878 100644 --- a/src/sdk.php +++ b/src/sdk.php @@ -4,105 +4,124 @@ * Open Web Analytics PHP SDK * */ - + namespace OwaSdk; - + +/** + * SDK entry point and client factory. + * + * `$sdk->createTracker()` and `$sdk->createSites()` resolve through the + * service manifest in data/manifest.php. + * + * @method \OwaSdk\Tracker\TrackerClient createTracker(array $config = []) + * @method \OwaSdk\Sites\SitesClient createSites(array $config = []) + */ class sdk { - - const VERSION = '1.0'; - - private $config; - - public static $inDebug; - - public function __construct( $config = [] ) { - - $this->config = $config; - - if ( $this->getSetting( 'debug' ) ) { - - ini_set('display_errors', 1); - error_reporting( -1 ); - - self::$inDebug = true; - - } - } - - public function getSetting( $name ) { - - if ( array_key_exists( $name, $this->config ) ) { - - return $this->config[ $name ]; - } - } - - public function __call($name, array $args) { - - $args = isset($args[0]) ? $args[0] : []; - - if (strpos($name, 'create') === 0) { - return $this->createClient(substr($name, 6), $args); + + const VERSION = '1.1.0'; + + private array $config; + + public static bool $inDebug = false; + + /** + * Destination for debug messages. Empty sends them to PHP's error_log. + */ + public static string $debugLog = ''; + + public function __construct( array $config = [] ) { + + $this->config = $config; + + self::$inDebug = (bool) $this->getSetting( 'debug' ); + self::$debugLog = (string) ( $this->getSetting( 'debug_log' ) ?? '' ); + } + + public function getSetting( string $name ): mixed { + + return $this->config[ $name ] ?? null; + } + + public function __call( string $name, array $args ): OwaClient { + + $config = isset( $args[0] ) && is_array( $args[0] ) ? $args[0] : []; + + if ( str_starts_with( $name, 'create' ) ) { + + return $this->createClient( substr( $name, 6 ), $config ); } - throw new Exception("Unknown method: {$name}."); + throw new \BadMethodCallException( "Unknown method: {$name}." ); } - - public function createClient( $name, array $config = [] ) { - - // Lookup service in the manifest + public function createClient( string $name, array $config = [] ): OwaClient { + + // Lookup service in the manifest $service = $this->lookupService( $name ); + + if ( ! $service ) { + + throw new \InvalidArgumentException( "Unknown service: {$name}." ); + } + $namespace = $service['namespace']; // Create the client $client = "OwaSdk\\{$namespace}\\{$namespace}Client"; - + return new $client( $this->addToConfig( $service, $config ) ); - - } - - - private function lookupService( $name ) { - - static $manifest; - - if ( empty( $manifest ) ) { - - $manifest = include __DIR__ . '/data/manifest.php'; - } - - $name = strtolower( $name ); - - if ( isset( $manifest[ $name ] ) ) { - - return $manifest[ $name ]; - } - } - - private function addToConfig( $service, $config ) { - - if ( isset( $service['namespace'] ) ) { - - $config['namespace'] = $service['namespace']; - } - - return $config + $this->config; - } - - static function debug( $msg ) { - - if ( self::$inDebug ) { - - if ( is_object( $msg ) || is_array( $msg ) ) { - - $msg = print_r( $msg, true ); - } - - error_log( $msg . "\n", 3, "./errors.log" ); - } - } + } + private function lookupService( string $name ): ?array { + + static $manifest; + + if ( empty( $manifest ) ) { + + $manifest = include __DIR__ . '/data/manifest.php'; + } + + return $manifest[ strtolower( $name ) ] ?? null; + } + + private function addToConfig( array $service, array $config ): array { + + if ( isset( $service['namespace'] ) ) { + + $config['namespace'] = $service['namespace']; + } + + return $config + $this->config; + } + + /** + * Writes a debug message when the SDK was created with 'debug' => true. + * + * Extra arguments are applied to $msg with sprintf(). + */ + public static function debug( mixed $msg, mixed ...$args ): void { + + if ( ! self::$inDebug ) { + + return; + } + + if ( is_object( $msg ) || is_array( $msg ) ) { + + $msg = print_r( $msg, true ); + + } elseif ( $args ) { + + $msg = vsprintf( (string) $msg, array_map( fn( $a ) => is_scalar( $a ) || $a === null ? $a : print_r( $a, true ), $args ) ); + } + + if ( self::$debugLog ) { + + error_log( $msg . "\n", 3, self::$debugLog ); + + } else { + + error_log( 'OWA SDK: ' . $msg ); + } + } } - -?> \ No newline at end of file diff --git a/tests/Http/TransportTest.php b/tests/Http/TransportTest.php new file mode 100644 index 0000000..a4b5225 --- /dev/null +++ b/tests/Http/TransportTest.php @@ -0,0 +1,125 @@ + [ 'file', '/dev/null', 'w' ], 2 => [ 'file', '/dev/null', 'w' ] ], $pipes ); + self::$base = "http://127.0.0.1:$port"; + + // wait for the listener + for ( $i = 0; $i < 50; $i++ ) { + + $c = @fsockopen( '127.0.0.1', $port, $errno, $errstr, 0.1 ); + + if ( $c ) { + + fclose( $c ); + return; + } + + usleep( 50000 ); + } + + self::fail( 'php -S did not start.' ); + } + + public static function tearDownAfterClass(): void { + + if ( self::$server ) { + + proc_terminate( self::$server ); + proc_close( self::$server ); + self::$server = null; + } + } + + /** + * @return array + */ + public static function transports(): array { + + $t = [ 'stream' => [ new StreamTransport() ] ]; + + if ( extension_loaded( 'curl' ) ) { + + $t['curl'] = [ new CurlTransport() ]; + } + + return $t; + } + + #[DataProvider( 'transports' )] + public function testGetSendsHeadersAndQuery( Transport $transport ): void { + + $res = $transport->send( 'GET', self::$base . '/log.php?owa_a=1&owa_b=two%20words', [ 'X-Test' => 'abc', 'User-Agent' => 'ua/1' ] ); + + $this->assertSame( 200, $res->status ); + $this->assertSame( 'yes', $res->getHeader( 'X-Echo' ) ); + + $echo = $res->json(); + + $this->assertSame( 'GET', $echo['method'] ); + $this->assertSame( '/log.php?owa_a=1&owa_b=two%20words', $echo['uri'] ); + $this->assertSame( 'abc', $echo['headers']['x-test'] ); + $this->assertSame( 'ua/1', $echo['headers']['user-agent'] ); + } + + #[DataProvider( 'transports' )] + public function testPostSendsBody( Transport $transport ): void { + + $res = $transport->send( 'POST', self::$base . '/api/x', [ 'Content-Type' => 'application/x-www-form-urlencoded' ], 'owa_domain=example.com' ); + + $echo = $res->json(); + + $this->assertSame( 'POST', $echo['method'] ); + $this->assertSame( 'owa_domain=example.com', $echo['body'] ); + } + + #[DataProvider( 'transports' )] + public function testErrorStatusIsReturnedNotThrown( Transport $transport ): void { + + $res = $transport->send( 'GET', self::$base . '/status/404' ); + + $this->assertSame( 404, $res->status ); + $this->assertFalse( $res->isSuccess() ); + $this->assertSame( [ 'status' => 'error' ], $res->json() ); + } + + #[DataProvider( 'transports' )] + public function testConnectionFailureIsStatusZero( Transport $transport ): void { + + // port 9 (discard) on loopback: nothing listens there + $res = $transport->send( 'GET', 'http://127.0.0.1:9/', [], null, [ 'timeout' => 1, 'connect_timeout' => 1 ] ); + + $this->assertSame( 0, $res->status ); + $this->assertNotSame( '', $res->error ); + } +} diff --git a/tests/OwaClientTest.php b/tests/OwaClientTest.php new file mode 100644 index 0000000..8a68fed --- /dev/null +++ b/tests/OwaClientTest.php @@ -0,0 +1,190 @@ +transport = new FakeTransport(); + } + + protected function tearDown(): void { + + putenv( 'OWA_API_KEY' ); + putenv( 'OWA_AUTH_KEY' ); + + if ( $this->tmp && is_dir( $this->tmp ) ) { + + exec( 'rm -rf ' . escapeshellarg( $this->tmp ) ); + } + } + + private function sites( array $config = [] ): SitesClient { + + return new SitesClient( $config + [ + 'instance_url' => 'https://owa.example.com/owa', + 'credentials' => [ 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], + 'transport' => $this->transport, + ] ); + } + + /** + * The server's formula, Auth.php in OWA 1.14. + */ + private static function expectedSignature( string $url ): string { + + return base64_encode( hash( 'sha256', 'OWASIGNATURE' . self::API_KEY . $url . gmdate( 'Ymd' ) . self::AUTH_KEY ) ); + } + + public function testListSitesSendsSignedRequestAndReturnsData(): void { + + $this->transport->queue( new Response( 200, json_encode( [ 'requestId' => 'r1', 'data' => [ [ 'site_id' => 'abc' ] ] ] ) ) ); + + $sites = $this->sites()->listSites(); + + $req = $this->transport->last(); + + $this->assertSame( 'GET', $req['method'] ); + $this->assertSame( 'https://owa.example.com/owa/api/base/v1/sites', $req['url'] ); + $this->assertSame( self::API_KEY, $req['headers']['X-API-KEY'] ); + $this->assertSame( self::expectedSignature( $req['url'] ), $req['headers']['X-SIGNATURE'] ); + $this->assertSame( [ [ 'site_id' => 'abc' ] ], $sites ); + } + + public function testSignatureCoversTheUrlExactlyAsSent(): void { + + $client = $this->sites(); + $client->makeRequest( [ 'http_method' => 'GET', 'uri' => '/base/v1/reports/x', 'query' => [ 'label' => 'two words~' ] ] ); + + $req = $this->transport->last(); + + // RFC 3986: the 1.0.x SDK signed '+' and Guzzle sent '%20', so the signature never matched + $this->assertSame( 'https://owa.example.com/owa/api/base/v1/reports/x?label=two%20words~', $req['url'] ); + $this->assertSame( self::expectedSignature( $req['url'] ), $req['headers']['X-SIGNATURE'] ); + } + + public function testGenerateRequestSignatureAcceptsRequestArray(): void { + + $client = $this->sites(); + $url = 'https://owa.example.com/owa/api/base/v1/sites'; + + $this->assertSame( self::expectedSignature( $url ), $client->generateRequestSignature( [ 'uri' => '/base/v1/sites' ] ) ); + $this->assertSame( self::expectedSignature( $url ), $client->generateRequestSignature( $url ) ); + } + + public function testAddSitePostsNamespacedForm(): void { + + $this->transport->queue( new Response( 201, json_encode( [ 'data' => [ 'site_id' => 'new' ] ] ) ) ); + + $site = $this->sites()->addSite( [ 'domain' => 'https://shop.example.com', 'name' => 'Shop', 'streamType' => 'web' ] ); + + $req = $this->transport->last(); + parse_str( $req['body'], $body ); + + $this->assertSame( 'POST', $req['method'] ); + $this->assertSame( 'application/x-www-form-urlencoded', $req['headers']['Content-Type'] ); + $this->assertSame( [ 'owa_domain' => 'https://shop.example.com', 'owa_name' => 'Shop', 'owa_description' => '', 'owa_site_family' => '', 'owa_streamType' => 'web' ], $body ); + $this->assertSame( [ 'site_id' => 'new' ], $site ); + } + + public function testValidationErrorsAreReturnedNotThrown(): void { + + $this->transport->queue( new Response( 422, json_encode( [ + 'data' => null, + 'error' => [ 'validation_errors' => [ 'name' => 'Name is required.' ] ], + ] ) ) ); + + $client = $this->sites(); + + $this->assertNull( $client->addSite( [ 'domain' => 'x.example.com' ] ) ); + $this->assertSame( [ 'Name is required.' ], $client->getLastErrors() ); + $this->assertSame( 422, $client->getLastResponse()->status ); + } + + public function testTransportFailureIsReported(): void { + + $this->transport->queue( new Response( 0, '', [], 'Could not resolve host' ) ); + + $client = $this->sites(); + + $this->assertNull( $client->listSites() ); + $this->assertSame( [ 'Could not resolve host' ], $client->getLastErrors() ); + } + + public function testFailureLogCarriesNoCredentialsOrQuery(): void { + + $this->tmp = sys_get_temp_dir() . '/owa-sdk-test-' . bin2hex( random_bytes( 4 ) ); + mkdir( $this->tmp ); + $log = $this->tmp . '/error.log'; + $previous = ini_set( 'error_log', $log ); + + try { + + $this->transport->queue( new Response( 401 ) ); + $this->sites()->makeRequest( [ 'http_method' => 'GET', 'uri' => '/base/v1/sites', 'query' => [ 'apiKey' => self::API_KEY ] ] ); + + } finally { + + ini_set( 'error_log', (string) $previous ); + } + + $written = (string) file_get_contents( $log ); + + $this->assertStringContainsString( 'GET https://owa.example.com/owa/api/base/v1/sites failed: HTTP 401', $written ); + $this->assertStringNotContainsString( self::API_KEY, $written ); + $this->assertStringNotContainsString( 'X-SIGNATURE', $written ); + } + + public function testCredentialsFromEnvironment(): void { + + putenv( 'OWA_API_KEY=' . self::API_KEY ); + putenv( 'OWA_AUTH_KEY=' . self::AUTH_KEY ); + + $client = new OwaClient( [ 'transport' => $this->transport ] ); + + // 1.0.x copied the api key into auth_key here + $this->assertSame( [ 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $client->getCredentials() ); + } + + public function testCredentialsFromFileDefaultsSection(): void { + + $this->tmp = sys_get_temp_dir() . '/owa-sdk-test-' . bin2hex( random_bytes( 4 ) ); + mkdir( $this->tmp . '/.owa', 0700, true ); + file_put_contents( $this->tmp . '/.owa/credentials', "[defaults]\napi_key = " . self::API_KEY . "\nauth_key = " . self::AUTH_KEY . "\n" ); + + $home = getenv( 'HOME' ); + putenv( 'HOME=' . $this->tmp ); + + try { + + $creds = ( new OwaClient( [] ) )->getCredentials(); + + } finally { + + putenv( $home === false ? 'HOME' : 'HOME=' . $home ); + } + + $this->assertSame( [ 'api_key' => self::API_KEY, 'auth_key' => self::AUTH_KEY ], $creds ); + } + + public function testInstanceUrlGetsTrailingSlash(): void { + + $this->assertSame( 'https://owa.example.com/owa/', OwaClient::normalizeInstanceUrl( 'https://owa.example.com/owa' ) ); + $this->assertSame( 'https://owa.example.com/owa/', OwaClient::normalizeInstanceUrl( 'https://owa.example.com/owa/' ) ); + $this->assertSame( '', OwaClient::normalizeInstanceUrl( '' ) ); + } +} diff --git a/tests/SdkTest.php b/tests/SdkTest.php new file mode 100644 index 0000000..87cc1f7 --- /dev/null +++ b/tests/SdkTest.php @@ -0,0 +1,71 @@ + 'https://owa.example.com/owa', 'cookie_domain' => 'example.com' ] ); + + $tracker = $sdk->createTracker( [ 'session_length' => 600 ] ); + $sites = $sdk->createSites(); + + $this->assertInstanceOf( TrackerClient::class, $tracker ); + $this->assertInstanceOf( SitesClient::class, $sites ); + $this->assertSame( 'https://owa.example.com/owa/', $tracker->getSetting( 'instance_url' ) ); + $this->assertSame( 600, $tracker->getSetting( 'session_length' ) ); + $this->assertSame( '.example.com', $tracker->getSetting( 'cookie_domain' ) ); + } + + public function testUnknownMethodThrows(): void { + + $this->expectException( \BadMethodCallException::class ); + + ( new sdk() )->fetchSomething(); + } + + public function testUnknownServiceThrows(): void { + + $this->expectException( \InvalidArgumentException::class ); + + ( new sdk() )->createReports(); + } + + public function testDebugWritesToConfiguredLogWithFormatting(): void { + + $log = sys_get_temp_dir() . '/owa-sdk-debug-' . bin2hex( random_bytes( 4 ) ) . '.log'; + + try { + + new sdk( [ 'debug' => true, 'debug_log' => $log ] ); + sdk::debug( 'store %s has %d keys', 'v', 3 ); + + $this->assertSame( "store v has 3 keys\n", file_get_contents( $log ) ); + + } finally { + + @unlink( $log ); + } + } + + public function testDebugDoesNotChangeHostErrorSettings(): void { + + $before = [ ini_get( 'display_errors' ), error_reporting() ]; + + new sdk( [ 'debug' => true, 'debug_log' => '/dev/null' ] ); + + $this->assertSame( $before, [ ini_get( 'display_errors' ), error_reporting() ] ); + } +} diff --git a/tests/Support/FakeTransport.php b/tests/Support/FakeTransport.php new file mode 100644 index 0000000..00036e4 --- /dev/null +++ b/tests/Support/FakeTransport.php @@ -0,0 +1,50 @@ +, body: ?string, options: array}> */ + public array $requests = []; + + /** @var list */ + private array $queue = []; + + public function queue( Response ...$responses ): self { + + array_push( $this->queue, ...$responses ); + return $this; + } + + public function send( string $method, string $url, array $headers = [], ?string $body = null, array $options = [] ): Response { + + $this->requests[] = compact( 'method', 'url', 'headers', 'body', 'options' ); + + return array_shift( $this->queue ) ?? new Response( 200 ); + } + + public function last(): array { + + return $this->requests[ count( $this->requests ) - 1 ]; + } + + /** + * Decoded params of the last request: the query for GET, the body for POST. + * + * @return array + */ + public function lastParams(): array { + + $r = $this->last(); + $raw = $r['method'] === 'GET' ? (string) parse_url( $r['url'], PHP_URL_QUERY ) : (string) $r['body']; + parse_str( $raw, $params ); + + return $params; + } +} diff --git a/tests/Tracker/StateTest.php b/tests/Tracker/StateTest.php new file mode 100644 index 0000000..719f467 --- /dev/null +++ b/tests/Tracker/StateTest.php @@ -0,0 +1,217 @@ + */ + private array $written = []; + + private function makeState( array $cookies = [], array $config = [] ): State { + + $this->written = []; + + $state = new State( + $config + [ 'cookie_domain' => '.example.com', 'cookie_secure' => false ], + $cookies, + function ( $name, $value, $options ) { + $this->written[] = [ 'name' => $name, 'value' => $value, 'options' => $options ]; + } + ); + + $state->registerStore( 'v', 364 ); + $state->registerStore( 'c', 60 ); + $state->registerStore( 's_site1', 364, true ); + + return $state; + } + + private function cookie( array $value ): string { + + return State::jsEscape( json_encode( $value, JSON_UNESCAPED_SLASHES ) ); + } + + private function lastWritten( string $name ): ?array { + + foreach ( array_reverse( $this->written ) as $w ) { + + if ( $w['name'] === $name ) { + + return $w; + } + } + + return null; + } + + public function testCookieDomainHashMatchesJsTracker(): void { + + // crc32('.example.com') as unsigned hex, the value the JS tracker stamps + $this->assertSame( dechex( crc32( '.example.com' ) ), State::crc32AsHex( '.example.com' ) ); + $this->assertMatchesRegularExpression( '/^[0-9a-f]{1,8}$/', State::crc32AsHex( '.example.com' ) ); + } + + public function testJsEscapeMatchesJavaScript(): void { + + // escape('{"a":"x y+z/é€"}') in a browser + $this->assertSame( '%7B%22a%22%3A%22x%20y+z/%E9%u20AC%22%7D', State::jsEscape( '{"a":"x y+z/é€"}' ) ); + $this->assertSame( '@*_+-./AZaz09', State::jsEscape( '@*_+-./AZaz09' ) ); + } + + public function testJsUnescapeRoundTripsAndKeepsPlus(): void { + + $raw = '{"landing_url":"https://example.com/?q=a+b&x=é😀"}'; + + $this->assertSame( $raw, State::jsUnescape( State::jsEscape( $raw ) ) ); + $this->assertSame( 'a+b', State::jsUnescape( 'a+b' ) ); + } + + public function testReadsJsonCookieWithMatchingDomainHash(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_v' => [ $this->cookie( [ 'vid' => '123', 'nps' => '2', 'cdh' => $hash, 'sv' => 1 ] ) ] ] ); + + $this->assertSame( '123', $state->get( 'v', 'vid' ) ); + $this->assertSame( '2', $state->get( 'v', 'nps' ) ); + } + + public function testAcceptsSingleValuePerCookieName(): void { + + // a framework's cookie bag is name => string, not name => list + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_v' => $this->cookie( [ 'vid' => '123', 'cdh' => $hash, 'sv' => 1 ] ) ] ); + + $this->assertSame( '123', $state->get( 'v', 'vid' ) ); + } + + public function testIgnoresCookieWithoutOrWithForeignDomainHash(): void { + + $state = $this->makeState( [ 'owa_v' => [ + $this->cookie( [ 'vid' => 'nohash' ] ), + $this->cookie( [ 'vid' => 'foreign', 'cdh' => State::crc32AsHex( '.other.com' ) ] ), + ] ] ); + + $this->assertNull( $state->get( 'v', 'vid' ) ); + } + + public function testFirstDuplicateWithMatchingHashWins(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_v' => [ + $this->cookie( [ 'vid' => 'sub', 'cdh' => State::crc32AsHex( '.www.example.com' ) ] ), + $this->cookie( [ 'vid' => 'first', 'cdh' => $hash ] ), + $this->cookie( [ 'vid' => 'second', 'cdh' => $hash ] ), + ] ] ); + + $this->assertSame( 'first', $state->get( 'v', 'vid' ) ); + } + + public function testIgnoresCookieFromNewerStoreVersion(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_v' => [ $this->cookie( [ 'vid' => '1', 'cdh' => $hash, 'sv' => 2 ] ) ] ] ); + + $this->assertNull( $state->get( 'v', 'vid' ) ); + } + + public function testReadsLegacyAssocFormat(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_v' => [ rawurlencode( "vid=>42|||fsts=>1600000000|||cdh=>$hash" ) ] ] ); + + $this->assertSame( '42', $state->get( 'v', 'vid' ) ); + $this->assertSame( '1600000000', $state->get( 'v', 'fsts' ) ); + } + + public function testSetPersistsJsonWithVersionAndHash(): void { + + $state = $this->makeState(); + $state->set( 'v', 'vid', '99' ); + + $w = $this->lastWritten( 'owa_v' ); + $this->assertNotNull( $w ); + + $value = json_decode( State::jsUnescape( $w['value'] ), true ); + $this->assertSame( [ 'vid' => '99', 'sv' => 1, 'cdh' => State::crc32AsHex( '.example.com' ) ], $value ); + + $this->assertSame( '.example.com', $w['options']['domain'] ); + $this->assertSame( '/', $w['options']['path'] ); + $this->assertSame( 'Lax', $w['options']['samesite'] ); + $this->assertFalse( $w['options']['httponly'] ); + $this->assertEqualsWithDelta( time() + 364 * 86400, $w['options']['expires'], 5 ); + } + + public function testCookiePersistenceOffWritesSessionCookies(): void { + + $state = $this->makeState( [], [ 'cookie_persistence' => false ] ); + $state->set( 'v', 'vid', '1' ); + + $this->assertSame( 0, $this->lastWritten( 'owa_v' )['options']['expires'] ); + } + + public function testDeferredStoreIsNotReadOrWrittenUntilSettled(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_s_site1' => [ $this->cookie( [ 'sid' => 'old', 'last_req' => 100, 'cdh' => $hash ] ) ] ] ); + + $state->set( 's_site1', 'landing_url', 'https://example.com/' ); + + $this->assertNull( $state->get( 's_site1', 'sid' ), 'cookie must not merge before settle()' ); + $this->assertSame( 100, $state->getPersisted( 's_site1', 'last_req' ) ); + $this->assertNull( $this->lastWritten( 'owa_s_site1' ), 'deferred store must not write on set()' ); + + $state->settle( 's_site1', false ); + + $this->assertSame( 'old', $state->get( 's_site1', 'sid' ) ); + $this->assertSame( 'https://example.com/', $state->get( 's_site1', 'landing_url' ) ); + } + + public function testSettleWithDiscardErasesCookieAndKeepsMemory(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_s_site1' => [ $this->cookie( [ 'sid' => 'old', 'referer' => 'x', 'cdh' => $hash ] ) ] ] ); + + $state->set( 's_site1', 'is_new_visitor', true ); + $state->settle( 's_site1', true ); + + $this->assertNull( $state->get( 's_site1', 'sid' ) ); + $this->assertNull( $state->get( 's_site1', 'referer' ) ); + $this->assertTrue( $state->get( 's_site1', 'is_new_visitor' ) ); + + $erase = $this->lastWritten( 'owa_s_site1' ); + $this->assertSame( '', $erase['value'] ); + $this->assertLessThan( time(), $erase['options']['expires'] ); + } + + public function testClearWholeStoreErasesCookie(): void { + + $hash = State::crc32AsHex( '.example.com' ); + $state = $this->makeState( [ 'owa_c' => [ $this->cookie( [ 'attribs' => [], 'cdh' => $hash ] ) ] ] ); + + $state->clear( 'c' ); + + $this->assertNull( $state->get( 'c' ) ); + $this->assertSame( '', $this->lastWritten( 'owa_c' )['value'] ); + } + + public function testReadRequestCookiesKeepsDuplicatesAndRawValues(): void { + + $_SERVER['HTTP_COOKIE'] = 'owa_v=%7B%22a%22%3A1%7D; other=x; owa_v=second; owa_s=a+b=c'; + + $cookies = State::readRequestCookies(); + + $this->assertSame( [ '%7B%22a%22%3A1%7D', 'second' ], $cookies['owa_v'] ); + $this->assertSame( [ 'a+b=c' ], $cookies['owa_s'] ); + } + + public function testReadRequestCookiesFallsBackToSuperglobal(): void { + + unset( $_SERVER['HTTP_COOKIE'] ); + $_COOKIE = [ 'owa_v' => 'x' ]; + + $this->assertSame( [ 'owa_v' => [ 'x' ] ], State::readRequestCookies() ); + } +} diff --git a/tests/Tracker/TrackerClientTest.php b/tests/Tracker/TrackerClientTest.php new file mode 100644 index 0000000..0ea649a --- /dev/null +++ b/tests/Tracker/TrackerClientTest.php @@ -0,0 +1,544 @@ + */ + private array $written = []; + + protected function setUp(): void { + + $_SERVER = [ + 'HTTP_HOST' => 'www.example.com', + 'REQUEST_URI' => '/products?id=7', + 'HTTPS' => 'on', + 'HTTP_USER_AGENT' => 'Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0', + 'REMOTE_ADDR' => '203.0.113.9', + ]; + $_GET = []; + + $this->transport = new FakeTransport(); + $this->written = []; + } + + private function tracker( array $cookies = [], array $config = [] ): TrackerClient { + + $tracker = new TrackerClient( $config + [ + 'instance_url' => 'https://owa.example.com/owa', + 'transport' => $this->transport, + 'cookies' => $cookies, + 'cookie_writer' => function ( $name, $value, $options ) { + $this->written[] = [ 'name' => $name, 'value' => $value, 'options' => $options ]; + }, + ] ); + + $tracker->setSiteId( self::SITE ); + + return $tracker; + } + + /** + * A cookie as the JS tracker writes it. + */ + private function jsCookie( array $value, string $domain = '.example.com' ): string { + + $value += [ 'cdh' => State::crc32AsHex( $domain ), 'sv' => 1 ]; + + return State::jsEscape( json_encode( $value, JSON_UNESCAPED_SLASHES ) ); + } + + /** + * Last value written to a cookie, decoded; '' for an erase, null if never written. + */ + private function cookieValue( string $name ): array|string|null { + + foreach ( array_reverse( $this->written ) as $w ) { + + if ( $w['name'] === $name ) { + + return $w['value'] === '' ? '' : State::decodeValue( $w['value'] ); + } + } + + return null; + } + + public function testFirstPageViewOfNewVisitor(): void { + + $tracker = $this->tracker(); + $tracker->setPageTitle( 'Products' ); + + $this->assertTrue( $tracker->trackPageView() ); + + $req = $this->transport->last(); + $this->assertSame( 'GET', $req['method'] ); + $this->assertStringStartsWith( 'https://owa.example.com/owa/log.php?', $req['url'] ); + + $p = $this->transport->lastParams(); + + $this->assertSame( 'base.page_request', $p['owa_event_type'] ); + $this->assertSame( self::SITE, $p['owa_site_id'] ); + $this->assertSame( 'Products', $p['owa_page_title'] ); + $this->assertSame( 'https://www.example.com/products?id=7', $p['owa_page_url'] ); + $this->assertSame( 'https://www.example.com/products?id=7', $p['owa_landing_url'] ); + $this->assertMatchesRegularExpression( '/^\d{19}$/', $p['owa_visitor_id'] ); + $this->assertMatchesRegularExpression( '/^\d{19}$/', $p['owa_session_id'] ); + $this->assertSame( 'true', $p['owa_is_new_session'] ); + $this->assertSame( 'true', $p['owa_is_new_session_start'] ); + $this->assertSame( 'true', $p['owa_is_new_visitor'] ); + $this->assertSame( 'true', $p['owa_is_new_visitor_created'] ); + $this->assertSame( '0', $p['owa_nps'] ); + $this->assertSame( '', $p['owa_last_req'] ); + $this->assertSame( $p['owa_timestamp'], $p['owa_fsts'] ); + $this->assertSame( $p['owa_timestamp'], $p['owa_sts'] ); + + // dropped by OWA 1.14 or no longer sent by its tracker + foreach ( [ 'dsfs', 'dsps', 'medium', 'source', 'search_terms', 'HTTP_USER_AGENT', 'ip_address', 'attribs', 'prior_session_id', 'psts' ] as $gone ) { + + $this->assertArrayNotHasKey( 'owa_' . $gone, $p, $gone ); + } + + $v = $this->cookieValue( 'owa_v' ); + $this->assertSame( $p['owa_visitor_id'], $v['vid'] ); + $this->assertSame( '0', $v['nps'] ); + $this->assertSame( 1, $v['sv'] ); + $this->assertSame( State::crc32AsHex( '.example.com' ), $v['cdh'] ); + + $s = $this->cookieValue( 'owa_s_' . self::SITE ); + $this->assertSame( $p['owa_session_id'], $s['sid'] ); + $this->assertSame( (int) $p['owa_timestamp'], $s['last_req'] ); + $this->assertSame( '', $s['prior_last_req'] ); + $this->assertTrue( $s['is_new_visitor'] ); + } + + public function testContinuesSessionFromJsTrackerCookies(): void { + + $now = time(); + $tracker = $this->tracker( [ + 'owa_v' => [ $this->jsCookie( [ 'vid' => '1700000000123456789', 'fsts' => $now - 86400 * 3, 'nps' => '2' ] ) ], + 'owa_s_' . self::SITE => [ $this->jsCookie( [ + 'sid' => '1700000000987654321', + 'last_req' => $now - 60, + 'prior_last_req' => $now - 86400, + 'sts' => $now - 300, + 'psts' => $now - 86400 - 600, + 'prior_session_id' => '1690000000111111111', + 'referer' => 'https://search.example.net/?q=a+b', + 'landing_url' => 'https://www.example.com/?owa_medium=email', + ] ) ], + ] ); + + $tracker->trackPageView(); + $p = $this->transport->lastParams(); + + $this->assertSame( '1700000000123456789', $p['owa_visitor_id'] ); + $this->assertSame( '1700000000987654321', $p['owa_session_id'] ); + $this->assertSame( '2', $p['owa_nps'] ); + $this->assertSame( (string) ( $now - 86400 ), $p['owa_last_req'] ); + $this->assertSame( (string) ( $now - 300 ), $p['owa_sts'] ); + $this->assertSame( '1690000000111111111', $p['owa_prior_session_id'] ); + // '+' survives: escape() leaves it raw and $_COOKIE would have made it a space + $this->assertSame( 'https://search.example.net/?q=a+b', $p['owa_session_referer'] ); + $this->assertSame( 'https://www.example.com/?owa_medium=email', $p['owa_landing_url'] ); + + foreach ( [ 'is_new_session', 'is_new_session_start', 'is_new_visitor', 'is_new_visitor_created' ] as $flag ) { + + $this->assertArrayNotHasKey( 'owa_' . $flag, $p, $flag ); + } + + $s = $this->cookieValue( 'owa_s_' . self::SITE ); + $this->assertSame( '1700000000987654321', $s['sid'] ); + $this->assertGreaterThanOrEqual( $now, $s['last_req'] ); + } + + public function testExpiredSessionStartsNewOneAndIncrementsPriorSessions(): void { + + $now = time(); + $tracker = $this->tracker( [ + 'owa_v' => [ $this->jsCookie( [ 'vid' => '1700000000123456789', 'fsts' => $now - 86400 * 10, 'nps' => '0' ] ) ], + 'owa_s_' . self::SITE => [ $this->jsCookie( [ + 'sid' => '1700000000987654321', + 'last_req' => $now - 3600, + 'sts' => $now - 4000, + 'referer' => 'https://old.example.net/', + 'cv2' => 'plan=old', + ] ) ], + ] ); + + $tracker->trackPageView(); + $p = $this->transport->lastParams(); + + $this->assertNotSame( '1700000000987654321', $p['owa_session_id'] ); + $this->assertSame( '1700000000987654321', $p['owa_prior_session_id'] ); + $this->assertSame( (string) ( $now - 4000 ), $p['owa_psts'] ); + $this->assertSame( (string) ( $now - 3600 ), $p['owa_last_req'] ); + // "0" is truthy in JS; the 1.0.x SDK reset it to "0" on every session + $this->assertSame( '1', $p['owa_nps'] ); + $this->assertSame( 'true', $p['owa_is_new_session'] ); + $this->assertArrayNotHasKey( 'owa_is_new_visitor', $p ); + + // the ended session's values are discarded + $this->assertArrayNotHasKey( 'owa_cv2', $p ); + $this->assertArrayNotHasKey( 'owa_session_referer', $p ); + + $s = $this->cookieValue( 'owa_s_' . self::SITE ); + $this->assertSame( $p['owa_session_id'], $s['sid'] ); + $this->assertArrayNotHasKey( 'cv2', $s ); + } + + public function testSessionCookieIsNotWrittenWhenLogRequestFails(): void { + + $this->transport->queue( new Response( 503 ) ); + $tracker = $this->tracker(); + + $this->assertFalse( $tracker->trackPageView() ); + $this->assertNull( $this->cookieValue( 'owa_s_' . self::SITE ) ); + $this->assertIsArray( $this->cookieValue( 'owa_v' ) ); + $this->assertSame( [ 'HTTP 503' ], $tracker->getLastErrors() ); + } + + public function testRedirectFromPostIsAccepted(): void { + + $this->transport->queue( new Response( 302 ) ); + + $this->assertTrue( $this->tracker()->trackPageView() ); + } + + public function testSessionCookieIsNotWrittenOnTransportFailure(): void { + + $this->transport->queue( new Response( 0, '', [], 'Connection refused' ) ); + $tracker = $this->tracker(); + + $this->assertFalse( @$tracker->trackPageView() ); + $this->assertNull( $this->cookieValue( 'owa_s_' . self::SITE ) ); + $this->assertSame( [ 'Connection refused' ], $tracker->getLastErrors() ); + } + + public function testMigratesLegacySessionCookieWrittenBySdk10(): void { + + $now = time(); + $hash = State::crc32AsHex( '.example.com' ); + $legacy = rawurlencode( "sid=>1600000000111222333|||last_req=>" . ( $now - 30 ) . "|||cdh=>$hash" ); + + $tracker = $this->tracker( [ 'owa_s' => [ $legacy ] ] ); + $tracker->trackPageView(); + + $this->assertSame( '1600000000111222333', $this->transport->lastParams()['owa_session_id'] ); + $this->assertSame( '', $this->cookieValue( 'owa_s' ), 'legacy cookie erased' ); + $this->assertSame( '1600000000111222333', $this->cookieValue( 'owa_s_' . self::SITE )['sid'] ); + } + + public function testCollapsesLegacySessionCustomVarStore(): void { + + $now = time(); + $tracker = $this->tracker( [ + 'owa_s_' . self::SITE => [ $this->jsCookie( [ 'sid' => '1700000000987654321', 'last_req' => $now - 30 ] ) ], + 'owa_b' => [ $this->jsCookie( [ 'cv3' => 'tier=gold' ] ) ], + ] ); + + $tracker->trackPageView(); + + $this->assertSame( 'tier=gold', $this->transport->lastParams()['owa_cv3'] ); + $this->assertSame( '', $this->cookieValue( 'owa_b' ) ); + } + + public function testIgnoresCookiesHashedToAnotherDomain(): void { + + $now = time(); + $tracker = $this->tracker( [ + 'owa_v' => [ $this->jsCookie( [ 'vid' => '1700000000123456789' ], '.other.org' ) ], + ] ); + + $tracker->trackPageView(); + + $this->assertNotSame( '1700000000123456789', $this->transport->lastParams()['owa_visitor_id'] ); + } + + public function testCampaignTouchIsRecordedAndSent(): void { + + $_SERVER['REQUEST_URI'] = '/?OWA_MEDIUM=email&owa_campaign=fall&owa_source=newsletter&owa_ad=banner1'; + + $tracker = $this->tracker(); + $tracker->trackPageView(); + + $p = $this->transport->lastParams(); + $touch = [ 'md' => 'email', 'cn' => 'fall', 'sr' => 'newsletter', 'ad' => 'banner1', 'at' => '(not set)' ]; + + $this->assertSame( [ $touch ], json_decode( $p['owa_attribs'], true ) ); + $this->assertSame( [ $touch ], $this->cookieValue( 'owa_c' )['attribs'] ); + $this->assertEqualsWithDelta( time() + 60 * 86400, $this->lastWrite( 'owa_c' )['options']['expires'], 5 ); + } + + public function testDirectAttributionKeepsFiveMostRecentTouches(): void { + + $_SERVER['REQUEST_URI'] = '/?owa_medium=cpc'; + + $prior = []; + + for ( $i = 1; $i <= 5; $i++ ) { + + $prior[] = [ 'md' => "m$i" ]; + } + + $tracker = $this->tracker( [ 'owa_c' => [ $this->jsCookie( [ 'attribs' => $prior ] ) ] ] ); + $tracker->trackPageView(); + + $attribs = $this->cookieValue( 'owa_c' )['attribs']; + + $this->assertCount( 5, $attribs ); + $this->assertSame( 'm2', $attribs[0]['md'] ); + $this->assertSame( 'cpc', $attribs[4]['md'] ); + } + + public function testOriginalAttributionKeepsFirstTouch(): void { + + $_SERVER['REQUEST_URI'] = '/?owa_medium=cpc'; + + $tracker = $this->tracker( + [ 'owa_c' => [ $this->jsCookie( [ 'attribs' => [ [ 'md' => 'email' ] ] ] ) ] ], + [ 'trafficAttributionMode' => 'original' ] + ); + $tracker->trackPageView(); + + $this->assertNull( $this->cookieValue( 'owa_c' ), 'history untouched' ); + $this->assertSame( [ [ 'md' => 'email' ] ], json_decode( $this->transport->lastParams()['owa_attribs'], true ) ); + } + + public function testLegacyCampaignTouchesAreReadUnderCurrentKeys(): void { + + $tracker = $this->tracker( [ 'owa_c' => [ $this->jsCookie( [ 'attribs' => [ + [ 'medium' => 'email', 'campaign' => 'spring', 'ts' => 1600000000 ], + ] ] ) ] ] ); + $tracker->trackPageView(); + + $this->assertSame( [ [ 'md' => 'email', 'cn' => 'spring' ] ], json_decode( $this->transport->lastParams()['owa_attribs'], true ) ); + } + + public function testForwardsVisitorHeaders(): void { + + $_SERVER['HTTP_X_FORWARDED_FOR'] = '198.51.100.7'; + $_SERVER['HTTP_ACCEPT_LANGUAGE'] = 'de-DE,de;q=0.9'; + + $this->tracker()->trackPageView(); + $h = $this->transport->last()['headers']; + + $this->assertSame( 'Mozilla/5.0 (X11; Linux x86_64) Firefox/140.0', $h['User-Agent'] ); + $this->assertSame( '198.51.100.7, 203.0.113.9', $h['X-Forwarded-For'] ); + $this->assertSame( 'de-DE,de;q=0.9', $h['Accept-Language'] ); + } + + public function testExplicitIpAndUserAgentWin(): void { + + $tracker = $this->tracker(); + $tracker->setIpAddress( '192.0.2.44' ); + $tracker->setUserAgent( 'Custom/1.0' ); + $tracker->trackPageView(); + + $h = $this->transport->last()['headers']; + + $this->assertSame( '192.0.2.44', $h['X-Forwarded-For'] ); + $this->assertSame( 'Custom/1.0', $h['User-Agent'] ); + } + + public function testStatelessEventDoesNotForwardRequestIdentity(): void { + + $tracker = $this->tracker(); + $tracker->addTransaction( 'order-9', 'web', 10.5, 1, 2, 'stripe', 'DE', 'BE', 'Berlin', '', '1700000000987654321' ); + $tracker->trackTransaction(); + + $req = $this->transport->last(); + $p = $this->transport->lastParams(); + + $this->assertSame( TrackerClient::USER_AGENT, $req['headers']['User-Agent'] ); + $this->assertArrayNotHasKey( 'X-Forwarded-For', $req['headers'] ); + $this->assertSame( '1700000000987654321', $p['owa_original_session_id'] ); + $this->assertArrayNotHasKey( 'owa_visitor_id', $p ); + $this->assertSame( [], $this->written, 'stateless tracking sets no cookies' ); + } + + public function testTransactionUsesCtAddressFieldsAndNestedLineItems(): void { + + $tracker = $this->tracker(); + $tracker->addTransaction( 1001, 'web', '25.00', 0, 5, 'paypal', 'US', 'CA', 'Oakland' ); + $tracker->addTransactionLineItem( 1001, 'SKU-1', 'Mug', 'kitchen', 10, 2 ); + $tracker->trackTransaction(); + + $p = $this->transport->lastParams(); + + $this->assertSame( 'ecommerce.transaction', $p['owa_event_type'] ); + $this->assertSame( 'US', $p['owa_ct_country'] ); + $this->assertSame( 'CA', $p['owa_ct_state'] ); + $this->assertSame( 'Oakland', $p['owa_ct_city'] ); + $this->assertArrayNotHasKey( 'owa_country', $p ); + $this->assertSame( 'SKU-1', $p['owa_ct_line_items'][0]['li_sku'] ); + $this->assertSame( '2', $p['owa_ct_line_items'][0]['li_quantity'] ); + $this->assertArrayHasKey( 'owa_session_id', $p ); + } + + public function testRespectsDoNotTrack(): void { + + $_SERVER['HTTP_DNT'] = '1'; + $tracker = $this->tracker(); + + $this->assertFalse( $tracker->trackPageView() ); + $this->assertSame( [], $this->transport->requests ); + $this->assertSame( [], $this->written ); + } + + public function testDoNotTrackCanBeIgnored(): void { + + $_SERVER['HTTP_DNT'] = '1'; + + $this->assertTrue( $this->tracker( [], [ 'respect_do_not_track' => false ] )->trackPageView() ); + } + + public function testNoSiteIdSendsNothing(): void { + + $tracker = $this->tracker(); + $tracker->setSiteId( '' ); + + $this->assertFalse( $tracker->trackPageView() ); + $this->assertSame( [], $this->transport->requests ); + } + + public function testLongPayloadIsPosted(): void { + + $tracker = $this->tracker(); + $tracker->setProperty( 'notes', str_repeat( 'x', 2500 ) ); + $tracker->trackPageView(); + + $req = $this->transport->last(); + + $this->assertSame( 'POST', $req['method'] ); + $this->assertSame( 'https://owa.example.com/owa/log.php', $req['url'] ); + $this->assertSame( 'application/x-www-form-urlencoded', $req['headers']['Content-Type'] ); + $this->assertSame( 'base.page_request', $this->transport->lastParams()['owa_event_type'] ); + } + + public function testSecondEventOnSamePageSharesSessionAndSpendsOneShotFlags(): void { + + $tracker = $this->tracker(); + $tracker->trackPageView(); + $first = $this->transport->lastParams(); + + $tracker->trackAction( 'signup', 'forms', 'footer', 1 ); + $second = $this->transport->lastParams(); + + $this->assertSame( 'track.action', $second['owa_event_type'] ); + $this->assertSame( 'signup', $second['owa_action_name'] ); + $this->assertSame( $first['owa_session_id'], $second['owa_session_id'] ); + $this->assertSame( $first['owa_visitor_id'], $second['owa_visitor_id'] ); + $this->assertArrayNotHasKey( 'owa_is_new_session_start', $second ); + $this->assertArrayNotHasKey( 'owa_is_new_visitor_created', $second ); + // page state rides every event of the page, as in the JS tracker + $this->assertSame( 'true', $second['owa_is_new_session'] ); + } + + public function testEventPropertyBeatsGlobalBeatsState(): void { + + $tracker = $this->tracker(); + $tracker->setProperty( 'page_url', 'https://www.example.com/global' ); + $tracker->setProperty( 'session_id', 'from-global' ); + + $event = new TrackingEvent(); + $event->set( 'page_url', 'https://www.example.com/event' ); + $tracker->trackPageView( $event ); + + $p = $this->transport->lastParams(); + + $this->assertSame( 'https://www.example.com/event', $p['owa_page_url'] ); + $this->assertSame( 'from-global', $p['owa_session_id'] ); + } + + public function testCustomVariableScopes(): void { + + $tracker = $this->tracker(); + $tracker->setCustomVar( 1, 'section', 'shop' ); + $tracker->setCustomVar( 2, 'plan', 'pro', 'session' ); + $tracker->setCustomVar( 3, 'cohort', 'b', 'visitor' ); + $tracker->setCustomVar( 4, str_repeat( 'k', 40 ), str_repeat( 'v', 30 ) ); + $tracker->trackPageView(); + + $p = $this->transport->lastParams(); + + $this->assertSame( 'section=shop', $p['owa_cv1'] ); + $this->assertSame( 'plan=pro', $p['owa_cv2'] ); + $this->assertSame( 'cohort=b', $p['owa_cv3'] ); + $this->assertArrayNotHasKey( 'owa_cv4', $p, 'over 65 characters is dropped' ); + + $this->assertSame( 'plan=pro', $this->cookieValue( 'owa_s_' . self::SITE )['cv2'] ); + $this->assertSame( 'cohort=b', $this->cookieValue( 'owa_v' )['cv3'] ); + $this->assertArrayNotHasKey( 'cv1', $this->cookieValue( 'owa_v' ) ); + } + + public function testUserNameIsStoredOnVisitor(): void { + + $tracker = $this->tracker(); + $tracker->setUserName( 'member-17' ); + $tracker->trackPageView(); + + $this->assertSame( 'member-17', $this->transport->lastParams()['owa_user_name'] ); + $this->assertSame( 'member-17', $this->cookieValue( 'owa_v' )['user_name'] ); + } + + public function testCookiesAreSecureOnHttps(): void { + + $this->tracker()->trackPageView(); + + $this->assertTrue( $this->lastWrite( 'owa_v' )['options']['secure'] ); + $this->assertSame( '.example.com', $this->lastWrite( 'owa_v' )['options']['domain'] ); + } + + public function testRunsWithoutWebServerVariables(): void { + + $_SERVER = []; + + $this->assertTrue( $this->tracker()->trackPageView() ); + $this->assertSame( TrackerClient::USER_AGENT, $this->transport->last()['headers']['User-Agent'] ); + } + + /** + * @return array + */ + public static function cookieDomains(): array { + + return [ + 'host, www stripped' => [ '', 'www.example.com', '.example.com' ], + 'host with port' => [ '', 'example.com:8080', '.example.com' ], + 'explicit keeps www' => [ 'www.example.com', 'ignored.test', '.www.example.com' ], + 'explicit leading dot' => [ '.example.com', '', '.example.com' ], + 'nothing to resolve' => [ '', '', '' ], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider( 'cookieDomains' )] + public function testResolveCookieDomain( string $domain, string $host, string $expected ): void { + + $this->assertSame( $expected, TrackerClient::resolveCookieDomain( $domain, $host ) ); + } + + private function lastWrite( string $name ): array { + + foreach ( array_reverse( $this->written ) as $w ) { + + if ( $w['name'] === $name ) { + + return $w; + } + } + + $this->fail( "$name was never written" ); + } +} diff --git a/tests/fixtures/echo-server.php b/tests/fixtures/echo-server.php new file mode 100644 index 0000000..0229d58 --- /dev/null +++ b/tests/fixtures/echo-server.php @@ -0,0 +1,34 @@ + 'error' ] ); + return true; +} + +$headers = []; + +foreach ( $_SERVER as $k => $v ) { + + if ( str_starts_with( $k, 'HTTP_' ) ) { + + $headers[ strtolower( str_replace( '_', '-', substr( $k, 5 ) ) ) ] = $v; + } +} + +header( 'Content-Type: application/json' ); +header( 'X-Echo: yes' ); + +echo json_encode( [ + 'method' => $_SERVER['REQUEST_METHOD'], + 'uri' => $_SERVER['REQUEST_URI'], + 'headers' => $headers, + 'body' => file_get_contents( 'php://input' ), +] ); + +return true;