getMessage(); $file = $e->getFile(); $line = $e->getLine(); $code = $e->getCode(); $trace = $e->getTraceAsString(); } else { $class = 'N/A'; $message = (string) $fallbackMessage; $file = ''; $line = 0; $code = 0; $trace = (new \Exception())->getTraceAsString(); } error_log(sprintf( '[51Degrees] %s | %s: %s @ %s:%d (code=%s)%s%s', $context, $class, $message, $file, $line, (string) $code, PHP_EOL, $trace )); } /** * Makes a pipeline from a Resource Key that * can be serialized to the database. * * @param string $resourceKey Resource Key * @return array an array containing pipeline and engines */ public static function make_pipeline($resourceKey) { // Prepare PipelineBuilder and add the JavaScript settings for the // JavaScriptBuilder, in this case an endpoint to call back to // retrieve additional properties populated by client side evidence // this ?json endpoint is used later to serve results from a special // json engine automatically included in the pipeline. // Host and protocol are intentionally left empty so that // JavascriptBuilderElement uses per-request evidence (the browser's // Host header). This avoids baking in a build-time hostname that // may differ from how the browser reaches the site. $builder = new PipelineBuilder([ 'javascriptBuilderSettings' => [ 'endpoint' => Pipeline::getRestEndpoint(), 'minify' => false ] ]); $error = null; try { $cloud = new CloudRequestEngine([ 'resourceKey' => $resourceKey, 'httpClient' => new FiftyOneDegreesWpHttpClient(), 'cloudRequestOrigin' => FiftyOneDegreesWpHttpClient::defaultOrigin(), ]); // Entitlement map advertised by the cloud for this Resource Key: // [engine => [propertyName => meta]]. Cached separately because // optional engines (e.g. fodid) may be filtered out of the runtime // pipeline below, yet callers still need to know whether the key // is entitled to their properties. $engineProperties = $cloud->getEngineProperties(); $engines = array_keys($engineProperties); } catch (\Throwable $e) { Pipeline::record_runtime_error( 'Pipeline build failed while contacting the 51Degrees cloud (resource key save).', $e ); return [ 'pipeline' => null, 'available_engines' => null, 'engine_properties' => null, 'error' => self::user_facing_pipeline_error($e), ]; } // Add CloudRequestEngine to the pipeline. $builder->add($cloud); // Skip engines: both would crash CloudEngine's missing-isset() path; robotstxt also has a direct-HTTP fetcher. $excluded = ['robotstxt']; if (get_option(Options::SUSPICIOUS_ENABLE, 'off') !== 'on') { $excluded[] = 'fodid'; } $engines = array_values(array_filter( $engines, fn($e) => !in_array($e, $excluded, true) )); foreach ($engines as $engine) { $cloudEngine = new CloudEngine(); $cloudEngine->dataKey = $engine; $builder->add($cloudEngine); } // Build the pipeline $pipeline = $builder->build(); return [ 'pipeline' => $pipeline, 'available_engines' => $engines, 'engine_properties' => $engineProperties, 'error' => $error ]; } private static function user_facing_pipeline_error(\Throwable $e): string { $unreachable = $e instanceof \TypeError || ($e instanceof CloudRequestException && $e->httpStatusCode === 0); $key = $unreachable ? 'common.cloud.unreachable' : 'common.cloud.rejected'; $host = FiftyOneDegreesCloudMetadata::get_cloud_host_url(); return strip_tags(FiftyOneDegreesStrings::get($key)) . ' ' . FiftyOneDegreesStrings::get('common.cloud.host_suffix', $host); } /** * Process function sets the evidence from web request in flowData and * runs the process function on each attached FlowElement. * * @return void */ public static function process() { // Only process if the data has not already been populated. if (Pipeline::$data === null) { // Fetch the data from the session if it's enabled and is already // there. if ( session_status() === PHP_SESSION_ACTIVE && isset($_SESSION['fiftyonedegrees_data']) && Pipeline::session_is_invalidated() === false ) { Pipeline::$data = $_SESSION['fiftyonedegrees_data']; return; } require_once dirname(__DIR__) . '/lib/vendor/autoload.php'; // Get the preconstructed pipeline from the cached option. $cachedPipeline = get_option(Options::PIPELINE); if (!$cachedPipeline) { // There is no pipeline, so return null. return; } if (isset($cachedPipeline['error'])) { Pipeline::record_runtime_error( 'Pipeline build failed at activation/save time.', null, $cachedPipeline['error'] ); return; } // Wrap the entire pipeline-processing block in a single // try/catch so any failure (createFlowData, evidence setup, // process(), setResponseHeader, getProperties) leaves // Pipeline::$data null without leaking exceptions to the // public traffic path. try { $pipeline = $cachedPipeline['pipeline']; $engines = $cachedPipeline['available_engines']; $flowData = $pipeline->createFlowData(); $flowData->evidence->setFromWebRequest(); $resolvedIp = ClientIpResolver::resolve(); if ($resolvedIp !== '') { $flowData->evidence->set('query.client-ip', $resolvedIp); } if (isset($pipeline->flowElementsList['fodid'])) { $flowData->evidence->set('query.id.usage', 'non-marketing'); } $flowData->process(); // https://51degrees.com/blog/user-agent-client-hints?utm_source=code&utm_medium=comment&utm_campaign=pipeline-wordpress&utm_content=includes-pipeline.php&utm_term=process Utils::setResponseHeader($flowData); // Prefer the cloud's entitlement map cached at build time: // it covers optional engines (e.g. fodid) that may be absent // from the runtime pipeline yet entitled by the Resource Key. // Fall back to live-pipeline introspection for legacy caches // built before engine_properties was stored. if (!empty($cachedPipeline['engine_properties'])) { $properties = $cachedPipeline['engine_properties']; } else { $properties = []; foreach ($engines as $engine) { $properties[$engine] = $pipeline->getElement($engine)->getProperties(); } } Pipeline::$data = [ 'flowData' => $flowData, 'properties' => $properties, 'errors' => $flowData->errors, 'createdAt' => time() ]; } catch (\Throwable $e) { Pipeline::record_runtime_error( 'Pipeline processing failed for an incoming request.', $e ); return; } // If session cache is enabled then store the result in it. if (session_status() == PHP_SESSION_ACTIVE) { $_SESSION['fiftyonedegrees_data'] = Pipeline::$data; } } } /** * Retrieves property by engine and property key. If there is no flow data * available, or it contains errors, then null is returned. * * @param string $engine FlowElementDataKey e.g. device * @param string $key Property Key e.g. browsername * @return null|string Property Value */ public static function get($engine, $key) { $data = Pipeline::$data; if (!$data) { // There is no processed flow data. return null; } if (isset($data['errors']) && count($data['errors'])) { // There were errors from processing. error_log('Errors processing Flow Data' . $data['errors']); return null; } $flowData = $data['flowData']; // Wrap the property dereference: AspectData::__get() throws (via // MissingPropertyService) when the resource key doesn't include the // requested property, which would otherwise short-circuit the // ?? false defences and bubble a fatal up to the renderer. try { $property = $flowData->{$engine}->{$key}; if ($property->hasValue ?? false) { return $property->value; } if ($property->noValueMessage ?? false) { error_log($property->noValueMessage); } } catch (\Throwable $e) { error_log(sprintf( "[51Degrees] Pipeline::get('%s', '%s') no value: %s", $engine, $key, $e->getMessage() )); } return null; } /** * Retrieves processed flow data as a JSON object. * * @return null|object flow data as a JSON Object */ public static function getJSON() { $data = Pipeline::$data; if (!$data) { return null; } if (isset($data['errors']) && count($data['errors'])) { error_log('Errors processing Flow Data' . $data['errors']); return null; } $flowData = $data['flowData']; try { return $flowData->jsonbundler->json; } catch (\Exception $e) { error_log($e->getMessage()); return null; } } /** * Retrieves a properties list for the specified category. * * @param string $category the category name to get properties for * @return null|array the list of properties */ public static function getCategory($category) { $data = Pipeline::$data; if (!$data) { return null; } if (isset($data['errors']) && count($data['errors'])) { error_log('Errors processing Flow Data' . $data['errors']); return null; } $flowData = $data['flowData']; $categoryResults = $flowData->getWhere('category', $category); $output = []; foreach ($categoryResults as $key => $property) { $value = null; if ($property->hasValue) { $value = $property->value; } $output[$key] = $value; } return $output; } /** * Gets client side javascript from FlowData. * * @return null|string the Javascript for the requesting device */ public static function getJavaScript() { $data = Pipeline::$data; if (!$data) { return null; } if (isset($data['errors']) && count($data['errors'])) { error_log('Errors processing Flow Data' . $data['errors']); return null; } $flowData = $data['flowData']; try { return $flowData->javascriptbuilder->javascript; } catch (\Exception $e) { error_log($e->getMessage()); return ''; } } /** * Gets AppContext from the URL. * * @param string $url * @return string the app context */ public static function getAppContext($url) { $urlParts = explode( '/', str_replace('https://', '', str_replace('http://', '', $url)) ); if (count($urlParts) > 1) { return '/' . end($urlParts); } return ''; } /** * Gets the REST API endpoint path for the 51Degrees JSON callback. * Uses WordPress rest_url() to support all permalink structures * (pretty permalinks, plain permalinks, subdirectory installs). * * @return string the endpoint path (e.g. "/wp-json/fiftyonedegrees/v4/json" * or "/?rest_route=/fiftyonedegrees/v4/json") */ public static function getRestEndpoint() { $restUrl = rest_url('fiftyonedegrees/v4/json'); $parsed = parse_url($restUrl); $endpoint = $parsed['path'] ?? '/'; if (isset($parsed['query'])) { $endpoint .= '?' . $parsed['query']; } return $endpoint; } /** * Returns true if the data in the session has been invalidated by * another process updating the pipeline. * * @return bool */ public static function session_is_invalidated() { $createdAt = $_SESSION['fiftyonedegrees_data']['createdAt']; $invalidatedAt = get_option(Options::SESSION_INVALIDATED); return $createdAt < $invalidatedAt; } }