The paid handlers that registered themselves in this request. */ private static array $handled = array(); public static function hooks(): void { add_action( 'init', array( __CLASS__, 'fallback' ), 99 ); add_action( self::SWEEP, array( __CLASS__, 'sweep' ) ); // Staff whose public name is their email address (below). add_action( 'template_redirect', array( __CLASS__, 'no_email_author_page' ), 0 ); add_filter( 'wp_sitemaps_users_query_args', array( __CLASS__, 'users_without_email_names' ) ); add_filter( 'rest_user_query', array( __CLASS__, 'users_without_email_names' ) ); add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'rest_email_user' ), 10, 3 ); add_filter( 'get_the_author_display_name', array( __CLASS__, 'author_name' ), 10, 2 ); add_filter( 'the_author', array( __CLASS__, 'current_author_name' ) ); add_filter( 'get_comment_author', array( __CLASS__, 'comment_author' ), 10, 3 ); add_filter( 'author_link', array( __CLASS__, 'author_link' ), 10, 2 ); add_filter( 'oembed_response_data', array( __CLASS__, 'embed_author' ), 10, 2 ); } /* ---------- Staff email addresses in public names ---------- */ // WordPress makes a user's public name and author address from the email they signed up with when no other name is // set ("ali@example.com", /author/aliexample-com/), and shows both to everyone: the author page (?author=1 redirects // to it), the users sitemap, the public REST user list, bylines, feeds, embeds and the store's replies to reviews. // For such a user the storefront shows the store's name instead and has no author page; nobody's settings change. // Staff who may list users (administrators) still see everything. /** Whether the user's public name or author address is made from their email address. */ public static function email_name( $user ): bool { static $seen = array(); $user = $user instanceof \WP_User ? $user : get_userdata( (int) $user ); if ( ! $user || ! $user->ID || (string) $user->user_email === '' ) { return false; } if ( ! isset( $seen[ $user->ID ] ) ) { $email = strtolower( trim( (string) $user->user_email ) ); $name = strtolower( trim( (string) $user->display_name ) ); $seen[ $user->ID ] = $name === $email || ( $name !== '' && is_email( $name ) !== false ) || (string) $user->user_nicename === sanitize_title( mb_substr( $email, 0, 50 ) ); } return $seen[ $user->ID ]; } /** Shown to this visitor as they are (administrators, staff who manage users), or behind the store's name. */ private static function hide_from_viewer(): bool { return ! current_user_can( 'list_users' ); } private static function storefront(): bool { return ! is_admin() || wp_doing_ajax(); } /** The ids of users with an email-made name among those with published posts (the ones the storefront can show). */ public static function email_name_ids(): array { static $ids = null; if ( $ids === null ) { $ids = array(); foreach ( get_users( array( 'has_published_posts'=>true, 'number'=>500 ) ) as $user ) { if ( self::email_name( $user ) ) { $ids[] = (int) $user->ID; } } } return $ids; } /** template_redirect (0): their author page, and ?author=N that would redirect to it, are not found. */ public static function no_email_author_page(): void { if ( ! is_author() || ! self::hide_from_viewer() ) { return; } $user = get_queried_object(); if ( ! $user instanceof \WP_User || ! self::email_name( $user ) ) { return; } remove_action( 'template_redirect', 'redirect_canonical' ); global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); } /** The users sitemap and the public REST user list leave them out. */ public static function users_without_email_names( $args ) { if ( ! is_array( $args ) || ! self::hide_from_viewer() || ! ( $ids = self::email_name_ids() ) ) { return $args; } $args['exclude'] = array_values( array_unique( array_merge( array_map( 'intval', (array) ( $args['exclude'] ?? array() ) ), $ids ) ) ); return $args; } /** GET /wp/v2/users/ for one of them: not found, as for a user without public posts. */ public static function rest_email_user( $response, $handler, $request ) { if ( $request instanceof \WP_REST_Request && $request->get_method() === 'GET' && preg_match( '#^/wp/v2/users/(\d+)#', (string) $request->get_route(), $m ) && self::hide_from_viewer() && self::email_name( (int) $m[1] ) ) { return new \WP_Error( 'rest_user_invalid_id', I18n::t( 'Invalid user ID.' ), array( 'status'=>404 ) ); } return $response; } private static function store_name(): string { return wp_strip_all_tags( (string) get_bloginfo( 'name' ) ); } /** get_the_author_meta( 'display_name' ) on the storefront (bylines, the theme's author box). */ public static function author_name( $name, $user_id = 0 ) { return self::storefront() && self::hide_from_viewer() && self::email_name( (int) $user_id ) ? self::store_name() : $name; } /** the_author() / get_the_author(): post lists, feeds (dc:creator). */ public static function current_author_name( $name ) { global $authordata; return self::storefront() && $authordata instanceof \WP_User && self::hide_from_viewer() && self::email_name( $authordata ) ? self::store_name() : $name; } /** The store's replies to reviews and comments, written under the staff member's name. */ public static function comment_author( $author, $comment_id = 0, $comment = null ) { return self::storefront() && $comment instanceof \WP_Comment && (int) $comment->user_id > 0 && self::hide_from_viewer() && self::email_name( (int) $comment->user_id ) ? self::store_name() : $author; } /** Links to their (not found) author page go to the home page. */ public static function author_link( $link, $author_id = 0 ) { return self::storefront() && self::hide_from_viewer() && self::email_name( (int) $author_id ) ? home_url( '/' ) : $link; } /** oEmbed answers (another site embedding a post) name the store. */ public static function embed_author( $data, $post = null ) { if ( ! is_array( $data ) || ! $post instanceof \WP_Post || ! self::email_name( (int) $post->post_author ) ) { return $data; } $data['author_name'] = self::store_name(); $data['author_url'] = home_url( '/' ); return $data; } /** A paid handler is running ('price_alerts', 'review_photos'): this class leaves that data to it. */ public static function handled( string $what ): void { self::$handled[ $what ] = true; } public static function is_handled( string $what ): bool { return ! empty( self::$handled[ $what ] ); } public static function alerts_table(): string { global $wpdb; return $wpdb->prefix . 'storesuit_price_alerts'; } private static function alerts_exist(): bool { global $wpdb; $table = self::alerts_table(); return $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) === $table; } /** init (99): what the paid handlers did not take on. */ public static function fallback(): void { add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'exporters' ), 99 ); add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'erasers' ), 99 ); if ( self::is_handled( 'price_alerts' ) ) { if ( get_option( self::SINCE, false ) !== false ) { delete_option( self::SINCE ); } if ( wp_next_scheduled( self::SWEEP ) ) { wp_clear_scheduled_hook( self::SWEEP ); } } elseif ( self::alerts_exist() && ! wp_next_scheduled( self::SWEEP ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::SWEEP ); } if ( ! self::is_handled( 'review_photos' ) ) { add_filter( 'ajax_query_attachments_args', array( __CLASS__, 'without_photos' ) ); add_filter( 'rest_attachment_query', array( __CLASS__, 'rest_without_photos' ) ); add_filter( 'rest_request_before_callbacks', array( __CLASS__, 'rest_photo' ), 10, 3 ); add_action( 'pre_get_posts', array( __CLASS__, 'list_without_photos' ) ); add_action( 'template_redirect', array( __CLASS__, 'no_photo_page' ), 0 ); add_action( 'delete_comment', array( __CLASS__, 'delete_photos_of' ) ); } } /* ---------- Price-drop alerts ---------- */ /** The alerts' storage limits (the paid sweep runs the same): an alert's address goes when the alert ends. */ public static function price_alert_retention(): void { if ( ! self::alerts_exist() ) { return; } global $wpdb; $table = self::alerts_table(); $now = current_time( 'mysql', true ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status='active' AND expires<=%s", $table, $now ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status='pending' AND created<%s", $table, gmdate( 'Y-m-d H:i:s', time() - 48 * HOUR_IN_SECONDS ) ) ); // Ended alerts kept by an earlier version, and sends that stopped half-way (never retried, so never emailed twice). $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status IN ('expired','cancelled') OR ( status='sending' AND COALESCE(notified,created)<%s )", $table, gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) ) ); $wpdb->query( $wpdb->prepare( "UPDATE %i SET email='', email_hash='' WHERE status='sent' AND email<>''", $table ) ); $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status='sent' AND COALESCE(notified,created)<%s", $table, gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ) ) ); } /** Daily without the paid handler: the storage limits, then, after the grace days, the whole table. */ public static function sweep(): void { if ( self::is_handled( 'price_alerts' ) || ! self::alerts_exist() ) { return; } self::price_alert_retention(); $since = (int) get_option( self::SINCE, 0 ); if ( ! $since ) { update_option( self::SINCE, time(), false ); return; } if ( time() - $since >= self::GRACE_DAYS * DAY_IN_SECONDS ) { self::drop_alerts(); } } /** The table, its schema marker and settings, the alert e-mails' settings and the sweep. */ public static function drop_alerts(): void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', self::alerts_table() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Removes StoreRuler Pro's own price alert table after the grace period. foreach ( array( 'storeruler_price_alerts', 'storeruler_price_alerts_schema', 'woocommerce_storesuit_price_alert_settings', 'woocommerce_storesuit_price_alert_confirm_settings', self::SINCE ) as $option ) { delete_option( $option ); } wp_clear_scheduled_hook( self::SWEEP ); } private static function alert_rows( string $email ): array { if ( ! self::alerts_exist() ) { return array(); } global $wpdb; return (array) $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE email_hash=%s', self::alerts_table(), hash( 'sha256', strtolower( trim( $email ) ) ) ), ARRAY_A ); } public static function export_alerts( string $email, int $page = 1 ): array { $data = array(); foreach ( self::alert_rows( $email ) as $row ) { $product = wc_get_product( (int) $row['product_id'] ); $data[] = array( 'group_id'=>'storeruler-price-alerts', 'group_label'=>I18n::t( 'Price drop alerts' ), 'item_id'=>'storeruler-price-alert-' . $row['id'], 'data'=>array( array( 'name'=>I18n::t( 'Product' ), 'value'=>$product ? $product->get_name() : '#' . $row['product_id'] ), array( 'name'=>I18n::t( 'Email address' ), 'value'=>(string) $row['email'] ), array( 'name'=>I18n::t( 'Status' ), 'value'=>(string) $row['status'] ), array( 'name'=>I18n::t( 'Requested' ), 'value'=>$row['created'] . ' UTC' ), ) ); } return array( 'data'=>$data, 'done'=>true ); } public static function erase_alerts( string $email, int $page = 1 ): array { $removed = 0; if ( self::alerts_exist() ) { global $wpdb; $removed = (int) $wpdb->delete( self::alerts_table(), array( 'email_hash'=>hash( 'sha256', strtolower( trim( $email ) ) ) ) ); } return array( 'items_removed'=>$removed > 0, 'items_retained'=>false, 'messages'=>array(), 'done'=>true ); } /* ---------- Gift notes ---------- */ private static function orders( string $email, int $page ): array { if ( ! function_exists( 'wc_get_orders' ) ) { return array( 'ids'=>array(), 'done'=>true ); } $ids = array_map( 'absint', (array) wc_get_orders( array( 'billing_email'=>$email, 'limit'=>20, 'page'=>max( 1, $page ), 'return'=>'ids', 'type'=>'shop_order', 'orderby'=>'ID', 'order'=>'ASC' ) ) ); $done = count( $ids ) < 20; $user = get_user_by( 'email', $email ); if ( $user && $page === 1 ) { $ids = array_values( array_unique( array_merge( $ids, array_map( 'absint', (array) wc_get_orders( array( 'customer_id'=>$user->ID, 'limit'=>-1, 'return'=>'ids', 'type'=>'shop_order' ) ) ) ) ) ); } return array( 'ids'=>$ids, 'done'=>$done ); } public static function export_gifts( string $email, int $page = 1 ): array { $found = self::orders( $email, $page ); $data = array(); foreach ( $found['ids'] as $id ) { $order = wc_get_order( $id ); if ( ! $order ) { continue; } $gift = $order->get_meta( self::GIFT, true ); $note = is_array( $gift ) ? (string) ( $gift['note'] ?? '' ) : ''; if ( $note === '' ) { $note = (string) $order->get_meta( self::GIFT_FIELD, true ); } if ( $note === '' ) { continue; } $data[] = array( 'group_id'=>'storeruler-gift', 'group_label'=>I18n::t( 'Gift notes' ), 'item_id'=>'storeruler-gift-' . $id, 'data'=>array( array( 'name'=>I18n::t( 'Order' ), 'value'=>'#' . $order->get_order_number() ), array( 'name'=>I18n::t( 'Gift note' ), 'value'=>$note ) ) ); } return array( 'data'=>$data, 'done'=>$found['done'] ); } /** The note goes; whether the order was wrapped stays (it is a paid line). */ public static function erase_gifts( string $email, int $page = 1 ): array { $found = self::orders( $email, $page ); $removed = false; foreach ( $found['ids'] as $id ) { $order = wc_get_order( $id ); if ( ! $order ) { continue; } $gift = $order->get_meta( self::GIFT, true ); $changed = false; if ( is_array( $gift ) && ( $gift['note'] ?? '' ) !== '' ) { $gift['note'] = ''; $order->update_meta_data( self::GIFT, $gift ); $changed = true; } if ( $order->get_meta( self::GIFT_FIELD, true ) !== '' ) { $order->delete_meta_data( self::GIFT_FIELD ); $changed = true; } if ( $changed ) { $order->save(); $removed = true; } } $user = get_user_by( 'email', $email ); if ( $user && delete_user_meta( $user->ID, self::GIFT_FIELD ) ) { $removed = true; } return array( 'items_removed'=>$removed, 'items_retained'=>false, 'messages'=>array(), 'done'=>$found['done'] ); } /* ---------- Review photos ---------- */ private static function photo_reviews( string $email ): array { return get_comments( array( 'author_email'=>$email, 'post_type'=>'product', 'status'=>'any', 'number'=>200, 'meta_key'=>self::PHOTO_LIST, 'orderby'=>'comment_ID', 'order'=>'ASC' ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- reviews with photos only. } private static function photos_of( int $comment_id ): array { $ids = array_filter( array_map( 'intval', (array) get_comment_meta( $comment_id, self::PHOTO_LIST, true ) ) ); return array_values( array_filter( $ids, static fn( int $id ) => (int) get_post_meta( $id, self::PHOTO_OWNER, true ) === $comment_id ) ); } public static function export_photos( string $email, int $page = 1 ): array { $data = array(); if ( $page === 1 && is_email( $email ) ) { foreach ( self::photo_reviews( $email ) as $comment ) { foreach ( self::photos_of( (int) $comment->comment_ID ) as $id ) { $data[] = array( 'group_id'=>'storesuit-review-photos', 'group_label'=>I18n::t( 'Review photos' ), 'item_id'=>'sr-review-photo-' . $id, 'data'=>array( array( 'name'=>I18n::t( 'Review ID' ), 'value'=>(string) $comment->comment_ID ), array( 'name'=>I18n::t( 'Photo address' ), 'value'=>(string) wp_get_attachment_url( $id ) ) ) ); } } } return array( 'data'=>$data, 'done'=>true ); } public static function erase_photos( string $email, int $page = 1 ): array { $rows = is_email( $email ) ? self::photo_reviews( $email ) : array(); $removed = false; $retained = false; foreach ( $rows as $comment ) { foreach ( array_filter( array_map( 'intval', (array) get_comment_meta( (int) $comment->comment_ID, self::PHOTO_LIST, true ) ) ) as $id ) { if ( wp_delete_attachment( $id, true ) ) { $removed = true; } else { $retained = true; } } delete_comment_meta( (int) $comment->comment_ID, self::PHOTO_LIST ); } return array( 'items_removed'=>$removed, 'items_retained'=>$retained, 'messages'=>array(), 'done'=>count( $rows ) < 200 || $retained ); } /** delete_comment without the paid handler: a deleted review takes its photos with it. */ public static function delete_photos_of( $comment_id ): void { foreach ( self::photos_of( (int) $comment_id ) as $id ) { wp_delete_attachment( $id, true ); } } private static function no_photos( array $query ): array { $query['meta_query'] = array_merge( (array) ( $query['meta_query'] ?? array() ), array( array( 'key'=>self::PHOTO_OWNER, 'compare'=>'NOT EXISTS' ) ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- review photos only. return $query; } public static function without_photos( $query ) { return is_array( $query ) ? self::no_photos( $query ) : $query; } public static function rest_without_photos( $query ) { return is_array( $query ) && ! current_user_can( 'moderate_comments' ) ? self::no_photos( $query ) : $query; } public static function rest_photo( $response, $handler, $request ) { if ( $request instanceof \WP_REST_Request && preg_match( '#^/wp/v2/media/(\d+)#', (string) $request->get_route(), $m ) && (int) get_post_meta( (int) $m[1], self::PHOTO_OWNER, true ) > 0 && ! current_user_can( 'moderate_comments' ) ) { return new \WP_Error( 'rest_post_invalid_id', I18n::t( 'Invalid post ID.' ), array( 'status'=>404 ) ); } return $response; } /** The media library list, and front-end searches that can return attachments. */ public static function list_without_photos( $query ): void { if ( ! $query instanceof \WP_Query ) { return; } $library = is_admin() && $query->is_main_query() && ( $GLOBALS['pagenow'] ?? '' ) === 'upload.php'; $types = $query->get( 'post_type' ); $search = ! is_admin() && $query->is_search() && ( $types === '' || $types === 'any' || in_array( 'attachment', (array) $types, true ) ); if ( $library || $search ) { $query->set( 'meta_query', self::no_photos( array( 'meta_query'=>$query->get( 'meta_query' ) ) )['meta_query'] ); } } /** A review photo's attachment address never leads to the file (before WordPress's own redirect at 10). */ public static function no_photo_page(): void { $id = is_attachment() ? (int) get_queried_object_id() : 0; if ( ! $id ) { $id = (int) get_query_var( 'attachment_id' ); } if ( ! $id || (int) get_post_meta( $id, self::PHOTO_OWNER, true ) <= 0 ) { return; } remove_action( 'template_redirect', 'redirect_canonical' ); global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); } /* ---------- Exporters and erasers: only where no paid handler registered its own ---------- */ public static function exporters( $items ) { $items = (array) $items; $ours = array( 'storeruler-price-alerts'=>array( I18n::t( 'Price drop alerts' ), 'export_alerts' ), 'storeruler-gift'=>array( I18n::t( 'Gift notes' ), 'export_gifts' ), 'storesuit-review-photos'=>array( I18n::t( 'Review photos' ), 'export_photos' ) ); foreach ( $ours as $key => [ $name, $method ] ) { if ( ! isset( $items[ $key ] ) ) { $items[ $key ] = array( 'exporter_friendly_name'=>$name, 'callback'=>array( __CLASS__, $method ) ); } } return $items; } public static function erasers( $items ) { $items = (array) $items; $ours = array( 'storeruler-price-alerts'=>array( I18n::t( 'Price drop alerts' ), 'erase_alerts' ), 'storeruler-gift'=>array( I18n::t( 'Gift notes' ), 'erase_gifts' ) ); foreach ( $ours as $key => [ $name, $method ] ) { if ( ! isset( $items[ $key ] ) ) { $items[ $key ] = array( 'eraser_friendly_name'=>$name, 'callback'=>array( __CLASS__, $method ) ); } } // Photos before the reviews are anonymised (the reviews are found by their address). if ( ! isset( $items['storesuit-review-photos'] ) ) { $items = array( 'storesuit-review-photos'=>array( 'eraser_friendly_name'=>I18n::t( 'Review photos' ), 'callback'=>array( __CLASS__, 'erase_photos' ) ) ) + $items; } return $items; } /* ---------- Plugin removal ---------- */ /** Every review photo: files, copies and attachments, then the reviews' photo lists. Returns how many were deleted. */ public static function delete_all_photos(): int { global $wpdb; $deleted = 0; for ( $round = 0; $round < 100; $round++ ) { $ids = array_map( 'intval', (array) $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT post_id FROM %i WHERE meta_key=%s LIMIT 200', $wpdb->postmeta, self::PHOTO_OWNER ) ) ); if ( ! $ids ) { break; } foreach ( $ids as $id ) { if ( get_post_type( $id ) === 'attachment' && wp_delete_attachment( $id, true ) ) { ++$deleted; } else { delete_post_meta( $id, self::PHOTO_OWNER ); delete_post_meta( $id, '_sr_review_product' ); } } } delete_metadata( 'comment', 0, self::PHOTO_LIST, '', true ); delete_option( 'storesuit_review_photos_status' ); return $deleted; } /** Deleting the plugin (either build): the price alerts and the review photos go. */ public static function uninstall(): void { self::drop_alerts(); self::delete_all_photos(); } }