core = $core; add_action( 'admin_init', [ $this, 'maybe_send' ] ); } /** Allowed attachment types for wp_handle_upload(). */ private static function mimes(): array { return [ 'jpg|jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp', 'pdf' => 'application/pdf', 'txt|log' => 'text/plain', 'csv' => 'text/csv', 'json' => 'application/json', 'zip' => 'application/zip', ]; } public static function email(): string { return (string) apply_filters( 'patchwork_core_support_email', 'support@patchwork.guru' ); } public static function url( array $args = [] ): string { return add_query_arg( array_merge( [ 'page' => self::PAGE ], $args ), admin_url( 'admin.php' ) ); } // ─── System info ────────────────────────────────────────────────────────── /** What "Include system info" sends — shown to the user before sending. */ public function system_info(): array { global $wp_version; $info = [ 'Site' => home_url(), 'WordPress' => $wp_version . ( is_multisite() ? ' (multisite)' : '' ), 'PHP' => PHP_VERSION, 'Language' => get_locale(), 'Theme' => wp_get_theme()->get( 'Name' ) . ' ' . wp_get_theme()->get( 'Version' ), 'Patchwork Core'=> PATCHWORK_CORE_VERSION, ]; if ( defined( 'WC_VERSION' ) ) { $info['WooCommerce'] = WC_VERSION; if ( class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' ) ) { $info['Order storage'] = \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled() ? 'HPOS' : 'Posts (legacy)'; } } foreach ( $this->core->modules() as $module ) { $info[ $module->name() ] = $module->version() . ( $module->is_pro() ? ' (Pro)' : ' (Free)' ); } return $info; } // ─── Send ───────────────────────────────────────────────────────────────── public function maybe_send(): void { if ( empty( $_POST['patchwork_support_send'] ) ) return; check_admin_referer( 'patchwork_support', 'patchwork_support_nonce' ); if ( ! Admin::current_user_can_menu( $this->core ) ) { wp_die( esc_html__( 'You are not allowed to send support requests from this site.', 'patchwork-order-split' ), 403 ); } $result = $this->send(); // Keep what they typed if sending failed, so nothing is lost. set_transient( 'patchwork_support_result_' . get_current_user_id(), $result, 10 * MINUTE_IN_SECONDS ); wp_safe_redirect( self::url( [ 'sent' => $result['ok'] ? 1 : 0 ] ) ); exit; } /** * @return array{ok: bool, message: string, form?: array} */ private function send(): array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in maybe_send() $form = [ 'plugin' => isset( $_POST['plugin'] ) ? sanitize_key( wp_unslash( $_POST['plugin'] ) ) : '', 'subject' => isset( $_POST['subject'] ) ? sanitize_text_field( wp_unslash( $_POST['subject'] ) ) : '', 'message' => isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '', 'email' => isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '', 'system_info' => ! empty( $_POST['system_info'] ), ]; // phpcs:enable $fail = function( string $message ) use ( $form ) { return [ 'ok' => false, 'message' => $message, 'form' => $form ]; }; $modules = $this->core->modules(); if ( $form['plugin'] !== 'other' && ! isset( $modules[ $form['plugin'] ] ) ) return $fail( __( 'Choose which plugin this is about.', 'patchwork-order-split' ) ); if ( $form['subject'] === '' ) return $fail( __( 'Add a short subject.', 'patchwork-order-split' ) ); if ( mb_strlen( $form['message'] ) < 10 ) return $fail( __( 'Describe the issue in a little more detail.', 'patchwork-order-split' ) ); if ( ! is_email( $form['email'] ) ) return $fail( __( 'Enter an email address we can reply to.', 'patchwork-order-split' ) ); // One request a minute per user, so a double-click doesn't send twice. $throttle = 'patchwork_support_sent_' . get_current_user_id(); if ( get_transient( $throttle ) ) return $fail( __( 'You just sent a request — please wait a minute before sending another.', 'patchwork-order-split' ) ); $files = $this->collect_files(); if ( is_wp_error( $files ) ) return $fail( $files->get_error_message() ); $plugin_name = $form['plugin'] === 'other' ? __( 'Something else', 'patchwork-order-split' ) : $modules[ $form['plugin'] ]->name(); $user = wp_get_current_user(); $body = sprintf( "Plugin: %s\nFrom: %s <%s>\nSite: %s\n\n", $plugin_name, $user->display_name, $form['email'], home_url() ); $body .= $form['message'] . "\n"; if ( $form['system_info'] ) { $body .= "\n— System info —\n"; foreach ( $this->system_info() as $label => $value ) $body .= "$label: $value\n"; } if ( $files ) { $body .= "\nAttachments: " . implode( ', ', array_map( 'basename', $files ) ) . "\n"; } $subject = sprintf( '[%s] %s', $plugin_name, $form['subject'] ); $headers = [ 'Reply-To: ' . ( $user->display_name ? '"' . str_replace( '"', '', $user->display_name ) . '" ' : '' ) . '<' . $form['email'] . '>' ]; $sent = wp_mail( self::email(), $subject, $body, $headers, $files ); // Temporary copies are removed whatever happened. foreach ( $files as $file ) { wp_delete_file( $file ); @rmdir( dirname( $file ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- our own empty temp folder } if ( ! $sent ) { return $fail( sprintf( /* translators: %s: support email address */ __( 'This site couldn\'t send the email (its mail setup may need attention). Please email us directly at %s.', 'patchwork-order-split' ), self::email() ) ); } set_transient( $throttle, 1, MINUTE_IN_SECONDS ); Activity::log( 'core', 'support', sprintf( 'Support request sent: [%s] %s', $plugin_name, $form['subject'] ) ); return [ 'ok' => true, /* translators: %s: email address */ 'message' => sprintf( __( 'Thanks — your request has been sent. We\'ll reply to %s.', 'patchwork-order-split' ), $form['email'] ), ]; } /** * Validate uploads and copy them to a private temp folder under their * original (sanitised) names, so the email shows proper file names. * * @return string[]|\WP_Error Paths to attach. */ private function collect_files() { require_once ABSPATH . 'wp-admin/includes/file.php'; // phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput -- verified in maybe_send(); each file is validated below if ( empty( $_FILES['attachments']['name'] ) || ! is_array( $_FILES['attachments']['name'] ) ) return []; $f = $_FILES['attachments']; // phpcs:enable $paths = []; $count = 0; foreach ( $f['name'] as $i => $name ) { if ( (int) ( $f['error'][ $i ] ?? UPLOAD_ERR_NO_FILE ) === UPLOAD_ERR_NO_FILE || $name === '' ) continue; if ( ++$count > self::MAX_FILES ) { /* translators: %d: number of files */ return new \WP_Error( 'too_many', sprintf( __( 'Attach up to %d files.', 'patchwork-order-split' ), self::MAX_FILES ) ); } if ( (int) $f['error'][ $i ] !== UPLOAD_ERR_OK ) { return new \WP_Error( 'upload', __( 'One of the files didn\'t upload. Try again, or attach a smaller file.', 'patchwork-order-split' ) ); } if ( (int) $f['size'][ $i ] > self::MAX_BYTES ) { /* translators: 1: file name, 2: size limit */ return new \WP_Error( 'size', sprintf( __( '%1$s is too large — the limit is %2$s per file.', 'patchwork-order-split' ), sanitize_file_name( $name ), size_format( self::MAX_BYTES ) ) ); } $type_error = new \WP_Error( 'type', sprintf( /* translators: 1: file name, 2: allowed types */ __( '%1$s isn\'t an allowed file type. Allowed: %2$s.', 'patchwork-order-split' ), sanitize_file_name( $name ), implode( ', ', self::ALLOWED ) ) ); if ( ! in_array( strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ), self::ALLOWED, true ) ) { return $type_error; } // WordPress's own upload handling (type checks included), into a private // temp folder rather than the public uploads directory. $dir = trailingslashit( get_temp_dir() ) . 'patchwork-support-' . wp_generate_password( 12, false ); wp_mkdir_p( $dir ); $to_temp = function( $uploads ) use ( $dir ) { return array_merge( $uploads, [ 'path' => $dir, 'url' => '', 'subdir' => '', 'basedir' => $dir, 'baseurl' => '', 'error' => false ] ); }; add_filter( 'upload_dir', $to_temp ); $upload = [ 'name' => $name, 'type' => $f['type'][ $i ], 'tmp_name' => $f['tmp_name'][ $i ], 'error' => $f['error'][ $i ], 'size' => $f['size'][ $i ] ]; $handled = wp_handle_upload( $upload, [ 'test_form' => false, 'mimes' => self::mimes() ] ); // takes $upload by reference remove_filter( 'upload_dir', $to_temp ); if ( empty( $handled['file'] ) ) { @rmdir( $dir ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- our own empty temp folder foreach ( $paths as $p ) { wp_delete_file( $p ); } return $type_error; } $paths[] = $handled['file']; } return $paths; } // ─── Page ───────────────────────────────────────────────────────────────── public function render(): void { $key = 'patchwork_support_result_' . get_current_user_id(); $result = get_transient( $key ); if ( $result ) delete_transient( $key ); $form = is_array( $result ) && ! empty( $result['form'] ) ? $result['form'] : []; $modules = $this->core->modules(); $user = wp_get_current_user(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- preselect only $pre = $form['plugin'] ?? ( isset( $_GET['plugin'] ) ? sanitize_key( $_GET['plugin'] ) : '' ); ?>