self::COOKIE_NAME, 'days' => self::COOKIE_DAYS, 'sessionTtl' => self::SESSION_TTL, 'clickIdKeys' => self::click_id_keys(), 'utmKeys' => self::utm_keys(), 'maxLength' => self::MAX_VALUE_LENGTH, /* When consent mode is on, hold the cookie until ad_storage. */ 'requireConsent' => AdvisorPPC_Consent::is_enabled(), 'consentSignal' => 'ad_storage', 'secure' => is_ssl(), /* WordPress's own COOKIEPATH, not a hardcoded "/": on a * subdirectory install a "/" cookie leaks this site's click ids * to every other WordPress on the same host. */ 'path' => self::cookie_path(), ); /* * With no tags or workspace and consent off, an untagged visit has no consumer * for a session ID. Keep only a small browser-side check in the HTML; * load capture when attribution data is present. The decision belongs * in the browser so a page cache cannot hide a later visitor's click. */ $lazy = ! AdvisorPPC_Consent::is_enabled() && empty( AdvisorPPC_Options::get_tag_ids() ) && ! AdvisorPPC_Options::is_connected(); $handle = $lazy ? self::HANDLE . '-loader' : self::HANDLE; $src = AdvisorPPC_Consent::asset_url( 'public/js/clickids.js' ); wp_register_script( $handle, $lazy ? false : $src, AdvisorPPC_Consent::is_enabled() ? array( AdvisorPPC_Consent::HANDLE ) : array(), AdvisorPPC_Consent::asset_version(), // A loading strategy needs a src; WordPress rejects one on the src-less loader alias. $lazy ? array( 'in_footer' => false ) : array( 'strategy' => 'defer', 'in_footer' => false ) ); wp_enqueue_script( $handle ); wp_add_inline_script( $handle, 'window.advisorppcClickIds = ' . wp_json_encode( $config ) . ';', 'before' ); if ( $lazy ) { wp_add_inline_script( $handle, '(function(w,d){ var c=w.advisorppcClickIds, q=new URLSearchParams(w.location.search); var landing=c.clickIdKeys.concat(c.utmKeys).some(function(k){return q.has(k);}); var stored=d.cookie.split(";").some(function(v){return v.trim().indexOf(c.cookie+"=")===0;}); if(!landing&&!stored){return;} var s=d.createElement("script"); s.src=' . wp_json_encode( $src . '?ver=' . rawurlencode( AdvisorPPC_Consent::asset_version() ) ) . '; s.async=true; d.head.appendChild(s); })(window,document);' ); } } /** * The path the click-ID cookie is written under. * * WordPress's COOKIEPATH, so the cookie is scoped to this install rather * than to the whole host. * * @return string */ public static function cookie_path() { $path = defined( 'COOKIEPATH' ) ? (string) COOKIEPATH : ''; return '' !== $path ? $path : '/'; } /* ------------------------------------------------------------------ */ /* Read side. Everything below is safe to call from any lane. */ /* ------------------------------------------------------------------ */ /** * Parse and sanitise the cookie once per request. * * Values in this cookie originate from the query string of the landing * page, which means they are attacker controlled. Nothing leaves this * method that is not a short, character-restricted scalar. * * @return array */ private static function cookie() { if ( null !== self::$cache ) { return self::$cache; } self::$cache = array(); if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) { return self::$cache; } // PHP has already URL-decoded the cookie value. $raw = wp_unslash( $_COOKIE[ self::COOKIE_NAME ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitised field by field below. if ( ! is_string( $raw ) || strlen( $raw ) > 8192 ) { return self::$cache; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { return self::$cache; } $out = array(); foreach ( self::click_id_keys() as $key ) { $value = self::sanitize_click_id( isset( $data[ $key ] ) ? $data[ $key ] : null ); if ( null !== $value ) { $out[ $key ] = $value; } } foreach ( self::utm_keys() as $key ) { $value = self::sanitize_param( isset( $data[ $key ] ) ? $data[ $key ] : null, 255 ); if ( null !== $value ) { $out[ $key ] = $value; } } $sid = self::sanitize_click_id( isset( $data['_sid'] ) ? $data['_sid'] : null ); if ( null !== $sid ) { $out['_sid'] = $sid; } if ( isset( $data['_ts'] ) && is_numeric( $data['_ts'] ) ) { $out['_ts'] = (int) $data['_ts']; } if ( isset( $data['_sts'] ) && is_numeric( $data['_sts'] ) ) { $out['_sts'] = (int) $data['_sts']; } if ( ! empty( $data['_lp'] ) && is_string( $data['_lp'] ) ) { $url = esc_url_raw( substr( $data['_lp'], 0, 1000 ) ); if ( $url ) { $out['_lp'] = $url; } } if ( ! empty( $data['_rf'] ) && is_string( $data['_rf'] ) ) { $url = esc_url_raw( substr( $data['_rf'], 0, 1000 ) ); if ( $url ) { $out['_rf'] = $url; } } self::$cache = $out; return self::$cache; } /** * Click identifiers are opaque tokens. Allow only the characters the * platforms actually emit, and cap the length. * * @param mixed $value Raw value. * @return string|null */ private static function sanitize_click_id( $value ) { if ( ! is_string( $value ) || '' === $value ) { return null; } $value = substr( $value, 0, self::MAX_VALUE_LENGTH ); $value = preg_replace( '/[^A-Za-z0-9._~\-]/', '', $value ); return ( '' === $value ) ? null : $value; } /** * Campaign parameters are human readable, so they get the normal text * treatment plus a length cap. * * @param mixed $value Raw value. * @param int $max Maximum length. * @return string|null */ private static function sanitize_param( $value, $max ) { if ( ! is_string( $value ) || '' === $value ) { return null; } $value = sanitize_text_field( substr( $value, 0, (int) $max ) ); return ( '' === $value ) ? null : $value; } /** * The `click_ids` object of the event envelope. * * Always returns all nine keys. A key that was never captured is null, not * missing, so lane C can hand the array straight to wp_json_encode() and * get the shape the contract specifies. * * @return array */ public static function get_click_ids() { $cookie = self::cookie(); $out = array(); foreach ( self::click_id_keys() as $key ) { $out[ $key ] = isset( $cookie[ $key ] ) ? $cookie[ $key ] : null; } return $out; } /** * True when not a single click identifier was captured, which means this * visitor did not arrive from a tracked ad click. * * @return bool */ public static function has_click_id() { foreach ( self::get_click_ids() as $value ) { if ( null !== $value ) { return true; } } return false; } /** * The five campaign parameters, all keys always present, null when absent. * * @return array */ public static function get_utms() { $cookie = self::cookie(); $out = array(); foreach ( self::utm_keys() as $key ) { $out[ $key ] = isset( $cookie[ $key ] ) ? $cookie[ $key ] : null; } return $out; } /** * The visitor's current session id, for the envelope `session_id` field. * * Minted in the browser, rotated after SESSION_TTL seconds of inactivity. * Null when no cookie exists yet, for example on a first pageview or when * consent has not been granted. * * @return string|null */ public static function get_session_id() { $cookie = self::cookie(); return isset( $cookie['_sid'] ) ? $cookie['_sid'] : null; } /** * Unix timestamp of the first pageview in this cookie's life. * * @return int|null */ public static function get_first_seen() { $cookie = self::cookie(); return isset( $cookie['_ts'] ) ? $cookie['_ts'] : null; } /** * The landing page URL and the referrer recorded on the first pageview. * * @return array{landing_url:string|null,referrer:string|null} */ public static function get_landing() { $cookie = self::cookie(); return array( 'landing_url' => isset( $cookie['_lp'] ) ? $cookie['_lp'] : null, 'referrer' => isset( $cookie['_rf'] ) ? $cookie['_rf'] : null, ); } /** * Meta's own first-party cookies, which raise CAPI match quality a long * way. Written by the Meta pixel, not by us; we only read and validate. * * @return array{_fbc:string|null,_fbp:string|null} */ public static function get_meta_cookies() { $out = array( '_fbc' => null, '_fbp' => null, ); foreach ( array_keys( $out ) as $name ) { if ( empty( $_COOKIE[ $name ] ) ) { continue; } $value = wp_unslash( $_COOKIE[ $name ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- validated by the pattern below. if ( ! is_string( $value ) || strlen( $value ) > 512 ) { continue; } // Meta's documented format: fb... if ( preg_match( '/^fb\.[0-9]{1,2}\.[0-9]{1,20}\.[A-Za-z0-9._~\-]{1,400}$/', $value ) ) { $out[ $name ] = $value; } } return $out; } /** * Everything we hold about this visitor's acquisition, in one array. * * Keys: the nine click ids, the five utm_*, session_id, first_seen, * landing_url, referrer. * * @return array */ public static function get_all() { $landing = self::get_landing(); return array_merge( self::get_click_ids(), self::get_utms(), array( 'session_id' => self::get_session_id(), 'first_seen' => self::get_first_seen(), 'landing_url' => $landing['landing_url'], 'referrer' => $landing['referrer'], ) ); } } /* * Registered on lane A's documented extension point. AdvisorPPC_Plugin::run() * requires this file and then fires `advisorppc_loaded`, so the hook is always * in place before it runs. */ add_action( 'advisorppc_loaded', array( 'AdvisorPPC_ClickIDs', 'init' ) );