array( 'category' => self::CATEGORY_FUNCTIONAL, 'expires' => $conversation_expires, 'function' => __('Identifies this visitor\'s chat session so their conversation stays their own. Cryptographically random; not an account identifier and not a tracking identifier.', 'answerrails'), 'collected' => $collected_none, ), 'answerrails-session-token' => array( 'category' => self::CATEGORY_FUNCTIONAL, 'expires' => $conversation_expires, 'function' => __('Authorises this visitor to add to their own conversation, and nobody else to. Presented on chat and consent requests.', 'answerrails'), 'collected' => $collected_none, ), 'answerrails-conversation-' => array( 'category' => self::CATEGORY_FUNCTIONAL, 'expires' => $transcript_expires, 'function' => __('Keeps this visitor\'s copy of the conversation so the thread survives a page reload. One entry per session identifier.', 'answerrails'), 'collected' => __('Whatever the visitor chooses to type into the chat, which may include personal data. Held in their own browser and never read from it by this plugin.', 'answerrails'), ), 'answerrails-consent' => array( 'category' => self::CATEGORY_FUNCTIONAL, 'expires' => $conversation_expires, 'function' => __('Remembers that this visitor accepted the chat disclaimer, so they are not asked again. The authoritative record is the server-side consent receipt.', 'answerrails'), 'collected' => $collected_none, ), 'answerrails-last-activity' => array( 'category' => self::CATEGORY_FUNCTIONAL, 'expires' => $conversation_expires, 'function' => __('Records when this visitor was last active in the chat, so the owner\'s auto-clear timer knows when the conversation went idle.', 'answerrails'), 'collected' => $collected_none, ), 'answerrails-window-size' => array( 'category' => self::CATEGORY_PREFERENCES, 'expires' => $preference_expires, 'function' => __('Remembers the size this visitor resized the chat window to, so it opens the same way next time.', 'answerrails'), 'collected' => $collected_none, ), 'answerrails-window-position' => array( 'category' => self::CATEGORY_PREFERENCES, 'expires' => $preference_expires, 'function' => __('Remembers where this visitor dragged the chat window to, so it opens where they left it.', 'answerrails'), 'collected' => $collected_none, ), ); } /** * Wire the declaration up. * * The storage declaration rides `wp_loaded` because the WP Consent API is * not there to receive it any earlier. It is guarded, so the hook being * present with the API absent costs one no-op call and nothing else. * * โš  There is deliberately no `plugins_loaded` hook here. It existed only to * answer the API's registration filter, which amendment A1 withdrew โ€” see * the class docblock. */ public function __construct() { add_filter('script_loader_tag', array(__CLASS__, 'add_consent_category_attribute'), 10, 3); add_action('wp_loaded', array(__CLASS__, 'declare_storage')); add_filter('cmplz_integrations', array(__CLASS__, 'register_complianz_integration')); add_filter('cmplz_integration_path', array(__CLASS__, 'complianz_integration_path'), 10, 2); } /** * D1b โ€” describe what we store, once, for every CMP that reads the API. * * ๐Ÿ”ด The guard is the point. wp_add_cookie_info() ships with the WP Consent * API PLUGIN. With that plugin absent โ€” which is the overwhelmingly common * case, and the case every existing install is in โ€” this method returns * having done nothing, and AnswerRails behaves byte-identically to the way * it did before #259. Removing this guard turns an optional integration * into a fatal error on every page load. ConsentDeclarationTest pins that * property in a subprocess with the function genuinely undefined. */ public static function declare_storage() { if (!function_exists('wp_add_cookie_info')) { return; } foreach (self::storage_keys() as $key => $info) { // The signature, read from the shipped API and not from memory // (`inc/api-functions.php:392`, v2.0.1): // // wp_add_cookie_info( $name, $plugin_or_service, $category, // $expires, $cookie_function, $collected_personal_data = '', // $member_cookie = false, $administrator_cookie = false, // $type = 'HTTP', $domain = '' ) // // ๐Ÿ”ด THERE IS NO $description PARAMETER. An earlier cut passed our // description as the sixth argument, where it landed in // `collectedPersonalData` โ€” so every entry told the CMP it collected // whatever that sentence said, and the `$type` default of `'HTTP'` // declared all seven as HTTP cookies. Both are the #257 error // reintroduced inside its own remedy. (Codex P1, PR #269.) // // $domain is omitted deliberately: the API defaults it to // site_url(), which is exactly right โ€” every entry is first-party. wp_add_cookie_info( $key, self::SERVICE_NAME, $info['category'], $info['expires'], $info['function'], $info['collected'], false, false, self::STORAGE_TYPE ); } } /** * D2a โ€” put `data-category="functional"` on our own frontend script tags. * * Complianz's cookie blocker skips a tag carrying this attribute before it * reaches any pattern matching at all (class-cookie-blocker.php:726), which * makes it the cheapest and most direct of the four mechanisms in the * design's ยง1.2 table โ€” and the only one that needs no cooperation from * anybody. * * โš  The attribute is emitted through WordPress's script loader, never by * string concatenation at the enqueue site. Handles not ours are returned * untouched: a filter that decorated other plugins' tags would be making a * consent claim on their behalf. * * @param string $tag The full script tag WordPress is about to print. * @param string $handle The registered handle. * @param string $src The script source (unused; part of core's signature). * @return string */ public static function add_consent_category_attribute($tag, $handle, $src = '') { unset($src); if (!is_string($tag) || !in_array($handle, self::FRONTEND_HANDLES, true)) { return $tag; } if (strpos($tag, 'data-category=') !== false) { return $tag; } return preg_replace( '/])/', '