value. * * @return array */ public static function environment(): array { global $wpdb; $theme = wp_get_theme(); $db = (string) get_option( Database::VERSION_OPTION, '' ); return array( 'Flexsolutions Form Builder' => VERSION, 'Database schema' => Database::SCHEMA_VERSION === $db ? $db : sprintf( '%s (expected %s)', '' !== $db ? $db : 'none', Database::SCHEMA_VERSION ), 'Form schema' => (string) Form_Schema::VERSION, 'WordPress' => get_bloginfo( 'version' ) . ( is_multisite() ? ' (multisite)' : '' ), 'PHP' => PHP_VERSION, 'Database server' => method_exists( $wpdb, 'db_server_info' ) ? (string) $wpdb->db_server_info() : 'unknown', 'Web server' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : 'unknown', 'Site language' => get_locale() . ( is_rtl() ? ' (right to left)' : '' ), 'Theme' => $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ), 'PHP memory limit' => (string) ini_get( 'memory_limit' ), 'Largest upload' => size_format( wp_max_upload_size() ), 'WP-Cron' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ? 'disabled (DISABLE_WP_CRON), needs a server cron job' : 'enabled', 'Debug mode' => defined( 'WP_DEBUG' ) && WP_DEBUG ? 'on' : 'off', 'Persistent object cache' => wp_using_ext_object_cache() ? 'yes' : 'no', ); } /** * Active plugins as "Name Version" strings. * * @return string[] */ public static function plugins(): array { if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all = get_plugins(); $active = (array) get_option( 'active_plugins', array() ); if ( is_multisite() ) { $active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) ); } $out = array(); foreach ( array_unique( $active ) as $file ) { if ( isset( $all[ $file ] ) ) { $out[] = trim( $all[ $file ]['Name'] . ' ' . $all[ $file ]['Version'] ); } } sort( $out, SORT_NATURAL | SORT_FLAG_CASE ); return $out; } /** * Job queue status. * * @return array{counts:array,overdue:int,oldest_due:string,next_run:int|false,failed:list,status:string} */ public static function queue(): array { global $wpdb; $table = Database::table( 'jobs' ); $counts = array_fill_keys( array( Jobs::PENDING, Jobs::RUNNING, Jobs::DONE, Jobs::FAILED ), 0 ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT status, COUNT(*) AS n FROM %i GROUP BY status', $table ) ); foreach ( (array) $rows as $row ) { $counts[ (string) $row->status ] = (int) $row->n; } // A job is overdue when it should have run more than 15 minutes ago: // WP-Cron only runs on page views, so a few minutes late is normal. $late = gmdate( 'Y-m-d H:i:s', time() - 15 * MINUTE_IN_SECONDS ); $overdue = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s AND run_at < %s', $table, Jobs::PENDING, $late ) ); $oldest = (string) $wpdb->get_var( $wpdb->prepare( 'SELECT MIN(run_at) FROM %i WHERE status = %s', $table, Jobs::PENDING ) ); $failed = $wpdb->get_results( $wpdb->prepare( 'SELECT id, type, attempts, last_error, updated_at FROM %i WHERE status = %s ORDER BY updated_at DESC, id DESC LIMIT 10', $table, Jobs::FAILED ) ); return array( 'counts' => $counts, 'overdue' => $overdue, 'oldest_due' => $oldest, 'next_run' => wp_next_scheduled( Jobs::HOOK ), 'failed' => is_array( $failed ) ? $failed : array(), 'status' => $overdue > 0 ? self::WARNING : ( $counts[ Jobs::FAILED ] > 0 ? self::WARNING : self::OK ), ); } /** * Sends a plain test email through wp_mail(), like notifications are sent. * * @param string $to Recipient. * @return array{status:string,message:string,to:string} */ public static function send_test_mail( string $to ): array { $to = sanitize_email( $to ); if ( '' === $to || ! is_email( $to ) ) { return array( 'status' => self::ERROR, 'message' => __( 'Enter a valid email address.', 'flexsolutions-form-builder' ), 'to' => $to, ); } $error = ''; $capture = static function ( $wp_error ) use ( &$error ): void { if ( $wp_error instanceof \WP_Error ) { $error = $wp_error->get_error_message(); } }; add_action( 'wp_mail_failed', $capture ); try { $sent = wp_mail( $to, /* translators: %s: site name. */ sprintf( __( '[%s] Flexsolutions Form Builder test email', 'flexsolutions-form-builder' ), wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), sprintf( /* translators: 1: site address, 2: date and time. */ __( "This is a test email from Flexsolutions Form Builder on %1\$s, sent %2\$s.\n\nIf it arrived in your inbox (not spam), form notifications can be delivered too. If it went to spam, set up SPF, DKIM and DMARC for the domain of the address it was sent from.", 'flexsolutions-form-builder' ), home_url(), wp_date( 'Y-m-d H:i:s T' ) ) ); } finally { remove_action( 'wp_mail_failed', $capture ); } if ( $sent ) { return array( 'status' => self::OK, /* translators: %s: email address. */ 'message' => sprintf( __( 'The mail server accepted the test email for %s. Check that it arrives, including the spam folder.', 'flexsolutions-form-builder' ), $to ), 'to' => $to, ); } return array( 'status' => self::ERROR, 'message' => '' !== $error /* translators: %s: error message from the mail server. */ ? sprintf( __( 'The test email could not be sent: %s', 'flexsolutions-form-builder' ), $error ) : __( 'The test email could not be sent, and the mail server gave no reason. An SMTP plugin can help, and usually logs why.', 'flexsolutions-form-builder' ), 'to' => $to, ); } /** * Web address of a file in the upload folder, or null when the folder * is outside the WordPress and uploads folders (so not served by them). * * @param string $dir Folder. */ public static function folder_url( string $dir ): ?string { $dir = wp_normalize_path( untrailingslashit( $dir ) ); $uploads = wp_upload_dir( null, false ); $bases = array( wp_normalize_path( untrailingslashit( (string) $uploads['basedir'] ) ) => untrailingslashit( (string) $uploads['baseurl'] ), wp_normalize_path( untrailingslashit( ABSPATH ) ) => untrailingslashit( site_url() ), ); foreach ( $bases as $path => $url ) { if ( $dir === $path || str_starts_with( $dir . '/', $path . '/' ) ) { return $url . substr( $dir, strlen( $path ) ); } } return null; } /** * Checks whether the web server serves the upload folder (stored files * are encrypted either way): puts a throwaway file there, requests it, * and removes it. * * @return array{status:string,message:string,url:string} */ public static function check_upload_folder(): array { $dir = Uploads::base_dir(); $url = self::folder_url( $dir ); if ( null === $url ) { return array( 'status' => self::OK, 'message' => __( 'Uploaded files are stored outside the WordPress folder (TFLEXFORMS_UPLOAD_DIR), so the web server does not serve them.', 'flexsolutions-form-builder' ), 'url' => '', ); } $token = wp_generate_password( 32, false ); $name = self::PROBE_PREFIX . strtolower( wp_generate_password( 12, false ) ) . '.txt'; $path = $dir . '/' . $name; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Throwaway file in our own folder. if ( false === file_put_contents( $path, $token ) ) { return array( 'status' => self::ERROR, 'message' => __( 'The upload folder is not writable, so uploaded files cannot be stored.', 'flexsolutions-form-builder' ), 'url' => '', ); } try { $response = wp_remote_get( $url . '/' . $name, array( 'timeout' => 10, 'redirection' => 0, /** This filter is documented in wp-includes/class-wp-http-streams.php */ 'sslverify' => (bool) apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core filter. ) ); } finally { wp_delete_file( $path ); } if ( is_wp_error( $response ) ) { return array( 'status' => self::UNKNOWN, /* translators: %s: error message. */ 'message' => sprintf( __( 'The site could not request its own upload folder (%s), so the check could not run. Make sure your server blocks web access to the folder.', 'flexsolutions-form-builder' ), $response->get_error_message() ), 'url' => $url, ); } $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 === $code && str_contains( (string) wp_remote_retrieve_body( $response ), $token ) ) { return array( 'status' => self::WARNING, 'message' => __( 'The web server serves this folder: it ignores the folder\'s .htaccess and web.config rules (this is normal on nginx). Uploaded files are stored encrypted, so a copy downloaded from it cannot be read. To block the folder as well, use one of the settings below.', 'flexsolutions-form-builder' ), 'url' => $url, ); } if ( $code >= 300 && $code < 400 ) { return array( 'status' => self::UNKNOWN, /* translators: %d: HTTP status code. */ 'message' => sprintf( __( 'The server redirected the request for the test file (%d), so the check could not tell whether files can be downloaded. Make sure your server blocks web access to the folder.', 'flexsolutions-form-builder' ), $code ), 'url' => $url, ); } return array( 'status' => self::OK, /* translators: %d: HTTP status code. */ 'message' => sprintf( __( 'Uploaded files cannot be downloaded from the web (the server answered %d).', 'flexsolutions-form-builder' ), $code ), 'url' => $url, ); } /** * Where a key comes from, without the key: 'constant', 'settings' or ''. * * @param string $provider Provider id. * @param string $which 'site_key' or 'secret_key'. */ public static function key_source( string $provider, string $which ): string { if ( defined( Settings::constant_name( $provider, $which ) ) ) { return '' !== Settings::key( $provider, $which ) ? 'constant' : ''; } return '' !== Settings::key( $provider, $which ) ? 'settings' : ''; } /** * CAPTCHA set-up without the provider round trip: which provider is * chosen and whether each key is present. * * @return array{provider:string,label:string,site_key:string,secret_key:string,status:string,message:string} */ public static function captcha_setup(): array { $id = (string) Settings::get( 'captcha_provider' ); $providers = Captcha::providers(); $out = array( 'provider' => $id, 'label' => isset( $providers[ $id ] ) ? $providers[ $id ]->label() : '', 'site_key' => '' !== $id ? self::key_source( $id, 'site_key' ) : '', 'secret_key' => '' !== $id ? self::key_source( $id, 'secret_key' ) : '', 'status' => self::OK, 'message' => __( 'CAPTCHA is not used: no provider is chosen in Settings.', 'flexsolutions-form-builder' ), ); if ( '' === $id ) { return $out; } if ( ! isset( $providers[ $id ] ) ) { $out['status'] = self::ERROR; /* translators: %s: provider id. */ $out['message'] = sprintf( __( 'The chosen CAPTCHA provider "%s" is no longer available. Choose another one in Settings.', 'flexsolutions-form-builder' ), $id ); return $out; } if ( '' === $out['site_key'] || '' === $out['secret_key'] ) { $out['status'] = self::ERROR; $out['message'] = __( 'A key is missing, so forms that ask for CAPTCHA are shown without it. Add both keys in Settings.', 'flexsolutions-form-builder' ); return $out; } $out['message'] = __( 'Both keys are set. Use "Check the keys" to confirm the provider accepts the secret key.', 'flexsolutions-form-builder' ); return $out; } /** * Asks the provider whether it accepts the secret key, by verifying a * dummy token: a provider rejects the token either way, but reports a bad * secret key with its own error code. * * @return array{status:string,message:string,codes:string[]} */ public static function check_captcha_keys(): array { $setup = self::captcha_setup(); if ( self::OK !== $setup['status'] || '' === $setup['provider'] ) { return array( 'status' => $setup['status'], 'message' => $setup['message'], 'codes' => array(), ); } $provider = Captcha::providers()[ $setup['provider'] ]; $verdict = $provider->verify( 'tflexforms-key-check', Settings::key( $provider->id(), 'secret_key' ), '', Settings::key( $provider->id(), 'site_key' ) ); $codes = $verdict->errors; $bad_secret = array_filter( $codes, static fn( string $code ): bool => str_contains( $code, 'secret' ) ); if ( array() !== $bad_secret ) { return array( 'status' => self::ERROR, /* translators: 1: provider name, 2: error codes. */ 'message' => sprintf( __( '%1$s rejected the secret key (%2$s). Copy it again from the provider\'s dashboard; make sure the site key and secret key belong to the same site.', 'flexsolutions-form-builder' ), $provider->label(), implode( ', ', $bad_secret ) ), 'codes' => $codes, ); } $unreachable = array_filter( $codes, static fn( string $code ): bool => in_array( $code, array( 'http-error', 'invalid-json' ), true ) || str_starts_with( $code, 'http-status-' ) ); if ( array() !== $unreachable ) { return array( 'status' => self::UNKNOWN, /* translators: 1: provider name, 2: error codes. */ 'message' => sprintf( __( 'The site could not reach %1$s (%2$s). Submissions with CAPTCHA are refused while this lasts; check that your server can make outgoing HTTPS requests.', 'flexsolutions-form-builder' ), $provider->label(), implode( ', ', $unreachable ) ), 'codes' => $codes, ); } return array( 'status' => self::OK, /* translators: %s: provider name. */ 'message' => sprintf( __( '%s accepted the secret key.', 'flexsolutions-form-builder' ), $provider->label() ), 'codes' => $codes, ); } /** * Plugin settings and content counts for the report. Keys appear only as * where they come from. * * @return array */ public static function plugin_state(): array { global $wpdb; $settings = Settings::all(); $captcha = self::captcha_setup(); $forms = wp_count_posts( Form_Post_Type::POST_TYPE ); $entries = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i', Database::table( 'entries' ) ) ); $spam = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE status = %s', Database::table( 'entries' ), 'spam' ) ); $unsent = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE mail_sent = 0 AND status <> %s', Database::table( 'entries' ), 'spam' ) ); $warning = get_option( Captcha::WARNING_OPTION ); return array( 'Forms' => sprintf( '%d published, %d draft', (int) ( $forms->publish ?? 0 ), (int) ( $forms->draft ?? 0 ) ), 'Forms not converted' => (string) Schema_Migration::failed_count(), 'Entries' => sprintf( '%d (%d spam, %d with notification not sent)', $entries, $spam, $unsent ), 'CAPTCHA provider' => '' !== $captcha['label'] ? $captcha['label'] : ( '' !== $captcha['provider'] ? $captcha['provider'] . ' (missing)' : 'none' ), 'CAPTCHA site key' => self::source_label( $captcha['site_key'], '' !== $captcha['provider'] ), 'CAPTCHA secret key' => self::source_label( $captcha['secret_key'], '' !== $captcha['provider'] ), 'Last CAPTCHA warning' => is_array( $warning ) && isset( $warning['message'] ) ? sanitize_text_field( (string) $warning['message'] ) . ' (' . gmdate( 'Y-m-d H:i', (int) ( $warning['time'] ?? 0 ) ) . ' UTC)' : 'none', 'Disallowed words list' => ! empty( $settings['use_disallowed_list'] ) ? 'used' : 'not used', 'Akismet' => class_exists( 'Akismet' ) && is_callable( array( 'Akismet', 'get_api_key' ) ) && '' !== (string) \Akismet::get_api_key() ? 'active' : 'not active', 'Store IP addresses' => ! empty( $settings['store_ip'] ) ? 'yes' : 'no', 'Entry retention' => (int) $settings['retention_days'] > 0 ? (int) $settings['retention_days'] . ' days' : 'keep until deleted', 'Delete data on uninstall' => ! empty( $settings['delete_data_on_uninstall'] ) ? 'yes' : 'no', 'Upload folder' => null !== self::folder_url( Uploads::base_dir() ) ? 'inside the web root' : 'outside the WordPress folder (TFLEXFORMS_UPLOAD_DIR)', ); } /** * Report wording for a key source. * * @param string $source 'constant', 'settings' or ''. * @param bool $in_use Whether a provider is chosen. */ private static function source_label( string $source, bool $in_use ): string { if ( ! $in_use ) { return 'not used'; } return match ( $source ) { 'constant' => 'set (wp-config.php)', 'settings' => 'set (Settings)', default => 'not set', }; } /** * Every configured CAPTCHA key value, to scrub from text. * * @return string[] */ private static function secrets(): array { $out = array(); foreach ( array_keys( Captcha::providers() ) as $id ) { foreach ( array( 'site_key', 'secret_key' ) as $which ) { $key = Settings::key( $id, $which ); if ( strlen( $key ) >= 4 ) { $out[] = $key; } } } if ( class_exists( 'Akismet' ) && is_callable( array( 'Akismet', 'get_api_key' ) ) ) { $key = (string) \Akismet::get_api_key(); if ( strlen( $key ) >= 4 ) { $out[] = $key; } } /** * Filters the secret values removed from the support report, so * add-ons can add their own API keys. * * @param string[] $secrets Secret values. */ return array_values( array_filter( array_map( 'strval', (array) apply_filters( 'tflexforms_support_report_secrets', $out ) ) ) ); } /** * Plain-text support report: versions, plugins, plugin state and the * queue. Contains no keys, passwords or email addresses. */ public static function report(): string { $queue = self::queue(); $sections = array( 'Environment' => self::environment(), 'Flexsolutions Form Builder' => self::plugin_state(), 'Job queue' => array( 'Pending' => (string) $queue['counts'][ Jobs::PENDING ], 'Running' => (string) $queue['counts'][ Jobs::RUNNING ], 'Done' => (string) $queue['counts'][ Jobs::DONE ], 'Failed' => (string) $queue['counts'][ Jobs::FAILED ], 'Overdue' => (string) $queue['overdue'], 'Next run' => false !== $queue['next_run'] ? gmdate( 'Y-m-d H:i:s', (int) $queue['next_run'] ) . ' UTC' : 'none scheduled', ), ); foreach ( $queue['failed'] as $job ) { $sections['Job queue'][ sprintf( 'Failed #%d', (int) $job->id ) ] = sprintf( '%s, %d attempts, %s UTC: %s', (string) $job->type, (int) $job->attempts, (string) $job->updated_at, (string) $job->last_error ); } $sections['Active plugins'] = self::plugins(); /** * Filters the support report sections before they are written out and * scrubbed of secrets. Each section is a list of lines or label => value. * * @param array> $sections Sections. */ $sections = (array) apply_filters( 'tflexforms_support_report', $sections ); $lines = array( '### Flexsolutions Form Builder support report', 'Generated: ' . gmdate( 'Y-m-d H:i:s' ) . ' UTC', '' ); foreach ( $sections as $title => $rows ) { $lines[] = '## ' . $title; foreach ( (array) $rows as $label => $value ) { $lines[] = is_int( $label ) ? '- ' . $value : $label . ': ' . $value; } $lines[] = ''; } return self::scrub( implode( "\n", $lines ) ); } /** * Removes every known secret value and anything that looks like an * email address from text. * * @param string $text Text. */ public static function scrub( string $text ): string { $secrets = self::secrets(); usort( $secrets, static fn( string $a, string $b ): int => strlen( $b ) <=> strlen( $a ) ); if ( array() !== $secrets ) { $text = str_replace( $secrets, '[removed]', $text ); } return (string) preg_replace( '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[email removed]', $text ); } }