get_blog_prefix() . self::USER_META_SUFFIX; } /** * User meta key of the signature on this site. * * @since 1.0.0 * @return string */ public static function user_sig_key(): string { global $wpdb; return '_' . $wpdb->get_blog_prefix() . self::USER_SIG_SUFFIX; } /** * The module's signing key: 32 random bytes (hex) in a non-autoloaded option, created when * the power-up is switched on, or the first time it's needed. * * @since 1.0.0 * @return string */ public static function key(): string { $key = get_option( self::KEY_OPTION ); // Cached by WordPress after the first read. if ( ! is_string( $key ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $key ) ) { // add_option() is a no-op when another request just added one: read that one back. add_option( self::KEY_OPTION, bin2hex( random_bytes( 32 ) ), '', false ); wp_cache_delete( self::KEY_OPTION, 'options' ); $key = get_option( self::KEY_OPTION ); if ( ! is_string( $key ) || 1 !== preg_match( '/^[a-f0-9]{64}$/D', $key ) ) { $key = bin2hex( random_bytes( 32 ) ); // A broken value: nothing valid was signed with it. update_option( self::KEY_OPTION, $key, false ); } } return $key; } /** * An HMAC with the module's signing key. * * @since 1.0.0 * @param string $data Data. * @return string Hex. */ public static function hmac( string $data ): string { return hash_hmac( 'sha256', $data, self::key() ); } /** * Whether a stored signature is the right one. * * @since 1.0.0 * @param string $kind `post`, `order`, `user`, `comment` or `term`. * @param int $id Object ID. * @param string $batch Batch ID (from the object's marker). * @param mixed $sig Stored signature. * @return bool */ public static function valid_signature( string $kind, int $id, string $batch, $sig ): bool { return self::valid_batch( $batch ) && is_string( $sig ) && '' !== $sig && hash_equals( self::signature( $kind, $id, $batch ), $sig ); } /** * Only the signed ones of a batch's objects (one query per 1,000 IDs). * * @since 1.0.0 * @param string $kind `post`, `order` or `user`. * @param int[] $ids IDs. * @param string $batch Batch ID. * @return int[] */ public static function only_signed( string $kind, array $ids, string $batch ): array { global $wpdb; $ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) ); if ( 'user' === $kind ) { list( $table, $col, $key ) = array( $wpdb->usermeta, 'user_id', self::user_sig_key() ); } elseif ( 'order' === $kind ) { list( , , , $table, $col ) = self::order_tables(); $key = self::SIG; } else { list( $table, $col, $key ) = array( $wpdb->postmeta, 'post_id', self::SIG ); } $out = array(); foreach ( array_chunk( $ids, 1000 ) as $chunk ) { $in = implode( ',', array_fill( 0, count( $chunk ), '%d' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- one %d per ID. $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT %i AS id, meta_value AS sig FROM %i WHERE meta_key = %s AND %i IN ({$in})", array_merge( array( $col, $table, $key, $col ), $chunk ) ) ); foreach ( $rows as $row ) { if ( self::valid_signature( $kind, (int) $row->id, $batch, $row->sig ) ) { $out[ (int) $row->id ] = (int) $row->id; } } } return array_values( array_intersect( $ids, $out ) ); } /** * The signature of one object: its kind (`post`, `order`, `user`, `comment`, `term`), ID and * batch (users also the site, their meta is shared by the network). * * @since 1.0.0 * @param string $kind Kind. * @param int $id Object ID. * @param string $batch Batch ID. * @return string */ public static function signature( string $kind, int $id, string $batch ): string { $data = $kind . '|' . $id . '|' . $batch; if ( 'user' === $kind ) { $data .= '|' . get_current_blog_id(); } return self::hmac( $data ); } /** * Sign an object (right after its first save). Orders are signed through their CRUD object. * * @since 1.0.0 * @param string $kind `post`, `user`, `comment` or `term`. * @param int $id Object ID. * @param string $batch Batch ID. * @return void */ public static function sign( string $kind, int $id, string $batch ): void { if ( 'user' === $kind ) { update_user_meta( $id, self::user_sig_key(), self::signature( 'user', $id, $batch ) ); return; } update_metadata( $kind, $id, self::SIG, self::signature( $kind, $id, $batch ) ); } /** * Sign an order or refund and save the meta. * * @since 1.0.0 * @param \WC_Abstract_Order $order Order or refund (saved). * @param string $batch Batch ID. * @return void */ public static function sign_order( \WC_Abstract_Order $order, string $batch ): void { $order->update_meta_data( self::SIG, self::signature( 'order', (int) $order->get_id(), $batch ) ); $order->save_meta_data(); } /** * Whether an object's marker is exactly this batch and its signature checks out. * * @since 1.0.0 * @param string $kind `post`, `order`, `user`, `comment` or `term`. * @param int $id Object ID. * @param string $batch Batch ID. * @return bool */ public static function verified( string $kind, int $id, string $batch ): bool { switch ( $kind ) { case 'order': $order = wc_get_order( $id ); if ( ! $order ) { return false; } $marker = (string) $order->get_meta( self::META ); $sig = (string) $order->get_meta( self::SIG ); break; case 'user': $marker = (string) get_user_meta( $id, self::user_key(), true ); $sig = (string) get_user_meta( $id, self::user_sig_key(), true ); break; default: $marker = (string) get_metadata( $kind, $id, self::META, true ); $sig = (string) get_metadata( $kind, $id, self::SIG, true ); } return $marker === $batch && '' !== $sig && hash_equals( self::signature( $kind, $id, $batch ), $sig ); } /** * A public stand-in for a batch ID (category slugs, SKUs): derived with the site's salt, so * the batch ID (and anything seeded from it) can't be worked out from the shop. * * @since 1.0.0 * @param string $batch Batch ID. * @return string 10 lowercase letters and digits. */ public static function public_token( string $batch ): string { return substr( base_convert( substr( self::hmac( 'public|' . $batch ), 0, 12 ), 16, 36 ) . '0000000000', 0, 10 ); } /** * Slug of a batch's parent category (children add `-{n}`). * * @since 1.0.0 * @param string $batch Batch ID. * @return string */ public static function slug( string $batch ): string { return self::SLUG . self::public_token( $batch ); } /** * User meta key of this site's roles (membership). * * @since 1.0.0 * @return string */ public static function caps_key(): string { global $wpdb; return $wpdb->get_blog_prefix() . 'capabilities'; } /** * Order tables in use: [ orders table, id column, type column, meta table, meta order column ]. * * @since 1.0.0 * @return array{0: string, 1: string, 2: string, 3: string, 4: string} */ private static function order_tables(): array { global $wpdb; if ( Orders::hpos() && class_exists( OrderUtil::class ) ) { return array( OrderUtil::get_table_for_orders(), 'id', 'type', OrderUtil::get_table_for_order_meta(), 'order_id' ); } return array( $wpdb->posts, 'ID', 'post_type', $wpdb->postmeta, 'post_id' ); } /** * Object counts per batch and type, from the markers (4–5 grouped queries). * * @since 1.0.0 * @return array> Batch ID => type => count (every type present). */ public static function counts(): array { global $wpdb; $out = array(); $add = static function ( $batch, string $type, $count ) use ( &$out ) { if ( ! self::valid_batch( $batch ) ) { return; } if ( ! isset( $out[ $batch ] ) ) { $out[ $batch ] = array_fill_keys( self::TYPES, 0 ); } $out[ $batch ][ $type ] += (int) $count; }; $post_types = array( 'product' => 'products', 'product_variation' => 'variations', 'shop_coupon' => 'coupons', ); $order_types = array( 'shop_order' => 'orders', 'shop_order_refund' => 'refunds', ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- read-only counts of our own markers. $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT pm.meta_value AS batch, p.post_type AS type, COUNT(*) AS n FROM %i pm INNER JOIN %i p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND p.post_type IN ('product', 'product_variation', 'shop_coupon') GROUP BY pm.meta_value, p.post_type", $wpdb->postmeta, $wpdb->posts, self::META ) ); foreach ( $rows as $row ) { $add( $row->batch, $post_types[ $row->type ] ?? 'products', $row->n ); } list( $orders, $id_col, $type_col, $meta, $meta_col ) = self::order_tables(); $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT m.meta_value AS batch, o.%i AS type, COUNT(*) AS n FROM %i m INNER JOIN %i o ON o.%i = m.%i WHERE m.meta_key = %s AND o.%i IN ('shop_order', 'shop_order_refund') GROUP BY m.meta_value, o.%i", $type_col, $meta, $orders, $id_col, $meta_col, self::META, $type_col, $type_col ) ); foreach ( $rows as $row ) { $add( $row->batch, $order_types[ $row->type ] ?? 'orders', $row->n ); } $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT um.meta_value AS batch, COUNT(*) AS n FROM %i um INNER JOIN %i site ON site.user_id = um.user_id AND site.meta_key = %s WHERE um.meta_key = %s GROUP BY um.meta_value', $wpdb->usermeta, $wpdb->usermeta, self::caps_key(), self::user_key() ) ); foreach ( $rows as $row ) { $add( $row->batch, 'customers', $row->n ); } $rows = (array) $wpdb->get_results( $wpdb->prepare( 'SELECT cm.meta_value AS batch, COUNT(*) AS n FROM %i cm INNER JOIN %i c ON c.comment_ID = cm.comment_id WHERE cm.meta_key = %s GROUP BY cm.meta_value', $wpdb->commentmeta, $wpdb->comments, self::META ) ); foreach ( $rows as $row ) { $add( $row->batch, 'reviews', $row->n ); } $rows = (array) $wpdb->get_results( $wpdb->prepare( "SELECT tm.meta_value AS batch, COUNT(*) AS n FROM %i tm INNER JOIN %i tt ON tt.term_id = tm.term_id AND tt.taxonomy = 'product_cat' WHERE tm.meta_key = %s GROUP BY tm.meta_value", $wpdb->termmeta, $wpdb->term_taxonomy, self::META ) ); foreach ( $rows as $row ) { $add( $row->batch, 'terms', $row->n ); } // phpcs:enable return $out; } /** * Total marked objects on this site. * * @since 1.0.0 * @param array>|null $counts counts(), when already read. * @return int */ public static function total( ?array $counts = null ): int { $total = 0; foreach ( null === $counts ? self::counts() : $counts as $types ) { $total += array_sum( $types ); } return $total; } /** * IDs of one batch's objects after a cursor, lowest first. * * Sources: `catalog` (products and variations), `products` (parents only), `coupons`, * `orders` (orders and refunds), `order_parents` (orders only), `customers` (members of this * site), `reviews`, `terms` (product categories). * * @since 1.0.0 * @param string $source Source. * @param string $batch Batch ID. * @param int $after Return IDs above this one. * @param int $limit Most IDs. * @return int[] */ public static function ids( string $source, string $batch, int $after = 0, int $limit = 100 ): array { global $wpdb; if ( ! self::valid_batch( $batch ) ) { return array(); } $limit = max( 1, min( 10000, $limit ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- read-only lookups of our own markers, lowest ID first. switch ( $source ) { case 'catalog': case 'products': case 'coupons': $types = array( 'catalog' => array( 'product', 'product_variation' ), 'products' => array( 'product', 'product' ), 'coupons' => array( 'shop_coupon', 'shop_coupon' ), )[ $source ]; $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT pm.post_id FROM %i pm INNER JOIN %i p ON p.ID = pm.post_id WHERE pm.meta_key = %s AND pm.meta_value = %s AND pm.post_id > %d AND p.post_type IN (%s, %s) ORDER BY pm.post_id ASC LIMIT %d', $wpdb->postmeta, $wpdb->posts, self::META, $batch, $after, $types[0], $types[1], $limit ) ); break; case 'orders': case 'order_parents': list( $orders, $id_col, $type_col, $meta, $meta_col ) = self::order_tables(); $refund = 'orders' === $source ? 'shop_order_refund' : 'shop_order'; $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT m.%i FROM %i m INNER JOIN %i o ON o.%i = m.%i WHERE m.meta_key = %s AND m.meta_value = %s AND m.%i > %d AND o.%i IN (%s, %s) ORDER BY m.%i ASC LIMIT %d', $meta_col, $meta, $orders, $id_col, $meta_col, self::META, $batch, $meta_col, $after, $type_col, 'shop_order', $refund, $meta_col, $limit ) ); break; case 'customers': $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT um.user_id FROM %i um INNER JOIN %i site ON site.user_id = um.user_id AND site.meta_key = %s WHERE um.meta_key = %s AND um.meta_value = %s AND um.user_id > %d ORDER BY um.user_id ASC LIMIT %d', $wpdb->usermeta, $wpdb->usermeta, self::caps_key(), self::user_key(), $batch, $after, $limit ) ); break; case 'reviews': $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT cm.comment_id FROM %i cm INNER JOIN %i c ON c.comment_ID = cm.comment_id WHERE cm.meta_key = %s AND cm.meta_value = %s AND cm.comment_id > %d ORDER BY cm.comment_id ASC LIMIT %d', $wpdb->commentmeta, $wpdb->comments, self::META, $batch, $after, $limit ) ); break; case 'terms': $ids = $wpdb->get_col( $wpdb->prepare( "SELECT tm.term_id FROM %i tm INNER JOIN %i tt ON tt.term_id = tm.term_id AND tt.taxonomy = 'product_cat' WHERE tm.meta_key = %s AND tm.meta_value = %s AND tm.term_id > %d ORDER BY tm.term_id ASC LIMIT %d", $wpdb->termmeta, $wpdb->term_taxonomy, self::META, $batch, $after, $limit ) ); break; default: $ids = array(); } // phpcs:enable return array_values( array_unique( array_map( 'intval', (array) $ids ) ) ); } /** * Newest IDs of other batches' signed objects still usable by a new run (orders, reviews and * coupons of a run that makes none of its own). Sources: `products` (private or published * parents), `customers` (members of this site), `coupons` (published). Objects with a marker * but no valid signature (copied, or written by hand) are never borrowed. * * @since 1.0.0 * @param string $source Source. * @param string $exclude Batch to leave out. * @param int $limit Most IDs. * @return int[] */ public static function pool( string $source, string $exclude, int $limit = 200 ): array { global $wpdb; $limit = max( 1, min( 1000, $limit ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery -- read-only lookups of our own markers, newest first. switch ( $source ) { case 'products': case 'coupons': $kind = 'post'; $type = 'products' === $source ? 'product' : 'shop_coupon'; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT pm.post_id AS id, pm.meta_value AS batch, s.meta_value AS sig FROM %i pm INNER JOIN %i p ON p.ID = pm.post_id INNER JOIN %i s ON s.post_id = pm.post_id AND s.meta_key = %s WHERE pm.meta_key = %s AND pm.meta_value <> %s AND p.post_type = %s AND p.post_status IN ('private', 'publish') ORDER BY pm.post_id DESC LIMIT %d", $wpdb->postmeta, $wpdb->posts, $wpdb->postmeta, self::SIG, self::META, $exclude, $type, $limit ) ); break; case 'customers': $kind = 'user'; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT um.user_id AS id, um.meta_value AS batch, s.meta_value AS sig FROM %i um INNER JOIN %i site ON site.user_id = um.user_id AND site.meta_key = %s INNER JOIN %i s ON s.user_id = um.user_id AND s.meta_key = %s WHERE um.meta_key = %s AND um.meta_value <> %s ORDER BY um.user_id DESC LIMIT %d', $wpdb->usermeta, $wpdb->usermeta, self::caps_key(), $wpdb->usermeta, self::user_sig_key(), self::user_key(), $exclude, $limit ) ); break; default: $kind = ''; $rows = array(); } // phpcs:enable $ids = array(); foreach ( (array) $rows as $row ) { if ( self::valid_signature( $kind, (int) $row->id, (string) $row->batch, $row->sig ) ) { $ids[] = (int) $row->id; } } sort( $ids ); return $ids; } }