*/ final class ConfigSync { /** * Resource path, relative to the WordPress API base. * * `ACTIRISE_URL_API_V2` already ends in `/api/wordpress/`, so the resource is * named bare here exactly as every other v2 call site does. * * @since 4.3.0 * @var string */ const RESOURCE = 'cmp_configs/'; /** * Whether the API has confirmed this website is in the rollout. * * @since 4.3.0 * @var string */ const OPTION_ENABLED = 'fastcmp-sync-enabled'; /** * The configuration version the plugin currently holds. `0` means Actirise * has nothing published for this domain. * * @since 4.3.0 * @var string */ const OPTION_VERSION = 'fastcmp-sync-version'; /** * When the held version was published, as the API reported it. * * @since 4.3.0 * @var string */ const OPTION_UPDATED_AT = 'fastcmp-sync-updatedat'; /** * Fingerprint of the last configuration the API confirmed. * * @since 4.3.0 * @var string */ const OPTION_DIGEST = 'fastcmp-sync-digest'; /** * Logger context shared by every diagnostic this sync emits. * * @since 4.3.0 * @var string */ const LOG_CONTEXT = 'include/fastcmp'; /** * Option holding the identifier the API resolves this website by. * * The Actirise website uuid, as the API handed it to the plugin at signup. * Not the FastCMP domain uid (`fastcmp-uuid`): that one identifies a domain * inside FastCMP and the API only maps it to a website for the sites its * import command migrated. A website born in WordPress has no such mapping * until its first publication creates one, so addressing the resource by * domain uid answers `404` for exactly the installs this sync exists for. * * @since 4.3.0 * @var string */ const OPTION_IDENTIFIER = 'website-uuid'; /** * How long a "no identifier" diagnostic silences its successors, in seconds. * * The condition is checked on every cron tick and every admin page load, and * the log table keeps the last hundred lines: an unthrottled line would push * every other diagnostic out of it within days. One a day is enough to find. * * @since 4.3.0 * @var int */ const LOG_THROTTLE = 86400; /** * Transient marking that the "no identifier" diagnostic was already logged. * * @since 4.3.0 * @var string */ const LOG_THROTTLE_KEY = 'actirise_fastcmp_sync_unidentified'; /** * Outbound client. * * @since 4.3.0 * @var Api */ private $api; /** * @since 4.3.0 * @param Api|null $api Client to use; a new one by default. */ public function __construct( $api = null ) { $this->api = ( null === $api ) ? new Api() : $api; } /** * Whether this install is an Actirise client the API can resolve. * * Pure option reads and no HTTP: it runs on every admin page load and on * every cron tick, so it must never probe the API to answer "am I a client?". * A site that is not one behaves exactly as it does today, byte for byte. * * The identifier is the whole condition beyond authentication. It is written * by the same API answer that writes `website-id`, so checking both would * only restate one fact — and the FastCMP domain uid, which this gate used to * require, is no longer part of the exchange at all. * * @since 4.3.0 * @return bool */ public static function is_syncable() { if ( ! Helpers::is_user_authenticated() ) { return false; } if ( '' === self::identifier() ) { self::log_missing_identifier(); return false; } return true; } /** * The identifier the resource is addressed by, or `''` when the site has none. * * @since 4.3.0 * @return string */ private static function identifier() { $identifier = Options::get( self::OPTION_IDENTIFIER, '' ); return is_scalar( $identifier ) ? trim( (string) $identifier ) : ''; } /** * Record that an otherwise connected install cannot be addressed. * * A site with credentials but no uuid is an anomaly — the API hands both out * together — and it leaves the sync permanently inactive without a single * request to show for it. Sending an empty or guessed identifier instead * would ask the API about someone else's website, so the only thing left to * do is say so where the publisher's support can read it. * * @since 4.3.0 * @return void */ private static function log_missing_identifier() { if ( false !== get_transient( self::LOG_THROTTLE_KEY ) ) { return; } set_transient( self::LOG_THROTTLE_KEY, true, self::LOG_THROTTLE ); Logger::add_log( 'FastCMP sync inactive: this website has no Actirise uuid stored (' . self::OPTION_IDENTIFIER . '); reconnect the site to Actirise to restore it', self::LOG_CONTEXT, 'error' ); } /** * Whether the hourly reconciliation read should run. * * It is a *fallback*. While the push channel is live the site already * receives every change, and polling on top of it would put the whole install * base back on the hourly requests the push work removed. * * The gate is {@see \Actirise\Includes\Cron::is_push_active()} — the plugin's * single existing definition of "push works here", already used to suppress * the ads.txt follow-up poll. Reusing it rather than re-reading the option * keeps one definition, so the day it gains a condition every caller gains it * too. * * @since 4.3.0 * @return bool */ public static function should_poll() { return self::is_syncable() && ! Cron::is_push_active(); } /** * Whether the write path may run — and, with it, whether a refused save is * allowed to refuse the publisher's edit. * * Being an Actirise client is not enough. `release/wordpress-cmp-sync` is a * per-website decision taken in the API and rolled out progressively *after* * the plugin ships, so the plugin can only observe it: a GET the API answered * is the proof, and a `404` takes it away again. Until then the site behaves * exactly as the current release. * * @since 4.3.0 * @return bool */ public static function armed() { return self::is_syncable() && true === Options::get( self::OPTION_ENABLED, false ); } /** * Whether Actirise holds a published configuration the banner can read. * * Not the same fact as {@see self::armed()}. The API answers * `200 {version: 0, config: null}` for a website in the rollout with nothing * published yet: that answer arms the sync, but hands nothing back, so the * page must keep describing its own banner until a first version exists. * * Pure option reads and no HTTP: it runs on every front-end request. The * version goes through {@see self::held_version()}, because the option store * hands version 1 back as `true`. * * @since 4.3.2 * @return bool */ public static function has_published_config() { return self::armed() && self::held_version() > 0; } /** * Whether the configuration the plugin last had confirmed is this one. * * Used to recognise the echo-back push that follows a save: the API leaves it * unsuppressed and it carries exactly the version the save just stored, so an * equal version is the ordinary case. Content is what separates that echo * from the one situation where an equal version means disagreement — the * pre-commit race, where a rolled-back transaction and the winning one * announce the same number with different bodies. * * Compared on a fingerprint rather than the value, so nothing large is kept * in the option store, and key order is normalised away: both sides come from * the same projection today, but an ordering-sensitive comparison would be a * silent trap the day that changes. * * @since 4.3.0 * @param array $config The configuration to compare. * @return bool */ public static function holds_config( array $config ) { $held = Options::get( self::OPTION_DIGEST, '' ); return is_string( $held ) && '' !== $held && $held === self::digest( $config ); } /** * Fingerprint a configuration, independently of key order. * * @since 4.3.0 * @param array $config The configuration. * @return string */ private static function digest( array $config ) { $encoded = wp_json_encode( self::sort_deep( $config ) ); return is_string( $encoded ) ? sha1( $encoded ) : ''; } /** * Sort an array's keys recursively, leaving lists in their own order. * * @since 4.3.0 * @param array $value The array to sort. * @return array */ private static function sort_deep( array $value ) { foreach ( $value as $key => $item ) { if ( is_array( $item ) ) { $value[ $key ] = self::sort_deep( $item ); } } ksort( $value ); return $value; } /** * Disarm the sync when the API refused the exchange for a reason that will * not change on its own. * * A `404` covers an unknown domain, another organization's website and a * rollout flag that is off — one body for all three, deliberately. The plugin * cannot tell them apart and does not need to: each means "not syncable * here", and the site must go back to behaving exactly as the current * release, saving included. * * A `401` joins them because {@see \Actirise\Includes\Api::request()} has * already deleted the credentials by the time it returns: nothing can be * exchanged until the publisher reconnects, and a form that refuses every * save until then would be worse than an unsynced one. * * Anything else — a timeout, a `5xx`, a rate limit — is transient and leaves * the state untouched. Disarming on a network blink would quietly drop a site * out of the rollout. * * @since 4.3.0 * @param \WP_Error $error The error Api::request() produced. * @return void */ private static function disarm_if_refused( $error ) { $code = (int) $error->get_error_code(); if ( 404 !== $code && 401 !== $code ) { return; } Options::update( self::OPTION_ENABLED, false ); } /** * Read the published configuration and adopt it. * * Also the only place the sync is armed: a GET the API answers proves this * website carries the release flag, which is what later lets a failed save be * refused rather than silently diverging. * * @since 4.3.0 * @return void */ public function pull() { if ( ! self::is_syncable() ) { return; } $response = $this->read(); if ( is_wp_error( $response ) ) { self::disarm_if_refused( $response ); return; } if ( ! is_array( $response ) ) { return; } Options::update( self::OPTION_ENABLED, true ); $this->adopt( $response ); } /** * Read the published configuration, without interpreting the answer. * * @since 4.3.0 * @return mixed */ private function read() { /** @var string $token */ $token = Options::get( 'settings-analytics-token', '' ); return $this->api->get( 'api', self::RESOURCE . self::identifier(), array(), $token ); } /** * Whether a one-off first publication is still owed. * * @since 4.3.0 * @var string */ const OPTION_SEED_PENDING = 'fastcmp-initial-sync-pending'; /** * How many times that publication has been attempted. * * @since 4.3.0 * @var string */ const OPTION_SEED_ATTEMPTS = 'fastcmp-initial-sync-attempts'; /** * Earliest time, as a Unix timestamp, the next attempt may run once the * hourly budget is spent. * * @since 4.3.2 * @var string */ const OPTION_SEED_RETRY_AT = 'fastcmp-initial-sync-retry-at'; /** * Hourly attempts before the seed slows to {@see self::SEED_RETRY_INTERVAL} * (one week of hourly runs). * * @since 4.3.0 * @var int */ const MAX_SEED_ATTEMPTS = 168; /** * Delay between two attempts once the hourly budget is spent, in seconds. * * @since 4.3.2 * @var int */ const SEED_RETRY_INTERVAL = 86400; /** * Arm the one-off first publication. * * Armed for **every** install, including sites that will turn out to have no * Actirise account. At the moment the plugin updates, a free publisher and a * client halfway through onboarding are indistinguishable (neither has * credentials yet), so refusing to arm there would permanently skip anyone * who finishes onboarding afterwards. The flag costs a free publisher one * option read per hourly run and no request at all, so it stays armed until * a publication settles it. * * Also lifts a pending daily retry: a re-arm starts again from hourly * attempts. * * @since 4.3.0 * @return void */ public static function arm_seed() { Options::update( self::OPTION_SEED_PENDING, true ); Options::update( self::OPTION_SEED_ATTEMPTS, 0 ); Options::update( self::OPTION_SEED_RETRY_AT, 0 ); } /** * Arm the first publication again, if none is held yet. * * Up to 4.3.1 the seed gave up after {@see self::MAX_SEED_ATTEMPTS} attempts, * and every hour without credentials or inside a `404` counted as one. Two * populations exhausted it without ever publishing: sites the API refused * while the rollout flag was still off, and sites connected to Actirise more * than a week after their update. The held version tells them apart from * everyone else (`0` means Actirise was never seen to hold anything), so * re-arming on it cannot overwrite a published configuration, and the seed * reads before it writes in any case. * * @since 4.3.2 * @return bool Whether the seed was armed. */ public static function rearm_seed() { if ( self::held_version() > 0 ) { return false; } self::arm_seed(); return true; } /** * Hand this site's configuration to Actirise once, if it is owed. * * Reads before it writes, and that order is the whole safety property: a * blind first publication would replace a configuration curated on the * Actirise side with whatever values happen to sit in this WordPress install. * The seed fills emptiness and nothing else. * * Nothing here can block a publisher — it runs from cron, with no save in * flight — which is why the first publication can afford to be complete * rather than a delta. * * @since 4.3.0 * @return bool Whether the seed ran to a conclusion and is no longer owed. */ public function run_seed() { if ( true !== Options::get( self::OPTION_SEED_PENDING, false ) ) { return false; } // No credentials or no uuid: nothing is requested, so the hour is not an // attempt. Counting it is how a site connected a week after its update // used to find its seed retired. if ( ! self::is_syncable() ) { return false; } if ( ! self::seed_due() ) { return false; } $read = $this->read(); if ( is_wp_error( $read ) ) { self::disarm_if_refused( $read ); return self::defer_seed(); } if ( ! is_array( $read ) ) { return self::defer_seed(); } Options::update( self::OPTION_ENABLED, true ); // Actirise already holds a configuration: it is the source of truth, so // adopt it and consider the first publication settled. if ( $this->adopt( $read ) ) { return self::consume_seed(); } // push() answers whether a save may be kept locally, not whether anything // was published: a `404` or a `401` steps aside and answers true. Only a // held version proves Actirise now holds a configuration. if ( ! $this->push( ConfigPayload::effective() ) ) { return self::defer_seed(); } if ( 0 === self::held_version() ) { return self::defer_seed(); } return self::consume_seed(); } /** * Whether the seed may spend a request on this run. * * Always, within the hourly budget. Past it, only once the daily retry * {@see self::defer_seed()} scheduled is due. * * @since 4.3.2 * @return bool */ private static function seed_due() { return self::int_option( self::OPTION_SEED_RETRY_AT ) <= time(); } /** * Leave the seed owed and count the attempt. * * Never retires it: a retired seed never comes back on its own, and a site * the API refuses today (rollout flag off, domain not known yet) may be * accepted tomorrow. Once {@see self::MAX_SEED_ATTEMPTS} hourly attempts are * spent, the next ones are spaced by {@see self::SEED_RETRY_INTERVAL}, which * caps a site refused for good at one request a day. * * @since 4.3.0 * @return bool */ private static function defer_seed() { $attempts = self::int_option( self::OPTION_SEED_ATTEMPTS ) + 1; Options::update( self::OPTION_SEED_ATTEMPTS, $attempts ); if ( $attempts < self::MAX_SEED_ATTEMPTS ) { return false; } if ( self::MAX_SEED_ATTEMPTS === $attempts ) { Logger::add_log( 'FastCMP initial sync still owed after ' . $attempts . ' attempts; retrying once a day', self::LOG_CONTEXT, 'error' ); } Options::update( self::OPTION_SEED_RETRY_AT, time() + self::SEED_RETRY_INTERVAL ); return false; } /** * Mark the first publication as no longer owed. * * @since 4.3.0 * @return bool */ private static function consume_seed() { Options::update( self::OPTION_SEED_PENDING, false ); return true; } /** * Publish what a WordPress save changed, and report whether the save may be * persisted locally. * * The one entry point the admin save calls, so the decision of "does this * save reach Actirise, and what happens if it cannot" lives here rather than * spread through the AJAX handler. * * Two ways to publish nothing and still let the save through: an install the * rollout has not reached — which includes every free publisher — and a * submission that changed none of the contract's fields. The second is the * common case, since the form posts its whole group every time; spending a * request and a slice of the rate-limit budget to publish an unchanged value * would be pure waste. * * @since 4.3.0 * @param array|object $submitted The keys the admin form posted. * @return bool Whether the caller may persist the save locally. */ public function publish_save( $submitted ) { if ( ! self::armed() ) { return true; } $delta = ConfigPayload::delta( $submitted ); if ( array() === $delta ) { return true; } return $this->push( $delta ); } /** * Publish a save as a new configuration version. * * Returns whether the local write may proceed. A refusal is a refusal of the * publisher's save as a whole: WordPress and Actirise never end up describing * different banners, which is the property this whole path exists to keep. * * A `404` or a `401` are not refusals in that sense — they mean the sync is * not live here, so the sync steps aside and the save behaves exactly as it * does in the current release. * * @since 4.3.0 * @param array $delta The changed fields, as ConfigPayload builds them. * @return bool Whether the caller may persist the save locally. */ public function push( array $delta ) { // The public entry point of the write path, so the identifier is checked // here rather than only at the gates that happen to call it today: an // unaddressable site must step aside, never PATCH an empty resource path. if ( ! self::is_syncable() ) { return true; } $response = $this->send( $delta, self::held_version() ); if ( ! is_wp_error( $response ) ) { $this->adopt( is_array( $response ) ? $response : array() ); return true; } return $this->resolve( $response, $delta ); } /** * Apply a configuration the API pushed. * * The version is monotonic per website, which gives three cases rather than * the obvious two: * * - **newer** than the one held: apply the pushed body. * - **equal**: compare the content. Identical is the echo of our own save — * the API leaves it unsuppressed and it carries exactly the version that * save stored, so this is the ordinary case and it must cost nothing. * Different is the one situation where an equal version means disagreement, * and a disagreement is settled by reading from the API rather than by * believing either body. * - **older**: ignore. A delayed or reordered delivery is never a rollback. * * That middle case is not hypothetical. The push is dispatched *before* the * transaction commits, so the loser of a write race announces a version it * then rolls back while the winner announces the same number with different * content. Applying the pushed body on an equal version would keep the * loser's configuration for good, with no screen to reveal it. * * @since 4.3.0 * @param array $envelope The pushed `{version, updatedAt, config}`. * @return bool Whether the event may be marked processed. */ public function apply_pushed( array $envelope ) { $version = isset( $envelope['version'] ) && is_numeric( $envelope['version'] ) ? (int) $envelope['version'] : 0; $held = self::held_version(); if ( $version < $held ) { return true; } $config = isset( $envelope['config'] ) && is_array( $envelope['config'] ) ? $envelope['config'] : null; if ( null === $config ) { return true; } if ( $version > $held ) { $this->adopt( $envelope ); return true; } if ( self::holds_config( $config ) ) { return true; } $this->pull(); return true; } /** * The configuration version the plugin currently believes Actirise holds. * * @since 4.3.0 * @return int */ private static function held_version() { return self::int_option( self::OPTION_VERSION ); } /** * Read an option that holds a counter. * * {@see \Actirise\Includes\Options::get()} maps a stored `'0'` or `'1'` back * to a **boolean**, and everything else comes back as a numeric string — the * store writes scalars into a text column and only re-types those two values. * So a version of 1, or a first attempt, arrives as `true`, which no * `is_numeric()` guard would accept. Casting the boolean recovers the exact * value it stood for. * * Getting this wrong is quiet and expensive: a site sitting at version 1 would * announce `baseVersion: 0` on every save and conflict every time, and a * counter would stick at 1 and never reach its cap. * * @since 4.3.0 * @param string $option Full option name. * @return int */ private static function int_option( $option ) { $value = Options::get( $option, 0 ); if ( is_bool( $value ) ) { return (int) $value; } return is_numeric( $value ) ? (int) $value : 0; } /** * Send one PATCH. * * @since 4.3.0 * @param array $config The fields to publish. * @param int $base_version The version this save is based on. * @return mixed */ private function send( array $config, $base_version ) { /** @var string $token */ $token = Options::get( 'settings-analytics-token', '' ); return $this->api->patch( 'api', self::RESOURCE . self::identifier(), array( 'baseVersion' => $base_version, 'config' => $config, ), $token ); } /** * Decide what a refused PATCH means for the publisher's save. * * @since 4.3.0 * @param \WP_Error $error The error Api::request() produced. * @param array $delta The fields the save was publishing. * @return bool Whether the caller may persist the save locally. */ private function resolve( $error, array $delta ) { $code = (int) $error->get_error_code(); if ( 404 === $code || 401 === $code ) { self::disarm_if_refused( $error ); return true; } if ( 409 === $code ) { return $this->arbitrate( $error, $delta ); } if ( 422 === $code ) { self::log_violations( $error ); } return false; } /** * Record which fields the API refused. * * A refused save is only better than a silent divergence if somebody can find * out *what* was refused. Property paths and nothing else: the values * themselves are the publisher's configuration and have no business in a log. * * @since 4.3.0 * @param \WP_Error $error The validation error. * @return void */ private static function log_violations( $error ) { $body = $error->get_error_data(); $violations = ( is_array( $body ) && isset( $body['violations'] ) && is_array( $body['violations'] ) ) ? $body['violations'] : array(); $paths = array(); foreach ( $violations as $violation ) { $violation = (array) $violation; if ( isset( $violation['propertyPath'] ) && is_string( $violation['propertyPath'] ) ) { $paths[] = $violation['propertyPath']; } } Logger::add_log( 'FastCMP save refused by the API; fields rejected: ' . ( array() === $paths ? '(none reported)' : implode( ', ', $paths ) ), self::LOG_CONTEXT, 'error' ); } /** * Settle a conflict field by field, then replay the save once. * * Actirise moved on while the publisher was editing. The fields they touched * are theirs and get republished; every other field is Actirise's and is * adopted locally. Neither side silently overwrites the other. * * The conflict body normally carries the current version *and* the current * projection, so this costs one replayed PATCH and no extra GET. On a genuine * write race the API cannot report either — its transaction is broken at that * point — and a GET supplies the same material instead. * * Exactly one replay. Looping on a refreshed `baseVersion` would eventually * push a stale edit over a newer configuration, which is the silent overwrite * this whole path exists to prevent. * * @since 4.3.0 * @param \WP_Error $error The conflict. * @param array $delta The fields the save was publishing. * @return bool Whether the caller may persist the save locally. */ private function arbitrate( $error, array $delta ) { $body = $error->get_error_data(); $body = is_array( $body ) ? $body : array(); $current = isset( $body['config'] ) && is_array( $body['config'] ) ? $body['config'] : null; if ( null === $current || ! isset( $body['currentVersion'] ) ) { $this->pull(); } else { // Only the fields the publisher left alone: theirs are about to be // republished, and adopting them here would undo their edit. ConfigPayload::apply( array_diff_key( $current, $delta ) ); Options::update( self::OPTION_VERSION, is_numeric( $body['currentVersion'] ) ? (int) $body['currentVersion'] : 0 ); } $response = $this->send( $delta, self::held_version() ); if ( is_wp_error( $response ) ) { return false; } $this->adopt( is_array( $response ) ? $response : array() ); return true; } /** * Adopt a `{version, updatedAt, config}` envelope: apply the configuration and * record the version it belongs to. * * The GET, the PATCH response and the push all carry this same shape from the * same projection, so they all land here. * * A `config` of `null` with version `0` is the documented first-access * sentinel: Actirise holds nothing. Every local value is kept — writing an * empty configuration would blank a live consent banner — and the held version * stays `0`, which is what the first publication sends as its `baseVersion`. * * @since 4.3.0 * @param array $envelope The API envelope. * @return bool Whether a configuration was applied. */ private function adopt( array $envelope ) { if ( ! isset( $envelope['config'] ) || ! is_array( $envelope['config'] ) ) { return false; } ConfigPayload::apply( $envelope['config'] ); Options::update( self::OPTION_DIGEST, self::digest( $envelope['config'] ) ); Options::update( self::OPTION_VERSION, isset( $envelope['version'] ) && is_numeric( $envelope['version'] ) ? (int) $envelope['version'] : 0 ); if ( isset( $envelope['updatedAt'] ) && is_scalar( $envelope['updatedAt'] ) ) { Options::update( self::OPTION_UPDATED_AT, (string) $envelope['updatedAt'] ); } return true; } }