uniqid, used as the remove key * field => 'email' | 'phone' | 'name' * hash => sha256 of the normalised value (SAME normalisation as scoring) * hashes => every spelling that must match (phones) * masked => display-safe remnant ("i••••@gmail.com") -- NEVER the raw value * reason => reason KEY from orderguard_exclusion_reasons() (local only) * by => display name of the admin who added it (local only) * ts => unix time * * THE RAW EMAIL/PHONE/NAME IS NEVER STORED. Only: * - a one-way SHA-256 hash, for matching. Not encryption -- there is no key * that could reverse it, because it was never reversible. The plaintext is * discarded the moment the hash is taken. * - a masked remnant, for the merchant to recognise their own entry. * * WHY IT MATTERS HERE: orderguard_exclusions lives in wp_options, so any plugin, theme, * or options-exporting tool on the site can read it via get_option(). Storing * plaintext addresses would hand every one of them a tidy list of exactly the * people this feature exists to protect. The option is also autoload=false (see * orderguard_exclusions_save), so it is never pulled into anonymous front-end requests. * * Note the hashes are UNPEPPERED here (the pepper only ever exists server-side), * so they are not a secret against an attacker who already has the site's DB and * wants to test a guess. They are not meant to be: the guarantee is that * OrderGuard itself never writes these people's plaintext anywhere, and that * nothing about them ever leaves the store. */ /** * Reasons offered in the UI. Stored as a key, rendered as a translated label, * so the audit trail doesn't freeze one language into the database. */ function orderguard_exclusion_reasons() { // No "customer objected" option. This list is for the merchant's OWN and // their staff's accounts -- a customer objecting to processing is a // different thing entirely, handled by the data-request tools, and offering // it here would invite merchants to use this list for arbitrary customers. return array( 'staff' => __( 'Staff / test', 'orderguard' ), 'own' => __( 'Own company number', 'orderguard' ), 'other' => __( 'Other reason', 'orderguard' ), ); } function orderguard_exclusion_reason_label( $key ) { $reasons = orderguard_exclusion_reasons(); if ( isset( $reasons[ $key ] ) ) { return $reasons[ $key ]; } // Free-text reasons written by an earlier build: show them as-is. return (string) $key; } function orderguard_exclusions_get( &$ok = null ) { $list = orderguard_get_option( 'orderguard_exclusions', array(), $ok ); return is_array( $list ) ? $list : array(); } /** * Single writer. Always refreshes the static map so a guard later in the SAME * request can't read a stale list. * * autoload = NO, deliberately. These are UNPEPPERED sha256 hashes (the pepper * only ever exists server-side), and a phone number's search space is ~1e9 -- * i.e. brute-forceable in minutes. So this option is a functionally reversible * record of exactly the people the merchant asked us to stop processing. * Autoloading it would push that into wp_load_alloptions() on EVERY front-end * request -- including anonymous ones -- where any plugin, theme, or * site-health/migration exporter can read it via get_option(). The guards that * need it are on outbound calls and admin screens, both of which can afford one * query (and it is object-cached + static-cached per request anyway). */ function orderguard_exclusions_save( $list ) { // TASK 50: the Review page's history section and the sidebar count are cached // (twelve hours and five minutes). Excluding someone must take them off those // surfaces at once -- a person who is still listed by name, with a Block COD // button, ten minutes after being excluded is the "the exclusion didn't work" // failure this file exists to prevent. The section also re-checks exclusion at // render for the rows it draws; this covers the count, which cannot. if ( function_exists( 'orderguard_review_queue_invalidate' ) ) { orderguard_review_queue_invalidate(); } orderguard_update_option( 'orderguard_exclusions', array_values( (array) $list ), false ); orderguard_exclusion_hash_map( true ); if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) { orderguard_owned_exclusion_reset_request_snapshot(); } delete_transient( 'orderguard_exclusion_source_notice_reason' ); if ( function_exists( 'orderguard_resume_import_after_exclusion_change' ) ) { orderguard_resume_import_after_exclusion_change(); } } /** Refresh exclusion-derived caches after one verified atomic mutation. */ function orderguard_exclusions_after_atomic_write() { orderguard_invalidate_option_caches( 'orderguard_exclusions', false ); orderguard_exclusion_hash_map( true ); if ( function_exists( 'orderguard_review_queue_invalidate' ) ) { orderguard_review_queue_invalidate(); } if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) { orderguard_owned_exclusion_reset_request_snapshot(); } delete_transient( 'orderguard_exclusion_source_notice_reason' ); if ( function_exists( 'orderguard_resume_import_after_exclusion_change' ) ) { orderguard_resume_import_after_exclusion_change(); } } /** The owned reader deliberately refuses larger sources. Keep the writer inside * the same contract so a merchant can never save a list that disables every * OrderGuard network feature. */ function orderguard_exclusion_capacity_error( $list, $pending_hashes = array() ) { if ( ! is_array( $list ) || count( $list ) >= 500 ) { return new WP_Error( 'orderguard_excl_limit', __( 'The exclusion list is full. Remove an old exclusion before adding another.', 'orderguard' ) ); } $unique = array(); foreach ( $list as $entry ) { $values = isset( $entry['hashes'] ) && is_array( $entry['hashes'] ) ? $entry['hashes'] : array(); if ( isset( $entry['hash'] ) ) { $values[] = $entry['hash']; } foreach ( $values as $hash ) { if ( is_string( $hash ) ) { $unique[ $hash ] = true; } } } foreach ( (array) $pending_hashes as $hash ) { if ( is_string( $hash ) ) { $unique[ $hash ] = true; } } return count( $unique ) > 500 ? new WP_Error( 'orderguard_excl_limit', __( 'The exclusion list is full. Remove an old exclusion before adding another.', 'orderguard' ) ) : null; } /** Explain durable source failures on the dashboard and OrderGuard screens. */ add_action( 'admin_notices', 'orderguard_exclusion_source_admin_notice' ); /** * Mutate the shared exclusion option without overwriting another request. * * The callback receives the freshest list and returns array( 'list' => ..., * 'result' => ... ). Exact-value compare-and-swap retries only unrelated * changes. A callback may return WP_Error to refuse without writing. */ function orderguard_exclusions_atomic_mutate( $callback, &$mutation_result = null ) { global $wpdb; if ( ! is_callable( $callback ) ) { return false; } $option_name = 'orderguard_exclusions'; $write_attempted = false; for ( $attempt = 0; $attempt < 10; ++$attempt ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact bytes are required for concurrency-safe mutation. if ( '' !== $wpdb->last_error ) { return false; } $found = null !== $raw; if ( $found ) { $list = maybe_unserialize( $raw ); } else { // Fleet bridge safety: if the renamed row has not been copied yet, // seed the atomic write from the compatibility accessor rather // than replacing every legacy exclusion with this one mutation. $fallback_ok = false; $list = orderguard_exclusions_get( $fallback_ok ); if ( ! $fallback_ok ) { return false; } } if ( ! is_array( $list ) ) { return false; } $change = call_user_func( $callback, array_values( $list ), $write_attempted ); if ( is_wp_error( $change ) ) { $mutation_result = $change; return true; } if ( ! is_array( $change ) || ! array_key_exists( 'list', $change ) ) { return false; } $next = array_values( (array) $change['list'] ); $mutation_result = $change['result'] ?? null; $serialised = maybe_serialize( $next ); if ( $found && $serialised === $raw ) { orderguard_exclusions_after_atomic_write(); return true; } if ( ! $found ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $written = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'off')", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $option_name, $serialised ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- atomic first exclusion writer. } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- OrderGuard's own tables or exact transactional reads/writes; no WordPress API covers these; must read current database state (locks, claims, delete checks); caching would be wrong $written = $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- current blog's core options table. $serialised, $option_name, $raw ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- exact-value exclusion compare-and-swap. } $write_attempted = true; orderguard_invalidate_option_caches( $option_name, false ); if ( false === $written ) { // A database proxy can lose the acknowledgement after commit. The // next fresh iteration either observes the callback as an // idempotent no-op or safely reapplies it to concurrent changes. continue; } if ( 1 === (int) $written ) { orderguard_exclusions_after_atomic_write(); return true; } } return false; } /** * Flat hash => true map for O(1) matching. Static-cached per request: the * guard runs on every outbound call, so this must never be a repeated * unserialise. */ function orderguard_exclusion_hash_map( $force_refresh = false ) { static $map = null; if ( null !== $map && ! $force_refresh ) { return $map; } $map = array(); foreach ( orderguard_exclusions_get() as $entry ) { // 'hashes' (plural) is the current shape -- a phone entry matches every // written form of the number. 'hash' is kept for entries written by an // earlier version, so an upgrade never silently un-excludes anyone. if ( ! empty( $entry['hashes'] ) && is_array( $entry['hashes'] ) ) { foreach ( $entry['hashes'] as $hash ) { if ( is_string( $hash ) && '' !== $hash ) { $map[ $hash ] = true; } } } if ( ! empty( $entry['hash'] ) && is_string( $entry['hash'] ) ) { $map[ $entry['hash'] ] = true; } } return $map; } /** * Hash a raw value the way the exclusion list matches it. * * Deliberately mirrors orderguard_hash_identity()'s per-field normalisation: phone goes * through orderguard_phone_hash (digits + leading '+'), everything else through * orderguard_hash_value (lowercase + trim). If these ever drift apart, exclusions stop * matching and the merchant gets no error -- just silence. Keep them together. */ function orderguard_exclusion_hash( $field, $value ) { $value = (string) $value; if ( '' === trim( $value ) ) { return ''; } return ( 'phone' === $field ) ? orderguard_phone_hash( $value ) : orderguard_hash_value( $value ); } /** * Plausible written forms of the SAME phone number, so an exclusion matches * however the number happens to be stored on the order. * * WHY THIS EXISTS -- a real trap, caught by testing the matcher: * orderguard_normalize_phone() only strips formatting; it does NOT map national to * international form (its docblock says so, deferring it to the corroboration * build). So sha256("0888123456") != sha256("+359888123456") -- the same human, * two hashes. For scoring that's a known limitation. For EXCLUSIONS it is a * silent failure: the merchant types their tester's number one way, the orders * store it the other, nothing matches, and there is NO error to notice -- just * test signals quietly continuing to leak. * * So an exclusion entry carries a hash for every variant. This is deliberately * one-directional: it can only ever exclude MORE spellings of the number the * merchant already typed. It cannot pull in a different person, because every * variant is generated from the same digits. * * The national->international expansion uses the STORE'S OWN country dial code, * never a guess across countries. Trying all of BG/RO/GR would mean excluding * "0888123456" also excludes "+40888123456" -- a Romanian number that is a * different human who merely shares subscriber digits. Harmless-looking, but it * would silently stop scoring a real customer. * * @return string[] normalised phone strings (not hashes). */ function orderguard_exclusion_store_dial_code() { $map = array( 'BG' => '359', 'RO' => '40', 'GR' => '30' ); $country = ''; if ( function_exists( 'wc_get_base_location' ) ) { $base = wc_get_base_location(); $country = isset( $base['country'] ) ? strtoupper( (string) $base['country'] ) : ''; } if ( '' === $country ) { $raw = (string) get_option( 'woocommerce_default_country', '' ); $country = strtoupper( substr( $raw, 0, 2 ) ); } return isset( $map[ $country ] ) ? $map[ $country ] : ''; } function orderguard_exclusion_phone_variants( $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return array(); } $digits = preg_replace( '/\D+/', '', $value ); $has_plus = ( 0 === strpos( $value, '+' ) ); if ( '' === $digits ) { return array(); } $variants = array(); $variants[] = ( $has_plus ? '+' : '' ) . $digits; // exactly as typed (normalised) // "00359..." is the same as "+359..." if ( ! $has_plus && 0 === strpos( $digits, '00' ) ) { $digits = substr( $digits, 2 ); $has_plus = true; $variants[] = '+' . $digits; } $cc = orderguard_exclusion_store_dial_code(); if ( $has_plus ) { // International -> national, ONLY for this store's own country. // // Stripping ANY known dial code here would be destructive. A BG store // excluding a Romanian tester "+40721123456" would generate "0721123456" // -- a perfectly valid BULGARIAN number belonging to a different human. // That hash would enter the local map (silently unscoring a real // customer) AND go to /v1/exclude, deleting that real customer's // sightings. Shortening a number across a country code moves it into a // different namespace; only the store's own code is safe to strip, // because only then do the national and international forms denote the // same person. if ( '' !== $cc && 0 === strpos( $digits, $cc ) ) { $rest = substr( $digits, strlen( $cc ) ); if ( '' !== $rest ) { $variants[] = '0' . $rest; $variants[] = $rest; $variants[] = $digits; // bare, no plus } } } elseif ( '' !== $cc && 0 === strpos( $digits, $cc ) ) { // Bare international form without '+' ("359888..."). It names this // store's own country unambiguously, so derive the same safe set as // the +359 spelling. Without this branch erasure/exclusion fingerprints // diverge even though the network body derives the canonical E.164 hash. $rest = substr( $digits, strlen( $cc ) ); if ( '' !== $rest ) { $variants[] = '+' . $digits; $variants[] = '0' . $rest; $variants[] = $rest; } } elseif ( '' !== $cc && 0 === strpos( $digits, '0' ) ) { // National -> international, using THIS STORE'S country only. $rest = ltrim( $digits, '0' ); if ( '' !== $rest ) { $variants[] = '+' . $cc . $rest; $variants[] = $cc . $rest; $variants[] = $rest; } } return array_values( array_unique( array_filter( $variants ) ) ); } /** * Every hash an exclusion entry should match on. */ function orderguard_exclusion_hashes_for( $field, $value ) { if ( 'phone' !== $field ) { $hash = orderguard_exclusion_hash( $field, $value ); return ( '' !== $hash ) ? array( $hash ) : array(); } $out = array(); foreach ( orderguard_exclusion_phone_variants( $value ) as $variant ) { $hash = orderguard_hash_value( $variant ); if ( '' !== $hash ) { $out[] = $hash; } } return array_values( array_unique( $out ) ); } /** * Every identity hash on an order, for exclusion matching ONLY. * * IMPORTANT: this deliberately ignores orderguard_identity_fields()' enabled flags and * hashes email + phone + name unconditionally. 'name' is disabled by default * for scoring, so honouring the flags here would mean a merchant excludes a * staff member by name, sees them listed, and the exclusion silently never * matches anything. Hashing all three is safe in one direction only: it can * only ever cause us to send LESS data, never more. */ function orderguard_exclusion_order_hashes( $order ) { if ( ! $order instanceof WC_Order ) { return array(); } $raw = array( 'email' => $order->get_billing_email(), 'phone' => $order->get_billing_phone(), 'name' => trim( $order->get_billing_first_name() . ' ' . $order->get_billing_last_name() ), ); $out = array(); foreach ( $raw as $field => $value ) { $hash = orderguard_exclusion_hash( $field, $value ); if ( '' !== $hash ) { $out[] = $hash; } } // H-2: also match the order phone's E.164 spelling (billing-country keyed). // Closes a real gap the store-dial-code variants above cannot: a FOREIGN // tester excluded as "+40721123456" whose order stores "0721123456" with // billing country RO now matches via the derived "+40721123456" hash. // Safe in one direction only, like everything in this file: an extra hash // here can only ever cause LESS data to be sent, never more. if ( function_exists( 'orderguard_phone_hash_e164' ) ) { $e164 = orderguard_phone_hash_e164( $order->get_billing_phone(), $order->get_billing_country() ); if ( '' !== $e164 ) { $out[] = $e164; } } return $out; } /** * TRUE when any of the supplied hashes is excluded. */ function orderguard_is_excluded_hash( $hashes ) { $hashes = (array) $hashes; if ( empty( $hashes ) ) { return false; } $map = orderguard_exclusion_hash_map(); if ( empty( $map ) ) { return false; // fast path: no list -> zero cost on every call } foreach ( $hashes as $hash ) { if ( '' !== $hash && isset( $map[ $hash ] ) ) { return true; } } return false; } /** * TRUE when this ORDER belongs to an excluded identity. */ function orderguard_is_excluded_order( $order ) { if ( ! $order instanceof WC_Order ) { return false; } if ( empty( orderguard_exclusion_hash_map() ) ) { return false; } return orderguard_is_excluded_hash( orderguard_exclusion_order_hashes( $order ) ); } /** * TRUE when an order_ref (as carried by events) belongs to an excluded * identity. Events carry no hashes, so the order must be re-read. */ function orderguard_is_excluded_order_ref( $order_ref ) { if ( empty( orderguard_exclusion_hash_map() ) ) { return false; // fast path -- avoid a wc_get_order() on every event } $order_ref = trim( (string) $order_ref ); if ( '' === $order_ref || ! function_exists( 'wc_get_order' ) ) { return false; } $order = wc_get_order( $order_ref ); return ( $order instanceof WC_Order ) ? orderguard_is_excluded_order( $order ) : false; } /** * TRUE when an internal hashed-identity array (orderguard_hash_identity output, keys * phone/email/name) is excluded. */ function orderguard_is_excluded_identity( $hashed ) { if ( ! is_array( $hashed ) ) { return false; } return orderguard_is_excluded_hash( array_values( array_filter( $hashed, 'is_string' ) ) ); } /** * Mask a value for display. Never reversible, just enough for the merchant to * recognise their own entry. */ function orderguard_exclusion_mask( $field, $value ) { $value = trim( (string) $value ); if ( '' === $value ) { return ''; } if ( 'email' === $field && false !== strpos( $value, '@' ) ) { list( $local, $domain ) = explode( '@', $value, 2 ); $keep = ( strlen( $local ) > 1 ) ? substr( $local, 0, 1 ) : $local; return $keep . str_repeat( '•', max( 3, min( 6, strlen( $local ) - 1 ) ) ) . '@' . $domain; } if ( 'phone' === $field ) { $digits = preg_replace( '/\D+/', '', $value ); $tail = substr( $digits, -3 ); return ( 0 === strpos( trim( $value ), '+' ) ? '+' : '' ) . substr( $digits, 0, 3 ) . ' ••• ••• ' . $tail; } // name -> initials of each word $parts = preg_split( '/\s+/', $value ); $out = array(); foreach ( $parts as $p ) { if ( '' === $p ) { continue; } $first = function_exists( 'mb_substr' ) ? mb_substr( $p, 0, 1 ) : substr( $p, 0, 1 ); $len = function_exists( 'mb_strlen' ) ? mb_strlen( $p ) : strlen( $p ); $out[] = $first . str_repeat( '•', max( 2, min( 5, $len - 1 ) ) ); } return implode( ' ', $out ); } /** * Guess which field a typed value is. */ function orderguard_exclusion_detect_field( $value ) { $value = trim( (string) $value ); if ( is_email( $value ) ) { return 'email'; } $digits = preg_replace( '/\D+/', '', $value ); if ( '' !== $digits && strlen( $digits ) >= 6 && preg_match( '/^\+?[\d\s\-().]+$/', $value ) ) { return 'phone'; } return 'name'; } /** * Add an entry. Returns the new entry, or WP_Error. */ function orderguard_exclusion_add( $value, $reason = '' ) { $value = trim( (string) $value ); if ( '' === $value ) { return new WP_Error( 'orderguard_excl_empty', __( 'Enter an email, phone or name.', 'orderguard' ) ); } $field = orderguard_exclusion_detect_field( $value ); $hashes = orderguard_exclusion_hashes_for( $field, $value ); if ( empty( $hashes ) ) { return new WP_Error( 'orderguard_excl_hash', __( 'That value could not be read.', 'orderguard' ) ); } // Reason is a KEY from orderguard_exclusion_reasons(); anything else is discarded // rather than stored, so this field can never become a free-text sink. $reason = (string) $reason; if ( ! array_key_exists( $reason, orderguard_exclusion_reasons() ) ) { $reason = 'other'; } $user = wp_get_current_user(); $entry = array( 'id' => uniqid( 'ogx_', false ), 'field' => $field, 'hash' => $hashes[0], // primary (as typed) -- kept for readability 'hashes' => $hashes, // every spelling that must match // Masked remnant only. $value is deliberately NOT stored -- once the // hashes are taken the plaintext is dropped on the floor. 'masked' => orderguard_exclusion_mask( $field, $value ), 'reason' => $reason, 'by' => ( $user && $user->exists() ) ? $user->display_name : '', 'ts' => time(), 'erase_pending' => true, // cleared once the server confirms the retraction // Every retry replays this one deletion operation. A timeout can never // mint a second logical request and an unsigned reply can never clear it. 'erase_operation_id' => bin2hex( random_bytes( 32 ) ), 'erase_attempts' => 0, 'erase_next_attempt_at' => time(), ); if ( ! function_exists( 'orderguard_acquire_gdpr_erasure_lifecycle_lock' ) || ! orderguard_plugin_is_active_for_erasure() || ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) { return new WP_Error( 'orderguard_excl_read', __( 'The exclusion list could not be read. Nothing was changed; please try again.', 'orderguard' ) ); } try { if ( ! orderguard_plugin_is_active_for_erasure() ) { return new WP_Error( 'orderguard_excl_inactive', __( 'OrderGuard is not active. Nothing was changed.', 'orderguard' ) ); } if ( 'name' !== $field ) { $installation_id = orderguard_exclusion_installation_id( true ); if ( '' !== $installation_id ) { // Frozen with the operation in the same exclusion-row CAS below. $entry['erase_installation_id'] = $installation_id; } } $result = null; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $entry, $hashes ) { foreach ( $list as $current ) { if ( isset( $current['id'] ) && (string) $current['id'] === (string) $entry['id'] ) { return array( 'list' => $list, 'result' => $current ); } $current_hashes = isset( $current['hashes'] ) && is_array( $current['hashes'] ) ? $current['hashes'] : ( isset( $current['hash'] ) ? array( $current['hash'] ) : array() ); if ( array_intersect( $hashes, $current_hashes ) ) { return new WP_Error( 'orderguard_excl_dupe', __( 'That customer is already excluded.', 'orderguard' ) ); } } $capacity_error = orderguard_exclusion_capacity_error( $list, $hashes ); if ( is_wp_error( $capacity_error ) ) { return $capacity_error; } $list[] = $entry; return array( 'list' => $list, 'result' => $entry ); }, $result ); if ( ! $saved ) { return new WP_Error( 'orderguard_excl_write', __( 'The exclusion list could not be saved. Nothing was changed; please try again.', 'orderguard' ) ); } return $result; } finally { orderguard_release_gdpr_erasure_lifecycle_lock(); } } /** * Remove an entry by id. Note this does NOT restore erased signals -- they are * gone from the server for good. Removing simply stops excluding from now on. */ function orderguard_exclusion_remove( $id ) { $id = (string) $id; if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) { orderguard_exclusion_notice( 'error', __( 'The exclusion list could not be read. Nothing was changed; please try again.', 'orderguard' ) ); return false; } try { $found = false; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id ) { $out = array(); $found = false; foreach ( $list as $entry ) { if ( isset( $entry['id'] ) && (string) $entry['id'] === $id ) { // The entry is the durable source for an unfinished // server deletion. Removing it would also remove the // hashes and make the advertised automatic retry // impossible, so refuse until the server confirms. if ( ! empty( $entry['erase_pending'] ) && ! ( ! empty( $entry['erase_waiting_for_receipt_key'] ) && array_key_exists( 'erase_attempts', $entry ) && 0 === (int) ( $entry['erase_attempts'] ?? 0 ) ) ) { return new WP_Error( 'orderguard_excl_erase_pending', ! empty( $entry['erase_waiting_for_receipt_key'] ) ? __( 'This customer cannot be removed yet. Network removal is waiting for server confirmation to be set up.', 'orderguard' ) : __( 'This customer cannot be removed yet because deletion of previously sent signals is still in progress.', 'orderguard' ) ); } $found = true; continue; } $out[] = $entry; } return array( 'list' => $out, 'result' => $found || $write_attempted ); }, $found ); if ( ! $saved ) { orderguard_exclusion_notice( 'error', __( 'The exclusion list could not be saved. Nothing was changed; please try again.', 'orderguard' ) ); return false; } if ( is_wp_error( $found ) ) { orderguard_exclusion_notice( 'warning', $found->get_error_message() ); return false; } return (bool) $found; } finally { orderguard_release_gdpr_erasure_lifecycle_lock(); } } /** Independently pinned verifier configuration. Production uses only the packaged keys; * ORDERGUARD_EXCLUSION_RECEIPT_KEYS and the development filters work only in explicit dev mode. */ function orderguard_exclusion_receipt_configuration() { // Packaged public keys are the default; in dev mode an explicit empty override stays empty. // Malformed bundled configuration fails closed instead of silently dropping revocations. $bundled = array( 'active'=>array(), 'revoked'=>array(), 'environment'=>'production' ); try { if ( is_readable( __DIR__ . '/receipt-key-registry.php' ) ) { $bundled = require __DIR__ . '/receipt-key-registry.php'; } if ( ! is_array( $bundled ) || ! is_array( $bundled['active'] ?? null ) || ! is_array( $bundled['revoked'] ?? null ) || ! is_string( $bundled['environment'] ?? null ) ) { throw new UnexpectedValueException( 'invalid_key_registry' ); } $bundled_keys = $bundled['active']; foreach ( $bundled['revoked'] as $id => $flag ) { if ( ! is_string( $id ) || ! preg_match( '/^(prod|test)-[a-z0-9-]{1,48}$/D', $id ) || true !== $flag ) { throw new UnexpectedValueException( 'invalid_key_registry' ); } unset( $bundled_keys[$id] ); } } catch ( Throwable $error ) { return array( 'status'=>'waiting_for_receipt_key', 'reason'=>'invalid_key_registry', 'keys'=>array(), 'environment'=>'production' ); } $dev_mode = function_exists( 'orderguard_dev_mode' ) && orderguard_dev_mode(); // Outside explicit development mode a configured key list is refused, never // selected: removals wait (no usable keys) and the admin notice names the constant. if ( defined( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) && ! $dev_mode ) { return array( 'status' => 'waiting_for_receipt_key', 'reason' => 'receipt_key_override_refused', 'keys' => array(), 'environment' => 'production' ); } $keys = defined( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) && is_array( constant( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) ) ? constant( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) : $bundled_keys; if ( defined( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) && ! is_array( constant( 'ORDERGUARD_EXCLUSION_RECEIPT_KEYS' ) ) ) { $keys = null; } // Likewise a non-production environment setting outside development mode is // refused by name, so the waiting message points at wp-config.php. if ( defined( 'ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT' ) && ! $dev_mode && 'production' !== constant( 'ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT' ) ) { return array( 'status' => 'waiting_for_receipt_key', 'reason' => 'receipt_environment_override_refused', 'keys' => array(), 'environment' => 'production' ); } $environment = defined( 'ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT' ) ? constant( 'ORDERGUARD_EXCLUSION_RECEIPT_ENVIRONMENT' ) : $bundled['environment']; if ( $dev_mode ) { $keys = apply_filters( 'orderguard_dev_exclusion_receipt_keys', $keys ); $environment = apply_filters( 'orderguard_dev_exclusion_receipt_environment', $environment ); } if ( ! is_array( $keys ) || ! is_string( $environment ) || ( ! $dev_mode && $environment !== 'production' ) ) { return array( 'status' => 'waiting_for_receipt_key', 'reason' => 'invalid_key_registry', 'keys' => array(), 'environment' => 'production' ); } // A constant or development filter cannot restore a shipped revocation. foreach ( $bundled['revoked'] as $id => $flag ) { unset( $keys[$id] ); } return array_merge( OG_Exclusion_Erase_Binding::verifier( $keys, $environment ), array( 'keys' => $keys, 'environment' => $environment ) ); } /** * Read or deliberately create the exclusion route's own installation ID. * * This identifier is independent of the GDPR-erasure lifecycle. add_option() * is the concurrency boundary: two first exclusions can generate candidates, * but every caller subsequently reads the one database winner. */ function orderguard_exclusion_installation_id( $create = false ) { $read_ok = false; $value = orderguard_get_option( 'orderguard_exclusion_installation_id', '', $read_ok ); if ( $read_ok && is_string( $value ) && preg_match( '/^[a-f0-9]{64}$/D', $value ) ) { return $value; } // Never replace unreadable or malformed state: that could change the // installation binding of an operation already accepted by the server. if ( ! $create || ! $read_ok || ( is_string( $value ) && '' !== $value ) || ! function_exists( 'add_option' ) ) { return ''; } try { $candidate = bin2hex( random_bytes( 32 ) ); } catch ( Throwable $throwable ) { return ''; } add_option( 'orderguard_exclusion_installation_id', $candidate, '', false ); orderguard_invalidate_option_caches( 'orderguard_exclusion_installation_id', false ); $winner_ok = false; $winner = orderguard_get_option( 'orderguard_exclusion_installation_id', '', $winner_ok ); return $winner_ok && is_string( $winner ) && preg_match( '/^[a-f0-9]{64}$/D', $winner ) ? $winner : ''; } /** * Ask the server to erase THIS STORE'S OWN past signals for an identity. * * Store scope is enforced server-side from the API key, not from anything we * send -- we cannot ask it to delete another store's data even if we wanted to. * * Failure is non-fatal and deliberately so: the exclusion itself is already in * effect locally (no new signals will be sent), so a network blip must not * block the merchant from excluding. The merchant is told, and can retry. * * @return int|WP_Error number of records erased. */ function orderguard_exclusion_erase_remote( $entry ) { $configuration = orderguard_exclusion_receipt_configuration(); if ( 'ready' !== ( $configuration['status'] ?? '' ) ) { $reason = sanitize_key( (string) ( $configuration['reason'] ?? 'invalid_key_registry' ) ); orderguard_log( 'OrderGuard: exclusion erase not sent; receipt verifier is not ready (' . $reason . ').' ); return new WP_Error( 'orderguard_excl_waiting_key', orderguard_exclusion_waiting_message(), array( 'reason' => $reason ) ); } if ( ! orderguard_is_activated() ) { return new WP_Error( 'orderguard_excl_inactive', __( 'OrderGuard is not activated.', 'orderguard' ) ); } $hashes = ! empty( $entry['hashes'] ) && is_array( $entry['hashes'] ) ? $entry['hashes'] : ( ! empty( $entry['hash'] ) ? array( $entry['hash'] ) : array() ); if ( empty( $hashes ) ) { return new WP_Error( 'orderguard_excl_bad', __( 'Nothing to erase.', 'orderguard' ) ); } $cutoff_at = isset( $entry['ts'] ) ? (int) $entry['ts'] : 0; if ( $cutoff_at <= 0 || $cutoff_at > time() + 300 ) { return new WP_Error( 'orderguard_excl_bad', __( 'The exclusion record could not be verified.', 'orderguard' ) ); } // Send EVERY spelling. The past signals we're retracting were written with // whatever form the order carried, which may not be the form the merchant // just typed -- so erasing only the typed hash would leave the real rows // behind while reporting success. // The installation ID is frozen into this operation's entry. Never // recompute it during retry: the same operation must keep one digest. $installation_id = isset( $entry['erase_installation_id'] ) ? (string) $entry['erase_installation_id'] : ''; $operation_id = isset( $entry['erase_operation_id'] ) ? (string) $entry['erase_operation_id'] : ''; try { $hashes = array_values( array_unique( $hashes ) ); sort( $hashes, SORT_STRING ); $body = OG_Exclusion_Erase_Binding::request( array( 'version' => OG_Exclusion_Erase_Binding::VERSION, 'installation_id' => $installation_id, 'operation_id' => $operation_id, 'cutoff_seconds' => (string) $cutoff_at, 'hashes' => $hashes, ) ); } catch ( Throwable $throwable ) { orderguard_log( 'OrderGuard: exclusion erase operation binding is unavailable; retry state was retained.' ); return new WP_Error( 'orderguard_excl_binding', orderguard_exclusion_retry_message() ); } $response = wp_remote_post( ORDERGUARD_SERVER_URL . '/v1/exclude', array( 'method' => 'POST', 'timeout' => 10, 'headers' => array( 'Content-Type' => 'application/json', 'X-OrderGuard-Key' => orderguard_get_api_key(), ), 'body' => wp_json_encode( $body ), ) ); // NOTE ON WORDING: a merchant must never be shown an HTTP status code. The // first live run of this feature printed "...(HTTP 404)" at a shop owner, // which reads like a fault they caused or must fix. It is neither -- it just // means "not done yet, we will keep trying". The real code goes to // error_log for us; the merchant gets a sentence in their own language. if ( is_wp_error( $response ) ) { orderguard_log( 'OrderGuard: exclusion erase transport error -- ' . $response->get_error_message() ); return new WP_Error( 'orderguard_excl_net', orderguard_exclusion_retry_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( $code < 200 || $code >= 300 ) { $failure = json_decode( (string) wp_remote_retrieve_body( $response ), true ); if ( 409 === $code && is_array( $failure ) && 'operation_conflict' === ( $failure['reason'] ?? '' ) ) { orderguard_log( 'OrderGuard: exclusion erase operation conflict; automatic retries stopped and support is required.' ); return new WP_Error( 'orderguard_excl_operation_conflict', orderguard_exclusion_support_message() ); } orderguard_log( 'OrderGuard: exclusion erase failed -- HTTP ' . $code . ' -- ' . substr( (string) wp_remote_retrieve_body( $response ), 0, 200 ) ); return new WP_Error( 'orderguard_excl_http', orderguard_exclusion_retry_message() ); } $receipt = OG_Exclusion_Erase_Binding::receipt( $body, (string) wp_remote_retrieve_body( $response ), $configuration['keys'], $configuration['environment'] ); if ( 'authenticated_remote_receipt' !== ( $receipt['status'] ?? '' ) ) { $reason = sanitize_key( (string) ( $receipt['reason'] ?? 'malformed_response' ) ); orderguard_log( 'OrderGuard: exclusion erase receipt was refused (' . $reason . '); retry state was retained.' ); return new WP_Error( 'orderguard_excl_response', orderguard_exclusion_retry_message() ); } return (int) $receipt['erased']; } /** * --------------------------------------------------------------------------- * Form handlers. * --------------------------------------------------------------------------- */ add_action( 'admin_post_orderguard_exclusion_add', 'orderguard_handle_exclusion_add' ); add_action( 'admin_post_orderguard_exclusion_remove', 'orderguard_handle_exclusion_remove' ); add_action( 'admin_post_orderguard_exclusion_restart', 'orderguard_handle_exclusion_restart' ); function orderguard_handle_exclusion_add() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Not allowed.', 'orderguard' ) ); } check_admin_referer( 'orderguard_exclusion_add', 'orderguard_exclusion_nonce' ); $value = isset( $_POST['orderguard_exclusion_value'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_exclusion_value'] ) ) : ''; $reason = isset( $_POST['orderguard_exclusion_reason'] ) ? sanitize_key( wp_unslash( $_POST['orderguard_exclusion_reason'] ) ) : 'other'; $entry = orderguard_exclusion_add( $value, $reason ); if ( is_wp_error( $entry ) ) { orderguard_exclusion_notice( 'error', $entry->get_error_message() ); } elseif ( 'name' === $entry['field'] ) { // Names are never sent to the network, so there can be no remote rows // to delete by name. Complete this local-only exclusion without HTTP. if ( orderguard_exclusion_mark_erased( $entry['id'], 0 ) ) { orderguard_exclusion_notice( 'warning', __( 'Excluded by name — they will no longer be scored or sent. Past signals could not be erased by name: OrderGuard only ever stores phone and email. To erase what was already sent, also exclude their email or phone.', 'orderguard' ) ); } else { orderguard_exclusion_notice( 'warning', __( 'Customer excluded locally. OrderGuard could not finish saving the local name-only exclusion; please try again.', 'orderguard' ) ); } } else { if ( empty( $entry['erase_installation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) $entry['erase_installation_id'] ) ) { orderguard_log( 'OrderGuard: exclusion erase not sent; dedicated installation ID is unavailable (installation_id_unavailable).' ); orderguard_exclusion_note_waiting_for_receipt_key( $entry['id'], 'installation_id_unavailable' ); orderguard_schedule_exclusion_erase_retry( $entry['id'], 6 * HOUR_IN_SECONDS ); orderguard_exclusion_notice( 'success', orderguard_exclusion_waiting_message() ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } $configuration = orderguard_exclusion_receipt_configuration(); if ( 'ready' !== ( $configuration['status'] ?? '' ) ) { $waiting_reason = sanitize_key( (string) ( $configuration['reason'] ?? 'invalid_key_registry' ) ); orderguard_log( 'OrderGuard: exclusion erase not sent; receipt verifier is not ready (' . $waiting_reason . ').' ); orderguard_exclusion_note_waiting_for_receipt_key( $entry['id'], $waiting_reason ); orderguard_schedule_exclusion_erase_retry( $entry['id'], 6 * HOUR_IN_SECONDS ); orderguard_exclusion_notice( 'success', orderguard_exclusion_waiting_message() ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } // Establish a durable background route before attempting the deletion // synchronously. A lost HTTP response or closed admin tab must not be // the only thing standing between this request and completion. orderguard_schedule_exclusion_erase_retry( $entry['id'], 60 ); $entry = orderguard_exclusion_note_dispatch_started( $entry['id'] ); if ( ! is_array( $entry ) ) { orderguard_exclusion_notice( 'success', __( 'Customer excluded. OrderGuard could not safely start network removal yet and will retry automatically.', 'orderguard' ) ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } // Local exclusion is already live. Now clean up what we already sent. $erased = orderguard_exclusion_erase_remote( $entry ); if ( is_wp_error( $erased ) ) { if ( 'orderguard_excl_operation_conflict' === $erased->get_error_code() ) { orderguard_exclusion_note_support_required( $entry['id'] ); orderguard_exclusion_notice( 'warning', orderguard_exclusion_support_message() ); wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } orderguard_exclusion_note_erase_failure( $entry['id'] ); // erase_pending stays set -> orderguard_exclusion_retry_pending_erases() // picks it up. Lead with what DID happen (they are excluded, right // now, for good) rather than with what didn't. The pending half is // ours to finish, needs nothing from them, and must not read as an // error -- the exclusion itself never depended on the server. orderguard_exclusion_notice( 'success', __( 'Customer excluded. From now on they are never checked and nothing about them is sent. Clearing the signals already sent from your store is still in progress — OrderGuard will finish this shortly, nothing for you to do.', 'orderguard' ) ); } else { if ( orderguard_exclusion_mark_erased( $entry['id'], (int) $erased ) ) { orderguard_exclusion_notice( 'success', sprintf( /* translators: %d: number of previously sent records erased. */ _n( 'Customer excluded. %d past signal from your store was erased.', 'Customer excluded. %d past signals from your store were erased.', (int) $erased, 'orderguard' ), (int) $erased ) ); } else { orderguard_exclusion_note_erase_failure( $entry['id'] ); orderguard_schedule_exclusion_erase_retry( $entry['id'], 60 ); orderguard_exclusion_notice( 'success', __( 'Customer excluded. From now on they are never checked and nothing about them is sent. Clearing the signals already sent from your store is still in progress — OrderGuard will finish this shortly, nothing for you to do.', 'orderguard' ) ); } } } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } function orderguard_handle_exclusion_remove() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'Not allowed.', 'orderguard' ) ); } check_admin_referer( 'orderguard_exclusion_remove', 'orderguard_exclusion_nonce' ); $id = isset( $_POST['orderguard_exclusion_id'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_exclusion_id'] ) ) : ''; if ( orderguard_exclusion_remove( $id ) ) { orderguard_exclusion_notice( 'success', __( 'Customer removed from the exclusion list. Previously erased signals are not restored.', 'orderguard' ) ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } /** Administrator-only recovery for a server-confirmed operation conflict. */ function orderguard_handle_exclusion_restart() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Not allowed.', 'orderguard' ) ); } $id = isset( $_POST['orderguard_exclusion_id'] ) ? sanitize_text_field( wp_unslash( $_POST['orderguard_exclusion_id'] ) ) : ''; check_admin_referer( 'orderguard_exclusion_restart_' . $id, 'orderguard_exclusion_restart_nonce' ); $result = orderguard_exclusion_restart_removal( $id ); if ( is_wp_error( $result ) ) { orderguard_exclusion_notice( 'warning', $result->get_error_message() ); } else { orderguard_schedule_exclusion_erase_retry( $id, 1 ); orderguard_exclusion_notice( 'success', __( 'A new network removal request was started. The local exclusion stayed in place.', 'orderguard' ) ); } wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'admin.php?page=orderguard&tab=features' ) ); exit; } /** * --------------------------------------------------------------------------- * Retry unfinished erases. * --------------------------------------------------------------------------- * WHY: the erase used to be attempted exactly once, from the add handler. If * the server was unreachable at that moment the entry was already saved, so * re-adding the same person hit the duplicate guard and the erase could NEVER * run again. The exclusion looked successful while the store's test signals * stayed in the network permanently -- a silent, unrecoverable failure of the * feature's whole point. * * Entries therefore carry erase_pending until the server confirms. Action * Scheduler owns the remote retries; WP-Cron is a fallback. A cheap recovery * pass runs on normal traffic as well as admin/cron so closing the browser can * never abandon an accepted deletion request. */ add_action( 'orderguard_exclusion_erase_retry', 'orderguard_run_exclusion_erase_retry', 10, 1 ); add_action( 'init', 'orderguard_exclusion_retry_pending_erases', 30 ); /** Return one fresh exclusion entry by opaque id. */ function orderguard_exclusion_entry( $id ) { $read_ok = false; $list = orderguard_exclusions_get( $read_ok ); if ( ! $read_ok ) { return null; } foreach ( $list as $entry ) { if ( is_array( $entry ) && isset( $entry['id'] ) && (string) $entry['id'] === (string) $id ) { return $entry; } } return null; } /** Upgrade one predecessor pending row exactly once, then replay that operation forever. */ function orderguard_exclusion_prepare_erase_operation( $id ) { $installation_id = orderguard_exclusion_installation_id( true ); $result = null; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id, $installation_id ) { foreach ( $list as $index => $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['id'] ) || (string) $entry['id'] !== (string) $id ) { continue; } if ( empty( $entry['erase_pending'] ) ) { return array( 'list' => $list, 'result' => null ); } if ( ! isset( $entry['erase_operation_id'] ) || ! is_string( $entry['erase_operation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $entry['erase_operation_id'] ) ) { $list[ $index ]['erase_operation_id'] = bin2hex( random_bytes( 32 ) ); } if ( ( ! isset( $entry['erase_installation_id'] ) || ! is_string( $entry['erase_installation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $entry['erase_installation_id'] ) ) && preg_match( '/^[a-f0-9]{64}$/D', $installation_id ) ) { $list[ $index ]['erase_installation_id'] = $installation_id; } return array( 'list' => $list, 'result' => $list[ $index ] ); } return array( 'list' => $list, 'result' => null ); }, $result ); return $saved && is_array( $result ) ? $result : null; } /** Record the network attempt before HTTP so cancellation can never race it. */ function orderguard_exclusion_note_dispatch_started( $id ) { $result = null; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id ) { foreach ( $list as $index => $entry ) { if ( ! is_array( $entry ) || ! isset( $entry['id'] ) || (string) $entry['id'] !== (string) $id ) { continue; } if ( empty( $entry['erase_pending'] ) ) { return array( 'list' => $list, 'result' => null ); } $list[ $index ]['erase_attempts'] = min( 20, max( 0, (int) ( $entry['erase_attempts'] ?? 0 ) ) + 1 ); unset( $list[ $index ]['erase_waiting_for_receipt_key'] ); return array( 'list' => $list, 'result' => $list[ $index ] ); } return array( 'list' => $list, 'result' => null ); }, $result ); return $saved && is_array( $result ) ? $result : null; } /** Whether an exact Action Scheduler retry already exists. */ function orderguard_exclusion_erase_retry_is_scheduled( $id ) { $args = array( (string) $id ); if ( ! function_exists( 'as_has_scheduled_action' ) ) { return false; } try { return (bool) as_has_scheduled_action( 'orderguard_exclusion_erase_retry', $args, 'orderguard' ); } catch ( Throwable $throwable ) { // An uncertain Action Scheduler read must not suppress WP-Cron. return false; } } /** * Schedule an exact exclusion deletion retry. * * A worker always requests an Action Scheduler successor without consulting * its own RUNNING row. Recovery callers deduplicate that backend, but every * caller independently maintains WP-Cron as a watchdog. A database row that * says PENDING/RUNNING is not proof that the Action Scheduler runner is alive. */ function orderguard_schedule_exclusion_erase_retry( $id, $delay = 60, $from_worker = false ) { $id = (string) $id; if ( '' === $id ) { return false; } $when = time() + max( 1, (int) $delay ); $args = array( $id ); $as_ready = ! $from_worker && orderguard_exclusion_erase_retry_is_scheduled( $id ); if ( function_exists( 'as_schedule_single_action' ) ) { if ( ! $as_ready ) { try { $action_id = as_schedule_single_action( $when, 'orderguard_exclusion_erase_retry', $args, 'orderguard', false ); $as_ready = is_numeric( $action_id ) && (int) $action_id > 0; } catch ( Throwable $throwable ) { // The independent WP-Cron watchdog below owns recovery. } } } $next_cron = wp_next_scheduled( 'orderguard_exclusion_erase_retry', $args ); $cron_ready = false !== $next_cron && (int) $next_cron <= $when; if ( ! $cron_ready ) { try { $scheduled = wp_schedule_single_event( $when, 'orderguard_exclusion_erase_retry', $args, true ); $cron_ready = true === $scheduled || ( ! is_wp_error( $scheduled ) && false !== wp_next_scheduled( 'orderguard_exclusion_erase_retry', $args ) ); } catch ( Throwable $throwable ) { $cron_ready = false; } } // If core declined an earlier duplicate, retain the later watchdog. Never // create a crash window by deleting it before an earlier event is proven. $cron_ready = $cron_ready || false !== $next_cron; if ( ! $as_ready ) { orderguard_log( 'OrderGuard: Action Scheduler could not queue an exclusion deletion retry; retaining the independent WordPress cron wake-up.' ); } if ( ! $cron_ready ) { orderguard_log( 'OrderGuard: WordPress cron could not queue an exclusion deletion retry; durable recovery state was retained.' ); } return $as_ready || $cron_ready; } /** Persist bounded retry timing without replacing another exclusion entry. */ function orderguard_exclusion_note_erase_failure( $id ) { $result = false; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id ) { foreach ( $list as $index => $entry ) { if ( ! isset( $entry['id'] ) || (string) $entry['id'] !== (string) $id ) { continue; } if ( empty( $entry['erase_pending'] ) ) { return array( 'list' => $list, 'result' => true ); } // The attempt was durably counted immediately before HTTP. $attempts = min( 20, max( 1, (int) ( $entry['erase_attempts'] ?? 1 ) ) ); $delay = min( 6 * HOUR_IN_SECONDS, 60 * ( 2 ** min( 8, $attempts - 1 ) ) ); $list[ $index ]['erase_attempts'] = $attempts; $list[ $index ]['erase_next_attempt_at'] = time() + $delay; unset( $list[ $index ]['erase_waiting_for_receipt_key'] ); return array( 'list' => $list, 'result' => $delay ); } return array( 'list' => $list, 'result' => false ); }, $result ); return $saved ? $result : false; } /** Stop automatic retries for a server-confirmed operation binding conflict. */ function orderguard_exclusion_note_support_required( $id ) { $result = false; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id ) { foreach ( $list as $index => $entry ) { if ( isset( $entry['id'] ) && (string) $entry['id'] === (string) $id && ! empty( $entry['erase_pending'] ) ) { $list[ $index ]['erase_support_required'] = 'operation_conflict'; unset( $list[ $index ]['erase_next_attempt_at'] ); return array( 'list' => $list, 'result' => true ); } } return array( 'list' => $list, 'result' => false ); }, $result ); return $saved && $result; } /** * Replace only the conflicted operation ID and record the administrator action. * Hashes, cutoff and installation binding remain byte-for-byte unchanged. */ function orderguard_exclusion_restart_removal( $id ) { $id = (string) $id; if ( '' === $id || ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'orderguard_excl_restart_forbidden', __( 'Only an administrator can start a new removal request.', 'orderguard' ) ); } try { $new_operation_id = bin2hex( random_bytes( 32 ) ); $audit_id = bin2hex( random_bytes( 16 ) ); } catch ( Throwable $throwable ) { return new WP_Error( 'orderguard_excl_restart_random', __( 'A new removal request could not be created. Nothing was changed.', 'orderguard' ) ); } $actor_id = get_current_user_id(); $created_at = time(); if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) { return new WP_Error( 'orderguard_excl_restart_locked', __( 'Another privacy cleanup is still running. Nothing was changed; please try again.', 'orderguard' ) ); } try { $result = null; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id, $new_operation_id, $audit_id, $actor_id, $created_at ) { foreach ( $list as $index => $entry ) { if ( ! isset( $entry['id'] ) || (string) $entry['id'] !== $id ) { continue; } $existing_history = isset( $entry['erase_restart_audit'] ) && is_array( $entry['erase_restart_audit'] ) ? array_values( $entry['erase_restart_audit'] ) : array(); foreach ( $existing_history as $audit_entry ) { if ( is_array( $audit_entry ) && hash_equals( $audit_id, (string) ( $audit_entry['audit_id'] ?? '' ) ) && hash_equals( $new_operation_id, (string) ( $entry['erase_operation_id'] ?? '' ) ) ) { return array( 'list' => $list, 'result' => $entry ); } } if ( empty( $entry['erase_pending'] ) || 'operation_conflict' !== ( $entry['erase_support_required'] ?? '' ) ) { return new WP_Error( 'orderguard_excl_restart_state', __( 'This exclusion does not need a new removal request.', 'orderguard' ) ); } $old_operation_id = isset( $entry['erase_operation_id'] ) ? (string) $entry['erase_operation_id'] : ''; $entry_hashes = isset( $entry['hashes'] ) && is_array( $entry['hashes'] ) ? $entry['hashes'] : ( ! empty( $entry['hash'] ) ? array( $entry['hash'] ) : array() ); if ( ! preg_match( '/^[a-f0-9]{64}$/D', $old_operation_id ) || empty( $entry['erase_installation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) $entry['erase_installation_id'] ) || empty( $entry_hashes ) || empty( $entry['ts'] ) || (int) $entry['ts'] <= 0 ) { return new WP_Error( 'orderguard_excl_restart_damaged', __( 'This removal record is incomplete. Nothing was changed; please contact OrderGuard support.', 'orderguard' ) ); } $history = $existing_history; $history[] = array( 'version' => 1, 'audit_id' => $audit_id, 'actor_id' => (int) $actor_id, 'created_at' => (int) $created_at, 'reason' => 'administrator_restart_after_conflict', 'old_operation_id' => $old_operation_id, 'new_operation_id' => $new_operation_id, ); $list[ $index ]['erase_restart_audit'] = array_slice( $history, -20 ); $list[ $index ]['erase_restart_count'] = min( 1000000, max( 0, (int) ( $entry['erase_restart_count'] ?? count( $history ) - 1 ) ) + 1 ); $list[ $index ]['erase_operation_id'] = $new_operation_id; // A conflicted operation was already accepted far enough by // the server to return 409. Keep that durable fact across the // restart: the waiting/no-key exemption is reserved for rows // that have genuinely never left this store. $list[ $index ]['erase_attempts'] = max( 1, (int) ( $entry['erase_attempts'] ?? 1 ) ); $list[ $index ]['erase_next_attempt_at'] = $created_at; unset( $list[ $index ]['erase_support_required'], $list[ $index ]['erase_waiting_for_receipt_key'] ); return array( 'list' => $list, 'result' => $list[ $index ] ); } return new WP_Error( 'orderguard_excl_restart_missing', __( 'The exclusion could not be found. Nothing was changed.', 'orderguard' ) ); }, $result ); if ( ! $saved ) { return new WP_Error( 'orderguard_excl_restart_write', __( 'The new removal request could not be saved. Nothing was changed.', 'orderguard' ) ); } return $result; } finally { orderguard_release_gdpr_erasure_lifecycle_lock(); } } /** Persist the dormant pre-dispatch state without counting a network attempt. */ function orderguard_exclusion_note_waiting_for_receipt_key( $id, $reason ) { $reason = sanitize_key( (string) $reason ); $result = false; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id, $reason ) { foreach ( $list as $index => $entry ) { if ( isset( $entry['id'] ) && (string) $entry['id'] === (string) $id && ! empty( $entry['erase_pending'] ) ) { $list[ $index ]['erase_waiting_for_receipt_key'] = '' !== $reason ? $reason : 'invalid_key_registry'; $list[ $index ]['erase_next_attempt_at'] = time() + 6 * HOUR_IN_SECONDS; return array( 'list' => $list, 'result' => true ); } } return array( 'list' => $list, 'result' => false ); }, $result ); return $saved && $result; } /** One durable worker invocation: one request, then verified clear or retry. */ function orderguard_run_exclusion_erase_retry( $id ) { if ( function_exists( 'orderguard_owned_exclusion_reset_request_snapshot' ) ) { orderguard_owned_exclusion_reset_request_snapshot(); } if ( ! function_exists( 'orderguard_acquire_gdpr_erasure_lifecycle_lock' ) || ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) { orderguard_schedule_exclusion_erase_retry( $id, 60, true ); return; } try { $entry = orderguard_exclusion_entry( $id ); if ( ! is_array( $entry ) || empty( $entry['erase_pending'] ) ) { return; } if ( ! empty( $entry['erase_support_required'] ) ) { return; } $next_at = max( 0, (int) ( $entry['erase_next_attempt_at'] ?? 0 ) ); if ( $next_at > time() ) { orderguard_schedule_exclusion_erase_retry( $id, $next_at - time(), true ); return; } if ( 'name' === ( $entry['field'] ?? '' ) ) { orderguard_exclusion_mark_erased( $id, 0 ); return; } $configuration = orderguard_exclusion_receipt_configuration(); if ( 'ready' !== ( $configuration['status'] ?? '' ) ) { $waiting_reason = sanitize_key( (string) ( $configuration['reason'] ?? 'invalid_key_registry' ) ); orderguard_log( 'OrderGuard: exclusion erase not sent; receipt verifier is not ready (' . $waiting_reason . ').' ); orderguard_exclusion_note_waiting_for_receipt_key( $id, $waiting_reason ); orderguard_schedule_exclusion_erase_retry( $id, 6 * HOUR_IN_SECONDS, true ); return; } if ( empty( $entry['erase_operation_id'] ) || ! is_string( $entry['erase_operation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $entry['erase_operation_id'] ) || empty( $entry['erase_installation_id'] ) || ! is_string( $entry['erase_installation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', $entry['erase_installation_id'] ) ) { $entry = orderguard_exclusion_prepare_erase_operation( $id ); if ( ! is_array( $entry ) ) { orderguard_schedule_exclusion_erase_retry( $id, 300, true ); return; } } if ( empty( $entry['erase_installation_id'] ) || ! preg_match( '/^[a-f0-9]{64}$/D', (string) $entry['erase_installation_id'] ) ) { orderguard_log( 'OrderGuard: exclusion erase not sent; dedicated installation ID is unavailable (installation_id_unavailable).' ); orderguard_exclusion_note_waiting_for_receipt_key( $id, 'installation_id_unavailable' ); orderguard_schedule_exclusion_erase_retry( $id, 6 * HOUR_IN_SECONDS, true ); return; } $entry = orderguard_exclusion_note_dispatch_started( $id ); if ( ! is_array( $entry ) ) { orderguard_schedule_exclusion_erase_retry( $id, 300, true ); return; } $result = orderguard_exclusion_erase_remote( $entry ); if ( is_wp_error( $result ) && 'orderguard_excl_operation_conflict' === $result->get_error_code() ) { orderguard_exclusion_note_support_required( $id ); return; } if ( ! is_wp_error( $result ) && orderguard_exclusion_mark_erased( $id, (int) $result ) ) { return; } $delay = orderguard_exclusion_note_erase_failure( $id ); orderguard_schedule_exclusion_erase_retry( $id, is_numeric( $delay ) && (int) $delay > 0 ? (int) $delay : 300, true ); } finally { orderguard_release_gdpr_erasure_lifecycle_lock(); } } function orderguard_exclusion_retry_pending_erases() { if ( ! orderguard_is_activated() || wp_doing_ajax() ) { return; } $read_ok = false; $list = orderguard_exclusions_get( $read_ok ); if ( ! $read_ok ) { return; } $pending = array(); foreach ( $list as $entry ) { if ( ! empty( $entry['erase_pending'] ) && empty( $entry['erase_support_required'] ) ) { $pending[] = $entry; } } if ( empty( $pending ) ) { orderguard_delete_option( 'orderguard_exclusion_erase_recovery_cursor' ); return; // fast path -- the normal state } // Resume after the last entry inspected by the previous recovery pass. // Without rotation, three permanently failing entries at the front make a // fourth job whose scheduler rows disappeared unreachable forever. $cursor_ok = false; $cursor_value = orderguard_get_option( 'orderguard_exclusion_erase_recovery_cursor', '', $cursor_ok ); if ( ! $cursor_ok ) { return; } $cursor = is_string( $cursor_value ) && strlen( $cursor_value ) <= 128 ? $cursor_value : ''; $start = 0; if ( '' !== $cursor ) { foreach ( $pending as $index => $entry ) { if ( isset( $entry['id'] ) && hash_equals( $cursor, (string) $entry['id'] ) ) { $start = ( $index + 1 ) % count( $pending ); break; } } } $rotated = array_merge( array_slice( $pending, $start ), array_slice( $pending, 0, $start ) ); $last_id = ''; foreach ( array_slice( $rotated, 0, 3 ) as $entry ) { // bounded scheduler queries per request $next_at = max( 0, (int) ( $entry['erase_next_attempt_at'] ?? 0 ) ); orderguard_schedule_exclusion_erase_retry( $entry['id'], max( 1, $next_at - time() ) ); $last_id = (string) $entry['id']; } if ( '' !== $last_id ) { orderguard_update_option( 'orderguard_exclusion_erase_recovery_cursor', $last_id, false ); } } /** * TRUE when any entry still hasn't had its past signals retracted. Drives the * warning on the card -- silence here would hide a real failure. */ function orderguard_exclusion_has_pending() { foreach ( orderguard_exclusions_get() as $entry ) { if ( ! empty( $entry['erase_pending'] ) ) { return true; } } return false; } function orderguard_exclusion_has_waiting_for_receipt_key() { foreach ( orderguard_exclusions_get() as $entry ) { if ( ! empty( $entry['erase_pending'] ) && ! empty( $entry['erase_waiting_for_receipt_key'] ) ) { return true; } } return false; } function orderguard_exclusion_has_support_required() { foreach ( orderguard_exclusions_get() as $entry ) { if ( ! empty( $entry['erase_pending'] ) && ! empty( $entry['erase_support_required'] ) ) { return true; } } return false; } /** * Mark an entry's erase as done (called from the add handler on success). */ function orderguard_exclusion_mark_erased( $id, $count ) { if ( ! orderguard_acquire_gdpr_erasure_lifecycle_lock() ) { return false; } try { $marked = false; $saved = orderguard_exclusions_atomic_mutate( static function ( $list, $write_attempted ) use ( $id, $count ) { $marked = false; foreach ( $list as $i => $entry ) { if ( isset( $entry['id'] ) && (string) $entry['id'] === (string) $id ) { unset( $list[ $i ]['erase_pending'] ); unset( $list[ $i ]['erase_attempts'], $list[ $i ]['erase_next_attempt_at'] ); unset( $list[ $i ]['erase_waiting_for_receipt_key'] ); unset( $list[ $i ]['erase_support_required'] ); $list[ $i ]['erased'] = (int) $count; $marked = true; break; } } return array( 'list' => $list, 'result' => $marked || $write_attempted ); }, $marked ); return $saved && $marked; } finally { orderguard_release_gdpr_erasure_lifecycle_lock(); } } /** * The one thing a merchant needs to know when the erase hasn't gone through: * it is queued, it is automatic, and there is nothing for them to do. No status * codes, no jargon, no implication that they broke something. */ function orderguard_exclusion_retry_message() { return __( 'OrderGuard will finish this shortly.', 'orderguard' ); } function orderguard_exclusion_waiting_message() { $configuration = orderguard_exclusion_receipt_configuration(); if ( in_array( $configuration['reason'] ?? '', array( 'receipt_key_override_refused', 'receipt_environment_override_refused' ), true ) ) { return __( 'Customer excluded locally. Removal from the OrderGuard network is waiting because a wp-config.php setting is refused (see the notice at the top of the page); nothing about this customer will be sent while it waits.', 'orderguard' ); } return __( 'Customer excluded locally. Removal from the OrderGuard network is waiting for server confirmation to be set up; nothing about this customer will be sent while it waits.', 'orderguard' ); } function orderguard_exclusion_support_message() { return __( 'Customer excluded locally. OrderGuard could not safely match this removal request, so automatic retries stopped. An administrator can start a new removal request below; contact OrderGuard support if it happens again.', 'orderguard' ); } function orderguard_exclusion_notice( $kind, $message ) { set_transient( 'orderguard_exclusion_notice_' . get_current_user_id(), array( 'kind' => $kind, 'msg' => $message ), 60 ); } function orderguard_exclusion_take_notice() { $key = 'orderguard_exclusion_notice_' . get_current_user_id(); $notice = orderguard_get_transient( $key ); if ( $notice ) { orderguard_delete_transient( $key ); } return is_array( $notice ) ? $notice : null; }