get_charset_collate(); $table = $wpdb->prefix . 'storesuit_events'; dbDelta( "CREATE TABLE {$table} (\n id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n object_type varchar(32) NOT NULL,\n object_id bigint(20) unsigned NOT NULL DEFAULT 0,\n event varchar(64) NOT NULL,\n actor bigint(20) unsigned NOT NULL DEFAULT 0,\n payload longtext NOT NULL,\n created_at datetime NOT NULL,\n PRIMARY KEY (id),\n KEY object_lookup (object_type,object_id),\n KEY object_recent (object_type,id),\n KEY created_at (created_at)\n ) {$charset};" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table && $wpdb->get_var( $wpdb->prepare( 'SHOW INDEX FROM %i WHERE Key_name = %s', $table, 'object_recent' ) ) ) { update_option( 'storesuit_schema', '2', false ); } } public static function audit( string $type, int $id, string $event, array $data = array(), ?int $actor = null ) : void { global $wpdb; $wpdb->insert( $wpdb->prefix . 'storesuit_events', array( 'object_type' => sanitize_key( $type ), 'object_id' => $id, 'event' => sanitize_key( $event ), 'actor' => $actor ?? get_current_user_id(), 'payload' => wp_json_encode( $data ), 'created_at' => current_time( 'mysql', true ), ) ); // The owner's team-activity notifications listen here (paid build). do_action( 'storesuit_audited', sanitize_key( $type ), $id, sanitize_key( $event ), $data, $actor ?? get_current_user_id() ); } public static function events() : array { global $wpdb; return ( $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT 100', $wpdb->prefix . 'storesuit_events' ), ARRAY_A ) ?: array() ); } /** * Whether this request checks that the recurring background steps are scheduled: admin screens, cron, AJAX, the command * line and every form post do, a shopper's page view does not. Each uncached product page paid one Action Scheduler * lookup per step (Almira profile, 1 Nov 2026); WP-Cron runs from the storefront's own traffic, so a step lost is back * within minutes. */ public static function upkeep_request() : bool { // No HTTP request at all: the command line, a server's cron script. if ( is_admin() || wp_doing_cron() || wp_doing_ajax() || !isset( $_SERVER['REQUEST_METHOD'] ) ) { return true; } return !in_array( strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ), array('GET', 'HEAD'), true ); } /** * A one-off background step, unless the same step already waits. Only a pending one counts: Action Scheduler's own * as_has_scheduled_action() also counts the step now running, so a step that schedules its next round (more work * left, the lock busy, the marketplace asked to wait) would never get it. Without Action Scheduler, WP-Cron. */ public static function later( string $hook, array $args, string $group, int $delay ) : void { $at = time() + max( 1, $delay ); if ( function_exists( 'as_schedule_single_action' ) && function_exists( 'as_get_scheduled_actions' ) ) { // The one waiting stays when it comes as soon (a minute's grace); one due later gives way to this sooner one. $waiting = (array) as_get_scheduled_actions( array( 'hook' => $hook, 'args' => $args, 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => 5, ), 'ids' ); foreach ( $waiting as $id ) { try { $date = \ActionScheduler::store()->get_date( (int) $id ); } catch ( \Throwable $e ) { $date = null; } if ( !$date || $date->getTimestamp() <= $at + MINUTE_IN_SECONDS ) { return; } } if ( $waiting ) { as_unschedule_all_actions( $hook, $args, $group ); } as_schedule_single_action( $at, $hook, $args, $group ); return; } $next = wp_next_scheduled( $hook, $args ); if ( $next && $next <= $at + MINUTE_IN_SECONDS ) { return; } if ( $next ) { wp_unschedule_event( $next, $hook, $args ); } wp_schedule_single_event( $at, $hook, $args ); } public static function require_cap( string $cap ) : void { if ( !current_user_can( $cap ) ) { throw new \RuntimeException(I18n::t( 'You do not have permission to perform this action.' )); } } public static function lock( string $key, callable $callback ) { // Atomic option insert. No expiry takeover: a crashed money operation // needs reconciliation, not a second concurrent attempt. global $wpdb; $name = 'storesuit_lock_' . md5( $key ); // add_option uses an upsert: racing callers can both believe they won. $old = $wpdb->suppress_errors( true ); $got = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,%s,'no')", $wpdb->options, $name, maybe_serialize( array( 'time' => time(), 'actor' => get_current_user_id(), ) ) ) ); $wpdb->suppress_errors( $old ); if ( !$got ) { throw new \RuntimeException(I18n::t( 'Another action is using this record. Wait for it to finish. If an action was interrupted, an administrator must review it.' )); } try { return $callback(); } finally { $wpdb->delete( $wpdb->options, array( 'option_name' => $name, ) ); wp_cache_delete( $name, 'options' ); } } public static function amount( $value ) : float { if ( !is_scalar( $value ) ) { throw new \InvalidArgumentException(I18n::t( 'Invalid amount.' )); } $decimal = wc_format_decimal( $value ); if ( !is_numeric( $decimal ) ) { throw new \InvalidArgumentException(I18n::t( 'Enter a valid number.' )); } $n = (float) $decimal; if ( !is_finite( $n ) || $n < 0 || $n > 1000000000000 ) { throw new \InvalidArgumentException(I18n::t( 'The amount is invalid or outside the allowed range.' )); } return round( $n, wc_get_price_decimals() ); } public static function text( array $data, string $key ) : string { return ( isset( $data[$key] ) && is_scalar( $data[$key] ) ? sanitize_text_field( (string) $data[$key] ) : '' ); } /** * A search term with the Turkish dotted and dotless i as one letter, for comparing with a column folded the same way in SQL * (REPLACE of 'ı', 'İ' and 'I' with 'i'). WordPress's usual collations already treat ş/s, ç/c, ğ/g, ö/o and ü/u as one * letter and ignore case, but not ı/i: "varyantli" did not find "Varyantlı" in the product list (denetim R1-5, R2-3, R6-6). * The storefront search has the same rule (theme inc/commerce.php, storeruler_search_variants()). */ public static function search_fold( string $term ) : string { return strtr( $term, array( 'ı' => 'i', 'İ' => 'i', 'I' => 'i', ) ); } /** * An order's billing or shipping address through WooCommerce's setters. The legacy WC_Order::set_address() also writes every * field to the order id's post meta, which under HPOS is a placeholder post: name, e-mail and phone stayed in wp_postmeta, * also after the order was deleted (denetim R4-2). A field WooCommerce has no setter for is left out, as the order kept none. */ public static function order_address( \WC_Order $order, array $address, string $type ) : void { foreach ( $address as $key => $value ) { $setter = 'set_' . (( $type === 'shipping' ? 'shipping' : 'billing' )) . '_' . $key; if ( is_string( $key ) && is_callable( array($order, $setter) ) ) { $order->{$setter}( $value ); } } } /** * A name WordPress keeps HTML-encoded (term names are saved with "&" for "&") as plain text for the screens, which print it * with textContent: decoded once here, escaped once by the browser ("Kitchen &amp; Dining" showed in the product window). */ public static function plain( $text ) : string { return html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); } /** * The small pictures of a list of products: key => array( option or product id, product id ) gives key => URL, the option's * picture or else its product's, '' for none. Products and pictures are read at once, not one by one. */ public static function thumbnails( array $items ) : array { $ids = array_values( array_unique( array_filter( array_map( 'intval', array_merge( ...array_values( ( $items ?: array(array()) ) ) ) ) ) ) ); if ( $ids ) { _prime_post_caches( $ids, false, true ); } $picture = array(); foreach ( $items as $key => $pair ) { foreach ( $pair as $id ) { $picture[$key] = ( $picture[$key] ?? 0 ?: (int) get_post_meta( (int) $id, '_thumbnail_id', true ) ); } } $found = array_values( array_unique( array_filter( $picture ) ) ); if ( $found ) { _prime_post_caches( $found, false, true ); } return array_map( static fn( $id ) => ( $id ? (string) wp_get_attachment_image_url( $id, 'thumbnail' ) : '' ), $picture ); } /** * The shop's name as plain text (persona fix SHP-1): WordPress saves the site title HTML-escaped ("Weber Kaffee & Küche", * "Joe's"), and get_bloginfo( 'name' ) answers that escaped form. Decoded once here, the same as AppInstall::shop(), for * what is not HTML: a carrier's API (JSON), a text field's value. Whatever prints it in a page escapes it there, once. */ public static function site_name() : string { return trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) ); } /** * The idempotency() key of a request key a screen sent: hashed with what it is for and who sent it, so a key typed by a * person can never be the key of another action, another person or one the plugin makes itself (security audit S05). */ public static function client_key( string $kind, string $key ) : string { if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing. Refresh the page.' )); } return hash( 'sha256', 'client:' . $kind . ':' . get_current_user_id() . ':' . $key ); } /** A key the plugin makes for one of its own steps (what it is for and which order): nobody can work it out from outside (S05). */ public static function internal_key( string $purpose ) : string { return hash_hmac( 'sha256', 'internal:' . $purpose, wp_salt( 'auth' ) ); } public static function idempotency( string $key, callable $callback ) { if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing. Refresh the page.' )); } $option = 'storesuit_request_' . hash( 'sha256', $key ); return self::lock( $option, static function () use($option, $callback) { $saved = get_option( $option, false ); if ( false !== $saved ) { return ( is_array( $saved ) && isset( $saved[self::REQUEST_MARK] ) ? $saved['result'] : $saved ); } $result = $callback(); add_option( $option, array( self::REQUEST_MARK => 1, 'at' => time(), 'result' => $result, ), '', false ); return $result; } ); } /** * style_loader_src / script_loader_src, before LiteSpeed Cache's "Remove Query Strings" (priority 999), which strips ?ver= * from every local file while the host's CDN keeps those files for a year (immutable): after an update shoppers kept * the old scripts (a live store, 27 Oct 2026). StoreRuler's own files and the StoreRuler theme's carry * _litespeed_rm_qs=0, which LiteSpeed leaves as it is. Nothing changes without LiteSpeed or with that setting off. */ public static function keep_asset_version( $src ) { if ( !is_string( $src ) || !defined( 'LSCWP_V' ) || strpos( $src, 'ver=' ) === false || strpos( $src, '_litespeed_rm_qs=' ) !== false || !get_option( 'litespeed.conf.optm-qs_rm' ) ) { return $src; } $ours = array(plugins_url( '/', STORESUIT_FILE )); if ( get_template() === 'storeruler' ) { $ours[] = get_template_directory_uri() . '/'; } foreach ( $ours as $base ) { if ( strpos( $src, $base ) === 0 ) { return add_query_arg( '_litespeed_rm_qs', '0', $src ); } } return $src; } public static function hooks() : void { self::install_keys(); foreach ( array('style_loader_src', 'script_loader_src') as $hook ) { add_filter( $hook, array(__CLASS__, 'keep_asset_version'), 998 ); } add_action( self::REQUEST_CLEANUP, array(__CLASS__, 'forget_requests') ); add_action( 'init', static function () { if ( !wp_next_scheduled( self::REQUEST_CLEANUP ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::REQUEST_CLEANUP ); } }, 99 ); } /** * A repeated-request record only stops a double submit, so it is removed after a week. Records written before 0.36 * carry no date; they are removed one week after this first runs. */ public static function forget_requests() : int { global $wpdb; $legacy_after = (int) get_option( 'storesuit_request_legacy_after', 0 ); if ( !$legacy_after ) { $legacy_after = time() + self::REQUEST_TTL; update_option( 'storesuit_request_legacy_after', $legacy_after, false ); } $removed = 0; $after = 0; do { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT option_id, option_name, option_value FROM %i WHERE option_name LIKE %s AND option_id>%d ORDER BY option_id LIMIT 500', $wpdb->options, $wpdb->esc_like( 'storesuit_request_' ) . '%', $after ) ); foreach ( $rows as $row ) { $after = (int) $row->option_id; if ( !preg_match( '/^storesuit_request_[a-f0-9]{64}$/', (string) $row->option_name ) ) { continue; } $value = maybe_unserialize( $row->option_value ); $expired = ( is_array( $value ) && isset( $value[self::REQUEST_MARK] ) ? (int) ($value['at'] ?? 0) < time() - self::REQUEST_TTL : time() > $legacy_after ); if ( $expired && delete_option( (string) $row->option_name ) ) { ++$removed; } } } while ( count( $rows ) === 500 ); // Order email corrections before 0.36 kept a copy of the order summary; only the fact of the change is needed. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET payload=%s WHERE source_key LIKE %s AND CHAR_LENGTH(payload)>40', self::keys_table(), '{"changed":true}', $wpdb->esc_like( 'order-email:' ) . '%' ) ); // Order edits up to 1.2.10 kept a copy of the order and its customer too, which outlived an erasure (S15): the fact only. $wpdb->query( $wpdb->prepare( 'UPDATE %i SET payload=%s WHERE source_key LIKE %s AND CHAR_LENGTH(payload)>40', self::keys_table(), '{"edited":true}', $wpdb->esc_like( 'order-edit:' ) . '%' ) ); return $removed; } /* * Shared plumbing (0.63). These came from the paid build's money, workflow and stock services, whose old methods now hand * over to these: one transaction depth, one InnoDB check and one unique-action table for both builds. */ private static int $depth = 0; private static bool $checked = false; /** hold() keeps the money lock for this request: atomic() inside it runs its transaction under that lock. */ private static bool $held = false; /** The open transaction was ended by the database (check_transaction()): atomic() rolls it back and says so. */ private static bool $aborted = false; /** It was ended because its connection was lost or replaced (the server rolled it back with the connection). */ private static bool $lost = false; /** The connection (its thread id) the open transaction runs on. */ private static int $thread = 0; /** The first statement of the open transaction the object cache could hold the result of, as verb and table (note_write()). */ private static string $wrote = ''; /** Only this exact cleanup statement may run on the transaction's original connection. */ private static string $cleanup_sql = ''; private static ?\wpdb $cleanup_database = null; private static ?\mysqli $cleanup_dbh = null; /** The database errors that end a transaction, or a statement of it, while its later statements would still run. */ public const ENDED = array( 1020, 1205, 1206, 1213 ); /** * The errors of a lost connection: the server has gone away (2006), the connection was lost during a statement (2013), killed * during one (1927, MariaDB) or closed for inactivity (4031, MySQL). */ public const LOST = array( 1927, 2006, 2013, 4031 ); /** wp_die()'s handler filters, one per kind of request. */ private const DIE_HANDLERS = array( 'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler', 'wp_die_xmlrpc_handler', 'wp_die_xml_handler' ); /** The unique-action table: every action key that must count once. */ public static function keys_table() : string { global $wpdb; return $wpdb->prefix . 'storesuit_money_keys'; } public static function install_keys() : void { if ( get_option( 'storesuit_money_schema' ) === '1' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::keys_table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} (\n source_key varchar(191) NOT NULL,\n order_id bigint(20) unsigned NOT NULL DEFAULT 0,\n tender_id bigint(20) unsigned NOT NULL DEFAULT 0,\n payload longtext NOT NULL,\n PRIMARY KEY (source_key),\n KEY order_id (order_id),\n KEY tender_id (tender_id)\n ) {$charset};" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table ) { update_option( 'storesuit_money_schema', '1', false ); } } /** Whether a money transaction is open in this request (a nested START TRANSACTION would commit it; 0.62.13). */ public static function in_transaction() : bool { return self::$depth > 0; } /** Serialize money/credit commits and roll back ALL database writes on failure. */ public static function atomic( callable $fn ) { global $wpdb; if ( self::$depth ) { // One savepoint per nesting level, named storesuit_nested_. $level = ++self::$depth; try { if ( false === $wpdb->query( $wpdb->prepare( 'SAVEPOINT %i', 'storesuit_nested_' . absint( $level ) ) ) ) { throw new \RuntimeException(I18n::t( 'Nested transaction safety could not be started.' )); } $result = $fn(); if ( false === $wpdb->query( $wpdb->prepare( 'RELEASE SAVEPOINT %i', 'storesuit_nested_' . absint( $level ) ) ) ) { throw new \RuntimeException(I18n::t( 'The nested transaction could not be verified.' )); } return $result; } catch ( \Throwable $e ) { $wpdb->query( $wpdb->prepare( 'ROLLBACK TO SAVEPOINT %i', 'storesuit_nested_' . absint( $level ) ) ); self::forget_rollback( $e ); throw $e; } finally { --self::$depth; } } if ( !self::$checked ) { // The paid build's register, stock and gift card tables join the check in their old places. $more = array( 'pos' => array(), 'stock' => array(), ); $tables = array_merge( array( $wpdb->posts, $wpdb->postmeta, $wpdb->options, $wpdb->comments, $wpdb->commentmeta, $wpdb->prefix . 'woocommerce_order_items', $wpdb->prefix . 'woocommerce_order_itemmeta', $wpdb->prefix . 'storesuit_events' ), $more['pos'], array(self::keys_table(), $wpdb->prefix . 'storesuit_order_index') ); if ( \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ) { foreach ( array( 'wc_orders', 'wc_order_addresses', 'wc_order_operational_data', 'wc_orders_meta' ) as $name ) { $tables[] = $wpdb->prefix . $name; } } $tables = array_merge( $tables, $more['stock'] ); foreach ( $tables as $table ) { $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( strtoupper( (string) $engine ) !== 'INNODB' ) { throw new \RuntimeException(I18n::format( 'Safe transactions require an InnoDB table: %s', $table )); } } self::$checked = true; } // Inside hold() this request already has the lock (hold() releases it). $lock = self::money_lock(); $own = !self::$held; if ( $own && (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) !== 1 ) { throw new \RuntimeException(self::busy()); } $lost_message = I18n::t( 'The connection to the database was lost, so nothing was saved. Try again.' ); $ended_message = I18n::t( 'The database was busy with another action on the same records, so nothing was saved. Try again.' ); self::$depth = 1; self::$aborted = false; self::$lost = false; self::$wrote = ''; // Every statement of the transaction, its COMMIT included, first checks that the database did not end it (review R4). $guard = static function ( $sql ) { self::check_transaction( (string) $sql ); self::note_write( (string) $sql ); return $sql; }; // wpdb reconnects silently and runs the failed statement again on a new connection, outside the transaction and the money // lock, and so would every statement after it (review F2). Not while the transaction is open: the transaction remembers // its connection (check_transaction()), and wpdb does not reconnect. The server rolls the transaction back with a lost // connection; the statement that finds it gone fails, and wpdb, unable to reconnect, ends the request through dead_db() // and wp_die() (in wp-admin, admin-ajax, REST and cron requests; after template_redirect it only fails the statement, and // the next one stops here). While the transaction is open that wp_die() throws instead, so the request ends as a refused // action does: rolled back, and "nothing was saved" on the screen rather than a database error page. $database = $wpdb; $retries = $database->reconnect_retries; $database->reconnect_retries = 0; $dbh = ( isset( $wpdb->dbh ) ? $wpdb->dbh : null ); self::$thread = ( $dbh instanceof \mysqli ? (int) $dbh->thread_id : 0 ); $die = static fn( $handler ) => static function ( ... $args ) use($handler) { self::stop_lost(); return call_user_func_array( $handler, $args ); }; try { if ( false === $wpdb->query( 'START TRANSACTION' ) ) { throw new \RuntimeException(I18n::t( 'The safe transaction could not be started.' )); } add_filter( 'query', $guard, 0 ); foreach ( self::DIE_HANDLERS as $filter ) { add_filter( $filter, $die, PHP_INT_MAX ); } $result = $fn(); if ( false === $wpdb->query( 'COMMIT' ) ) { throw new \RuntimeException(I18n::t( 'The transaction could not be verified. Check the records before trying again.' )); } return $result; } catch ( \Throwable $e ) { // The loss may not have been met yet: the code's own exception can come straight after a lost statement (review of // F1/F2, N1), so it is checked before the ROLLBACK is chosen, and a ROLLBACK that meets it is the loss too. if ( !self::$lost && self::connection_lost() ) { self::$lost = true; } try { // wpdb's API, pinned to the original connection: never reconnect or roll back a replacement connection. // ROLLBACK is constant SQL with no arguments to prepare. A closed connection was rolled back by the server. self::cleanup_connection( $database, $dbh ); } catch ( \Throwable $rollback ) { // The ROLLBACK found the connection gone (check_transaction(), or wp_die() through stop_lost()): the server rolled // the transaction back with its connection. self::$lost = true; unset($rollback); } finally { // WC may have cached objects modified inside the rolled-back transaction: forgotten whatever the ROLLBACK met. self::forget_rollback( $e ); } if ( self::$lost ) { throw new \RuntimeException($lost_message, 0, $e); } if ( self::$aborted ) { throw new \RuntimeException($ended_message, 0, $e); } throw $e; } finally { try { if ( $own ) { self::cleanup_connection( $database, $dbh, $lock ); } } catch ( \Throwable $release ) { unset($release); } finally { remove_filter( 'query', $guard, 0 ); foreach ( self::DIE_HANDLERS as $filter ) { remove_filter( $filter, $die, PHP_INT_MAX ); } $database->reconnect_retries = $retries; self::$aborted = false; self::$lost = false; self::$wrote = ''; self::$thread = 0; self::$depth = 0; // A refused cleanup closed the original connection and the server rolled it back. Only after that transaction // and all its guards ended, restore a usable wpdb for the response. No failed statement is replayed. if ( !$database->ready && !$database->dbh ) { $database->db_connect( false ); } } } } /** * Remembers a statement of the open transaction that may leave something behind in the object cache: anything but a read or a * write that names only tables whose rows are never cached, this plugin's own and Action Scheduler's actions and logs (its * groups are cached). Any other table named, even in a value, and any statement that names no table is remembered too. */ private static function note_write( string $sql ) : void { global $wpdb; if ( '' !== self::$wrote ) { return; } $sql = ltrim( $sql ); if ( preg_match( '/^(?:SELECT|SHOW|DESCRIBE|SET|SAVEPOINT|RELEASE|ROLLBACK|COMMIT)\\b/i', $sql ) ) { return; } $base = preg_quote( $wpdb->base_prefix, '/' ); preg_match_all( '/(?notice( sprintf( 'A rollback emptied the whole object cache: %s, undone by %s (%s:%d)', self::$wrote, get_class( $e ), basename( $e->getFile() ), $e->getLine() ), array( 'source' => 'storeruler-cache', ) ); } catch ( \Throwable $logged ) { unset($logged); } } /** * Stops an open transaction the database ended (reviews R4, F1, F2): a deadlock (1213), a record changed since it was read * (1020, MariaDB's snapshot isolation) or a full lock table (1206) rolls the whole transaction back, a lock wait timeout * (1205) drops its statement, and a lost connection (LOST, or wpdb's connection is no longer the transaction's) takes the * transaction with it. The statements after them would run outside it, each saved at once. The next statement of the * request (or $sql, the statement about to run) therefore throws an Error, which WooCommerce's own catch blocks * (Exception) do not swallow, so nothing more is written; atomic() rolls back and reports it. A ROLLBACK goes through, * unless the connection was lost. Nothing happens outside a transaction. */ public static function check_transaction( string $sql = '' ) : void { global $wpdb; if ( '' !== self::$cleanup_sql ) { self::check_cleanup( $sql ); return; } if ( !self::$depth ) { return; } if ( !self::$aborted ) { if ( self::connection_lost() ) { self::$lost = true; } elseif ( !($wpdb->dbh instanceof \mysqli && in_array( (int) $wpdb->dbh->errno, self::ENDED, true )) ) { return; } } if ( !self::$lost && preg_match( '/^\\s*ROLLBACK\\b/i', $sql ) ) { return; } self::$aborted = true; throw new \Error(( self::$lost ? 'The connection of the store transaction was lost.' : 'The database ended the store transaction.' )); } /** * Use WordPress's database API on the original handle even if other code replaced wpdb's connection. Disable reconnect * throughout and restore both objects/handles in finally. Query filters may observe cleanup, but cannot change its SQL * or its connection: the late guard checks again immediately before wpdb executes it. No new connection is opened. */ private static function cleanup_connection( \wpdb $database, $dbh, ?string $lock = null ) : bool { global $wpdb; if ( !$dbh instanceof \mysqli ) { return false; } try { if ( !self::$thread || (int) $dbh->thread_id !== self::$thread ) { return false; } } catch ( \Throwable $closed ) { return false; } $current = $wpdb; $current_dbh = $database->dbh; $retries = $database->reconnect_retries; $ready = $database->ready; $connected = $database->has_connected; $closed_original = false; $sql = ''; $early = static function ( $query ) { self::check_cleanup( (string) $query ); return $query; }; // Transaction cleanup is not replaceable SQL. Return the exact original statement after every existing filter. $late = static function () use(&$sql) { self::check_cleanup( $sql ); return $sql; }; try { $wpdb = $database; $database->dbh = $dbh; $database->reconnect_retries = 0; self::$cleanup_database = $database; self::$cleanup_dbh = $dbh; $sql = ( null === $lock ? 'ROLLBACK' : $database->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); self::$cleanup_sql = $sql; add_filter( 'query', $early, 0 ); // hold() cleanup has no open atomic() query guard. add_filter( 'query', $late, PHP_INT_MAX ); $result = ( null === $lock ? $database->query( 'ROLLBACK' ) : $database->query( $database->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ) ); if ( false === $result ) { $database->dbh = $dbh; $closed_original = $database->close(); } return false !== $result; } catch ( \Throwable $cleanup ) { // If a filter refuses cleanup or the old connection fails, closing that original connection rolls its transaction // back and frees its locks on the server. The replacement connection is never closed or queried here. try { $database->dbh = $dbh; $closed_original = $database->close(); } catch ( \Throwable $closed ) { unset($closed); } throw $cleanup; } finally { remove_filter( 'query', $early, 0 ); remove_filter( 'query', $late, PHP_INT_MAX ); self::$cleanup_sql = ''; $database->dbh = ( $closed_original && $current_dbh === $dbh ? null : $current_dbh ); $database->ready = ( $database->dbh ? $ready : false ); $database->has_connected = ( $database->dbh ? $connected : false ); $database->reconnect_retries = $retries; $wpdb = $current; self::$cleanup_database = null; self::$cleanup_dbh = null; } } private static function check_cleanup( string $sql ) : void { global $wpdb; // A LOST errno can also be reported on a still-live connection: verify its identity, then attempt the fixed cleanup. // wpdb cannot reconnect here; a genuinely dead query fails and cleanup_connection() closes only the original handle. if ( $sql !== self::$cleanup_sql || $wpdb !== self::$cleanup_database || $wpdb->dbh !== self::$cleanup_dbh || !self::$cleanup_dbh || (int) self::$cleanup_dbh->thread_id !== self::$thread ) { throw new \Error('The store transaction cleanup statement or connection changed.'); } } /** * Whether wpdb's connection is no longer the open transaction's: gone (LOST), closed, or replaced by another (another thread * id: wpdb does not reconnect inside the transaction, other code still could). */ private static function connection_lost() : bool { global $wpdb; $dbh = ( isset( $wpdb->dbh ) ? $wpdb->dbh : null ); if ( !$dbh instanceof \mysqli ) { return self::$thread > 0; } // wpdb has no connection any more. try { $errno = (int) $dbh->errno; $thread = (int) $dbh->thread_id; } catch ( \Throwable $e ) { return true; } // A closed connection. return in_array( $errno, self::LOST, true ) || self::$thread && $thread && $thread !== self::$thread; } /** In wp_die() while a money transaction is open (atomic()): a lost connection stops the transaction, not the request. */ private static function stop_lost() : void { if ( !self::$depth || !(self::$lost || self::connection_lost()) ) { return; } self::$lost = true; self::$aborted = true; throw new \Error('The connection of the store transaction was lost.'); } /** Whether this request's database connection still holds the store's money lock (wpdb reconnects silently; review R5). */ public static function holds_money_lock() : bool { global $wpdb; return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT IS_USED_LOCK(%s) = CONNECTION_ID()', self::money_lock() ) ) === 1; } /** * Keeps this request's idle database connection open for at least $seconds (review R5): a payment provider can take a * minute to answer, and a server that closed the connection meanwhile would end the transaction and free the money lock. */ public static function keep_connection( int $seconds ) : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'SET SESSION wait_timeout = GREATEST(@@SESSION.wait_timeout, %d)', max( 1, $seconds ) ) ); } /** * Runs $fn under the store's money lock, outside any transaction (0.65 review M3): what it writes itself is committed at * once, and atomic() inside it runs its transaction under the same lock. A refund through a payment provider writes the * record of its call before the provider is asked, and its mark after a rollback, with no other money action in between. */ public static function hold( callable $fn ) { global $wpdb; if ( self::$depth || self::$held ) { throw new \RuntimeException(I18n::t( 'Nested transaction safety could not be started.' )); } $lock = self::money_lock(); if ( (int) $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ) !== 1 ) { throw new \RuntimeException(self::busy()); } $database = $wpdb; $dbh = $database->dbh; $thread = ( $dbh instanceof \mysqli ? (int) $dbh->thread_id : 0 ); self::$held = true; try { return $fn(); } finally { // The provider operation's lock belongs to its original connection, even if its callback replaced wpdb. $previous_thread = self::$thread; self::$thread = $thread; try { self::cleanup_connection( $database, $dbh, $lock ); } catch ( \Throwable $release ) { unset($release); } finally { self::$thread = $previous_thread; self::$held = false; } if ( !$database->ready && !$database->dbh ) { $database->db_connect( false ); } } } /** The money lock of one store (per database and table prefix). */ private static function money_lock() : string { global $wpdb; return 'ss-money-' . md5( DB_NAME . $wpdb->prefix ); } private static function busy() : string { $busy = I18n::t( 'Another store action is in progress. Try again shortly.' ); // The till: paid build. return $busy; } /** Runs $fn once per key, inside atomic(): a repeated key returns the first result. */ public static function once( string $key, callable $fn ) { if ( !preg_match( '/^[a-zA-Z0-9:_-]{16,180}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing. Refresh the page.' )); } return self::atomic( static function () use($key, $fn) { global $wpdb; $saved = $wpdb->get_var( $wpdb->prepare( 'SELECT payload FROM %i WHERE source_key=%s', self::keys_table(), $key ) ); if ( null !== $saved ) { return json_decode( $saved, true ); } $result = $fn(); if ( false === $wpdb->insert( self::keys_table(), array( 'source_key' => $key, 'payload' => wp_json_encode( $result ), ) ) ) { throw new \RuntimeException(I18n::t( 'The unique action record could not be saved.' )); } return $result; } ); } /** once() for a screen request: the key is the request's own, per kind and person. */ public static function once_request( string $kind, array $data, callable $fn ) { $key = self::text( $data, 'request_key' ); if ( !preg_match( '/^[a-zA-Z0-9_-]{16,80}$/', $key ) ) { throw new \InvalidArgumentException(I18n::t( 'The action key is missing.' )); } return self::once( 'workflow:' . $kind . ':' . get_current_user_id() . ':' . $key, $fn ); } public static function quantity( $value, bool $zero = false ) : float { if ( !is_scalar( $value ) || !is_numeric( $value ) ) { throw new \InvalidArgumentException(I18n::t( 'Enter a valid quantity.' )); } $n = (float) $value; if ( !is_finite( $n ) || $n < 0 || !$zero && $n === 0.0 || $n > 1000000 ) { throw new \InvalidArgumentException(I18n::t( 'Quantity must be between 0 and 1,000,000; an action requires a quantity greater than zero.' )); } return $n; } public static function valid_date( string $date ) : bool { $d = \DateTimeImmutable::createFromFormat( '!Y-m-d', $date ); return $d && $d->format( 'Y-m-d' ) === $date; } /** * Runs one waiting step of a tool's background job now, in the team member's own request (0.61.1): the tool's own job, * run by Action Scheduler as it would run it. A step a queue runner has already claimed is left to it, and $match picks * the step of this record only (review of 0.61.1: a claimed step is still "pending" until it starts). */ public static function run_pending( string $hook, string $group = '', ?callable $match = null ) : bool { if ( !class_exists( '\\ActionScheduler' ) || !class_exists( '\\ActionScheduler_Store' ) || !function_exists( 'as_get_scheduled_actions' ) ) { return false; } $store = \ActionScheduler::store(); foreach ( (array) as_get_scheduled_actions( array( 'hook' => $hook, 'group' => $group, 'status' => \ActionScheduler_Store::STATUS_PENDING, 'per_page' => 20, 'orderby' => 'date', 'order' => 'ASC', ), 'ids' ) as $action ) { $action = (int) $action; if ( method_exists( $store, 'get_claim_id' ) && $store->get_claim_id( $action ) ) { continue; } if ( $match && !$match( (array) $store->fetch_action( $action )->get_args() ) ) { continue; } try { \ActionScheduler::runner()->process_action( $action, 'StoreRuler' ); } catch ( \Throwable $e ) { return false; } return true; } return false; } /** Must run inside atomic(). Lock WC's actual stock owner, including pooled variations. */ public static function lock_products( array $ids ) : void { global $wpdb; $owners = array(); foreach ( array_unique( array_map( 'intval', $ids ) ) as $id ) { clean_post_cache( $id ); $p = wc_get_product( $id ); if ( !$p ) { throw new \RuntimeException(I18n::format( 'Product not found: #%s', $id )); } $owners[] = (int) $p->get_stock_managed_by_id(); $owners[] = $id; // The variation's routing and unit cost must be stable too. } $owners = array_unique( $owners ); sort( $owners, SORT_NUMERIC ); foreach ( $owners as $id ) { // Lock the post too when a not-yet-managed product has no _stock row. if ( false === $wpdb->query( $wpdb->prepare( 'SELECT ID FROM %i WHERE ID=%d FOR UPDATE', $wpdb->posts, $id ) ) || false === $wpdb->query( $wpdb->prepare( 'SELECT meta_id FROM %i WHERE post_id=%d FOR UPDATE', $wpdb->postmeta, $id ) ) ) { throw new \RuntimeException(I18n::t( 'The stock lock could not be acquired.' )); } clean_post_cache( $id ); } } /** * A phone as digits with the default country code, exactly as the POS engine's customer lookup normalizes it, from the * same setting (the POS settings' phone_cc), so the order index keeps the same phone keys in both builds. */ public static function normalize_phone( $raw ) : string { $digits = preg_replace( '/\\D+/', '', (string) $raw ); if ( '' === $digits ) { return ''; } $cc = self::phone_cc(); if ( '' !== $cc && 0 !== strpos( $digits, $cc ) ) { // "05551112233" → "5551112233" → "905551112233"; keep already-international numbers. $national = ltrim( $digits, '0' ); if ( strlen( $national ) <= 10 ) { $digits = $cc . $national; } } return $digits; } /** The till's country calling code: as saved, or before the settings exist the store country's (the engine's default). */ public static function phone_cc() : string { $settings = get_option( 'rpsw_settings' ); if ( is_array( $settings ) && array_key_exists( 'phone_cc', $settings ) ) { return (string) preg_replace( '/\\D+/', '', (string) $settings['phone_cc'] ); } // Read from WooCommerce here, as the till's settings do: the free build carries no till (modules/ are Pro). static $codes = array(); if ( !function_exists( 'WC' ) || !WC()->countries || !method_exists( WC()->countries, 'get_country_calling_code' ) ) { return ''; } $country = (string) WC()->countries->get_base_country(); if ( !isset( $codes[$country] ) ) { $code = WC()->countries->get_country_calling_code( $country ); $codes[$country] = (string) preg_replace( '/\\D+/', '', (string) (( is_array( $code ) ? reset( $code ) : $code )) ); } return $codes[$country]; } /** * The visitor's network, for the limits of the public forms: an IPv4 address, or the /64 network of an IPv6 address (one * connection gets a whole /64, so all its addresses are one visitor). Behind a local proxy or load balancer (a private or * loopback REMOTE_ADDR) the address that proxy wrote into X-Forwarded-For (the right-most public one) or X-Real-IP is * used; from anywhere else those headers could be forged, so they are ignored (as OrderTracking::client()). '' when the * request has no address (a command-line run). */ public static function visitor_network( ?array $server = null ) : string { return self::network( self::visitor_ip( $server ) ); } /** The three request headers the visitor's address is read from, each read alone, unslashed and sanitized. */ public static function request_addresses() : array { $server = array(); foreach ( array('REMOTE_ADDR', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_REAL_IP') as $key ) { if ( isset( $_SERVER[$key] ) && is_string( $_SERVER[$key] ) ) { $server[$key] = sanitize_text_field( wp_unslash( $_SERVER[$key] ) ); } } return $server; } /** The visitor's own address, as visitor_network() finds it (before the IPv6 /64 is taken); '' when there is none. */ public static function visitor_ip( ?array $server = null ) : string { $server = $server ?? self::request_addresses(); $ip = self::plain_ip( (string) ($server['REMOTE_ADDR'] ?? '') ); $public = static fn( string $address ): bool => (bool) filter_var( $address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); if ( $ip !== '' && !$public( $ip ) ) { $hops = array_reverse( explode( ',', (string) ($server['HTTP_X_FORWARDED_FOR'] ?? '') ) ); $hops[] = (string) ($server['HTTP_X_REAL_IP'] ?? ''); foreach ( $hops as $hop ) { $hop = self::plain_ip( (string) $hop ); if ( $hop !== '' && $public( $hop ) ) { $ip = $hop; break; } } } return $ip; } /** An IPv4 address as it is, an IPv6 address as its /64 network; '' for anything that is not an address. */ public static function network( string $ip ) : string { $ip = self::plain_ip( $ip ); if ( $ip === '' ) { return ''; } $packed = (string) inet_pton( $ip ); return ( strlen( $packed ) === 16 ? inet_ntop( substr( $packed, 0, 8 ) . str_repeat( "\x00", 8 ) ) . '/64' : $ip ); } /** A valid address in its short form, an IPv4-mapped IPv6 address (a dual-stack server's IPv4 visitor) as IPv4; '' otherwise. */ private static function plain_ip( string $ip ) : string { $ip = trim( $ip ); if ( !filter_var( $ip, FILTER_VALIDATE_IP ) ) { return ''; } $packed = (string) inet_pton( $ip ); if ( strlen( $packed ) === 16 && substr( $packed, 0, 12 ) === str_repeat( "\x00", 10 ) . "\xff\xff" ) { $packed = substr( $packed, 12 ); } return (string) inet_ntop( $packed ); } /** * One mailbox for limits, duplicates and opt-outs: lower case, without a "+tag", and for Gmail without the dots Gmail * ignores (googlemail.com is gmail.com). victim+1@gmail.com and v.ictim@gmail.com reach one inbox, so they count as one. */ public static function mailbox( string $email ) : string { $email = strtolower( trim( $email ) ); $at = strrpos( $email, '@' ); if ( $at === false || $at === 0 ) { return $email; } $local = substr( $email, 0, $at ); $domain = substr( $email, $at + 1 ); $plus = strpos( $local, '+' ); if ( $plus !== false && $plus > 0 ) { $local = substr( $local, 0, $plus ); } if ( $domain === 'googlemail.com' ) { $domain = 'gmail.com'; } if ( $domain === 'gmail.com' ) { $local = str_replace( '.', '', $local ); } return $local . '@' . $domain; } /** * Counts one use of a public form against a limit: at most $limit uses of ($kind, $value) per $window seconds. One * atomic statement on a counter whose name holds only a keyed hash (no address or IP is stored) and the end of its * window; a few expired counters are removed on each use, so they never pile up. True within the limit. * The counters have a table of their own (admin audit C4, 28 Oct 2026): every 404 a bot hits made one, and in wp_options * a day's 258 rows sat beside the settings. Until that table exists they are options, as before. */ public static function throttle( string $kind, string $value, int $limit, int $window ) : bool { global $wpdb; $window = max( 60, $window ); $prefix = 'storesuit_rl_'; $ends = (intdiv( time(), $window ) + 1) * $window; $hash = substr( hash_hmac( 'sha256', $kind . '|' . $value, wp_salt( 'nonce' ) ), 0, 32 ); if ( self::install_limits() ) { $table = self::limits_table(); $name = $hash . '_' . $window; if ( false === $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (name,ends,hits) VALUES (%s,%d,1) ON DUPLICATE KEY UPDATE hits=IF(ends=%d,hits+1,1),ends=%d', $table, $name, $ends, $ends, $ends ) ) ) { return false; } $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT hits FROM %i WHERE name=%s', $table, $name ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE ends < %d LIMIT 20', $table, time() ) ); return $count > 0 && $count <= $limit; } $name = $prefix . sprintf( '%010d', $ends ) . '_' . $hash; if ( false === $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,'1','no') ON DUPLICATE KEY UPDATE option_value=CAST(option_value AS UNSIGNED)+1", $wpdb->options, $name ) ) ) { return false; } $count = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name=%s', $wpdb->options, $name ) ); $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s AND option_name < %s LIMIT 20', $wpdb->options, $wpdb->esc_like( $prefix ) . '%', $prefix . sprintf( '%010d', time() ) ) ); return $count > 0 && $count <= $limit; } /** The rate-limit counters: one row per counter, its window's end and its uses. */ public static function limits_table() : string { global $wpdb; return $wpdb->prefix . 'storesuit_rate_limits'; } /** Creates the counters' table once; the counters left in wp_options go then (they would only have expired). */ private static function install_limits() : bool { if ( get_option( 'storesuit_limits_schema' ) === '1' ) { return true; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::limits_table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE {$table} (\n name varchar(48) NOT NULL,\n ends int(10) unsigned NOT NULL,\n hits int(10) unsigned NOT NULL DEFAULT 0,\n PRIMARY KEY (name),\n KEY ends (ends)\n ) {$charset};" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { return false; } $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s', $wpdb->options, $wpdb->esc_like( 'storesuit_rl_' ) . '%' ) ); update_option( 'storesuit_limits_schema', '1', true ); return true; } }