true ); foreach ( array_merge( self::extra_pages(), self::type_pages() ) as $url ) { $urls[ $url ] = true; } $ids = array_merge( self::posts_with_forms(), self::products() ); foreach ( array_merge( $ids, self::pages() ) as $id ) { if ( in_array( (int) $id, $skip, true ) ) { continue; } $url = get_permalink( (int) $id ); if ( $url ) { $urls[ $url ] = true; } if ( count( $urls ) >= self::MAX_PAGES ) { break; } } /** * Filters the pages the form scan opens. * * @param string[] $urls Page URLs of this site. */ $urls = (array) apply_filters( 'dealroot_form_scan_urls', array_keys( $urls ) ); return array_slice( array_values( array_filter( array_map( 'strval', $urls ), array( __CLASS__, 'is_own_url' ) ) ), 0, self::MAX_PAGES ); } /** * Two entries and the archive of every public post type, e.g. products of a shop the theme * builds itself, because a form in a template shows on each page of that type. With two * pages, such a form is not named after the heading of one product. * * @return string[] URLs. */ private static function type_pages() { $urls = array(); $types = array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment', 'page' ) ); foreach ( array_slice( array_values( $types ), 0, 20 ) as $type ) { $entries = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'has_password' => false, 'numberposts' => 2, 'fields' => 'ids', ) ); foreach ( $entries as $id ) { $urls[] = (string) get_permalink( (int) $id ); } $archive = get_post_type_archive_link( $type ); if ( $archive && 'post' !== $type ) { $urls[] = (string) $archive; } } return array_values( array_unique( array_filter( $urls ) ) ); } /** * Published posts of any public type whose content or Elementor data holds a form. * * @return int[] */ private static function posts_with_forms() { global $wpdb; $types = array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) ); if ( ! $types ) { return array(); } $likes = array(); foreach ( self::MARKERS as $marker ) { $likes[] = '%' . $wpdb->esc_like( $marker ) . '%'; } // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $content = $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM %i WHERE post_status = 'publish' AND post_password = '' AND post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ') AND (' . implode( ' OR ', array_fill( 0, count( $likes ), 'post_content LIKE %s' ) ) . ') ORDER BY post_modified DESC LIMIT 60', array_merge( array( $wpdb->posts ), $types, $likes ) ) ); $elementor = $wpdb->get_col( $wpdb->prepare( "SELECT p.ID FROM %i p INNER JOIN %i m ON m.post_id = p.ID WHERE m.meta_key = '_elementor_data' AND m.meta_value LIKE %s AND p.post_status = 'publish' AND p.post_password = '' AND p.post_type IN (" . implode( ',', array_fill( 0, count( $types ), '%s' ) ) . ') ORDER BY p.post_modified DESC LIMIT 60', array_merge( array( $wpdb->posts, $wpdb->postmeta, '%' . $wpdb->esc_like( '"widgetType":"form"' ) . '%' ), $types ) ) ); // phpcs:enable return array_map( 'intval', array_merge( (array) $content, (array) $elementor ) ); } /** * Published pages, in menu order. * * @return int[] */ private static function pages() { return array_map( 'intval', get_posts( array( 'post_type' => 'page', 'post_status' => 'publish', 'has_password' => false, 'numberposts' => self::MAX_PAGES, 'orderby' => array( 'menu_order' => 'ASC', 'date' => 'DESC', ), 'fields' => 'ids', ) ) ); } /** * Pages scanned one by one. * * @return string[] */ public static function extra_pages() { return array_values( array_filter( array_map( 'strval', (array) get_option( self::PAGES_OPTION, array() ) ), array( __CLASS__, 'is_own_url' ) ) ); } /** * Keeps a page scanned on its own, so later scans open it again. * * @param string $url Page URL. */ public static function add_page( $url ) { $url = esc_url_raw( $url ); if ( '' === $url || ! self::is_own_url( $url ) ) { return; } $pages = array_values( array_diff( self::extra_pages(), array( $url ) ) ); array_unshift( $pages, $url ); update_option( self::PAGES_OPTION, array_slice( $pages, 0, self::MAX_EXTRA ), false ); } /** * Products whose page may hold a form other products do not have: a few without a price, * where shops often ask to request one, and one product of each category. * * @return int[] */ private static function products() { if ( ! post_type_exists( 'product' ) ) { return array(); } $base = array( 'post_type' => 'product', 'post_status' => 'publish', 'has_password' => false, 'fields' => 'ids', ); $ids = get_posts( $base + array( 'numberposts' => 3, 'meta_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Runs when the admin starts a scan. 'relation' => 'OR', array( 'key' => '_price', 'compare' => 'NOT EXISTS', ), array( 'key' => '_price', 'value' => '', ), ), ) ); if ( taxonomy_exists( 'product_cat' ) ) { $categories = get_terms( array( 'taxonomy' => 'product_cat', 'hide_empty' => true, 'number' => 10, 'fields' => 'ids', ) ); foreach ( is_array( $categories ) ? $categories : array() as $category ) { $ids = array_merge( $ids, get_posts( $base + array( 'numberposts' => 1, 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- Runs when the admin starts a scan. array( 'taxonomy' => 'product_cat', 'terms' => (int) $category, ), ), ) ) ); } } return array_values( array_unique( array_map( 'intval', $ids ) ) ); } /** * Cart, checkout and account pages, which only hold shop forms. * * @return int[] */ private static function shop_pages() { if ( ! function_exists( 'wc_get_page_id' ) ) { return array(); } return array_map( 'intval', array( wc_get_page_id( 'cart' ), wc_get_page_id( 'checkout' ), wc_get_page_id( 'myaccount' ) ) ); } /** * Whether a URL points to this site. * * @param string $url URL. * @return bool */ public static function is_own_url( $url ) { $host = wp_parse_url( home_url(), PHP_URL_HOST ); return in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) && strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) ) === strtolower( (string) $host ); } /** * Last result. * * @return array{time: int, pages: int, failed: int, forms: array}|null */ public static function last() { $scan = get_option( self::OPTION ); return is_array( $scan ) && isset( $scan['forms'] ) ? $scan : null; } /** * Whether the last result is of an older format. * * @return bool */ public static function outdated() { $scan = self::last(); return $scan && self::FORMAT !== (int) ( $scan['format'] ?? 0 ); } /** * Checks and stores what the browser found. * * @param array $data pages, failed and forms as sent by the scan script. * @param bool $merge Add the forms to those of the last scan, e.g. after scanning one more page. * @return array Stored result. */ public static function store( array $data, $merge = false ) { $plugins = self::plugin_classes(); $forms = array(); foreach ( array_slice( (array) ( $data['forms'] ?? array() ), 0, self::MAX_FORMS ) as $raw ) { $form = is_array( $raw ) ? self::clean_form( $raw, $plugins ) : null; if ( ! $form ) { continue; } if ( isset( $forms[ $form['key'] ] ) ) { $forms[ $form['key'] ]['pages'] = array_slice( array_merge( $forms[ $form['key'] ]['pages'], $form['pages'] ), 0, 20 ); continue; } $forms[ $form['key'] ] = $form; } $pages = max( 0, min( self::MAX_PAGES, (int) ( $data['pages'] ?? 0 ) ) ); $failed = max( 0, min( self::MAX_PAGES, (int) ( $data['failed'] ?? 0 ) ) ); $last = $merge ? self::last() : null; if ( $last ) { $known = array(); foreach ( $last['forms'] as $form ) { $known[ $form['key'] ] = $form; } foreach ( $forms as $key => $form ) { if ( isset( $known[ $key ] ) ) { $known[ $key ]['pages'] = self::clean_pages( array_merge( $known[ $key ]['pages'], $form['pages'] ) ); if ( 'plugin' !== $form['kind'] && $known[ $key ]['name'] !== $form['name'] ) { $known[ $key ]['name'] = self::shared_name( (string) $known[ $key ]['name'], (string) $form['name'] ); } } else { $known[ $key ] = $form; } } $forms = array_slice( $known, 0, self::MAX_FORMS ); $pages += (int) $last['pages']; $failed += (int) $last['failed']; } $scan = array( 'format' => $last && self::FORMAT !== (int) ( $last['format'] ?? 0 ) ? 0 : self::FORMAT, 'time' => time(), 'pages' => $pages, 'failed' => $failed, 'forms' => array_values( $forms ), ); update_option( self::OPTION, $scan, false ); return $scan; } /** * One found form, or null when it is not valid. * * @param array $raw Form from the browser. * @param array $plugins Plugin key => integration class. * @return array|null */ private static function clean_form( array $raw, array $plugins ) { $kind = (string) ( $raw['kind'] ?? '' ); $name = mb_substr( sanitize_text_field( (string) ( $raw['name'] ?? '' ) ), 0, 191 ); $form = array( 'kind' => $kind, 'key' => '', 'plugin' => '', 'id' => '', 'selector' => '', 'provider' => '', 'via' => '', 'tag' => 'newsletter' === ( $raw['tag'] ?? '' ) ? 'newsletter' : '', 'name' => $name, 'pages' => self::clean_pages( (array) ( $raw['pages'] ?? array() ) ), ); switch ( $kind ) { case 'plugin': $plugin = (string) ( $raw['plugin'] ?? '' ); $key = Form_Selection::key( $plugin, (string) ( $raw['id'] ?? '' ) ); if ( ! isset( $plugins[ $plugin ] ) || '' === $key ) { return null; } $form['plugin'] = $plugin; $form['id'] = (string) $raw['id']; $form['key'] = $key; $title = ctype_digit( $form['id'] ) ? $plugins[ $plugin ]::form_title( (int) $form['id'] ) : ''; $form['name'] = '' !== $title ? mb_substr( $title, 0, 191 ) : $name; break; case 'custom': $form['selector'] = Form_Selection::sanitize_selector( (string) ( $raw['selector'] ?? '' ) ); if ( '' === $form['selector'] ) { return null; } $form['key'] = 'custom:' . $form['selector']; break; case 'coded': // Why it is always saved: "attribute" for data-dealroot in the theme, otherwise the // selector from the settings that matched. $via = (string) ( $raw['via'] ?? '' ); $form['via'] = 'attribute' === $via ? $via : Form_Selection::sanitize_selector( $via ); $form['selector'] = Form_Selection::sanitize_selector( (string) ( $raw['selector'] ?? '' ) ); $form['key'] = 'coded:' . md5( '' !== $form['selector'] ? $form['selector'] : $name ); break; case 'auto': $plugin = (string) ( $raw['plugin'] ?? '' ); if ( ! isset( $plugins[ $plugin ] ) ) { return null; } $form['plugin'] = $plugin; $form['key'] = 'auto:' . $plugin; break; case 'embed': $provider = (string) ( $raw['provider'] ?? '' ); if ( ! in_array( $provider, self::PROVIDERS, true ) ) { return null; } $form['provider'] = $provider; $form['name'] = $provider; $form['plugin'] = (string) array_search( $provider, Registry::EMBEDS, true ); // Embeds whose submissions reach the page can be switched off like a form plugin. $form['key'] = '' !== $form['plugin'] ? $form['plugin'] . ':all' : 'embed:' . sanitize_key( $provider ); break; default: return null; } return $form; } /** * What the names of one form on several pages share, e.g. "Bestellanfrage" for * "Bestellanfrage für Ring" and "Bestellanfrage für Armreif". Same as in the scan script. * * @param string $first Name. * @param string $second Name. * @return string */ private static function shared_name( $first, $second ) { $a = explode( ' ', $first ); $b = explode( ' ', $second ); $words = array(); foreach ( $a as $i => $word ) { if ( ! isset( $b[ $i ] ) || $b[ $i ] !== $word ) { break; } $words[] = $word; } $shared = preg_replace( '/\s+(for|für|zu|zum|zur|de|du|des|pour|per|para|por|voor|van|dla|na|o|для|на|о|про)$/iu', '', implode( ' ', $words ) ); $shared = preg_replace( '/[\s:–—-]+$/u', '', (string) $shared ); return mb_strlen( (string) $shared ) >= 3 ? (string) $shared : $first; } /** * Pages a form was found on. * * @param array $pages URL and title of each page. * @return array */ private static function clean_pages( array $pages ) { $clean = array(); foreach ( array_slice( $pages, 0, 20 ) as $page ) { $url = esc_url_raw( (string) ( is_array( $page ) ? ( $page['url'] ?? '' ) : '' ) ); if ( '' === $url || ! self::is_own_url( $url ) || isset( $clean[ $url ] ) ) { continue; } $clean[ $url ] = array( 'url' => $url, 'title' => mb_substr( sanitize_text_field( (string) ( $page['title'] ?? '' ) ), 0, 191 ), ); } return array_values( $clean ); } /** * Integrations whose forms the scan can recognize, by plugin key. * * @return array */ public static function plugin_classes() { $classes = array(); foreach ( Registry::integrations() as $class ) { if ( is_a( $class, Integration::class, true ) && 'woocommerce' !== $class::key() ) { $classes[ $class::key() ] = $class; } } return $classes; } }