__( 'Getting started', 'abientot' ), 'booking_form' => __( 'Booking form', 'abientot' ), 'calendar' => __( 'Calendar and availability', 'abientot' ), 'notifications' => __( 'Email and SMS', 'abientot' ), 'payments' => __( 'Payments', 'abientot' ), 'import' => __( 'Import and migration', 'abientot' ), 'licensing' => __( 'Pro and licensing', 'abientot' ), 'bug' => __( 'Report a problem', 'abientot' ), 'feedback' => __( 'Feature request', 'abientot' ), 'other' => __( 'Other', 'abientot' ), ); } public function ajax(): void { if ( ! check_ajax_referer( 'abientot_contact_request', 'nonce', false ) ) { wp_send_json_error( $this->expired(), 403 ); } if ( ! self::permitted() ) { wp_send_json_error( $this->expired(), 403 ); } if ( ! \Abientot\Support\Request::is_post() ) { wp_send_json_error( $this->expired(), 405 ); } $input = \Abientot\Support\Request::post_fields( 'abientot_contact_request', array( 'attachments_redacted', 'consent', 'email' => 'email', 'message' => 'textarea', 'topic', ), 'nonce' ); $upload = \Abientot\Support\Request::files( 'abientot_contact_request', 'attachments', 'nonce' ); $result = $this->send( $input, $upload ? array( 'attachments' => $upload ) : array() ); if ( $result['ok'] ) { wp_send_json_success( $result ); } else { wp_send_json_error( $result ); } } /** * Validates and sends a contact request. The caller has verified the nonce, the * capability and the POST method; $input holds sanitized fields only. */ public function send( array $input, array $files ): array { if ( ! self::permitted() ) { return $this->expired(); } if ( array_diff( array_keys( $input ), array( 'topic', 'email', 'message', 'consent', 'attachments_redacted' ) ) || array_diff( array_keys( $files ), array( 'attachments' ) ) || ( $input['consent'] ?? '' ) !== '1' ) { return $this->invalid(); } foreach ( array( 'topic', 'email', 'message' ) as $key ) { if ( ! is_string( $input[ $key ] ?? null ) ) { return $this->invalid(); } } $topics = self::topics(); $email = trim( $input['email'] ); $message = trim( $input['message'] ); if ( ! isset( $topics[ $input['topic'] ] ) || strlen( $email ) > 254 || preg_match( '/[\r\n\x00]/', $email ) || ! is_email( $email ) || $message === '' || preg_match( '//u', $message ) !== 1 || preg_match( '/\x00/', $message ) ) { return $this->invalid(); } $length = function_exists( 'mb_strlen' ) ? mb_strlen( $message, 'UTF-8' ) : preg_match_all( '/./us', $message ); if ( $length > 10000 ) { return $this->invalid(); } try { $attachments = $this->attachments( $files ); if ( $attachments && ( $input['attachments_redacted'] ?? '' ) !== '1' ) { return $this->failure( __( 'Anonymize the files and confirm that they contain no identifying customer data, sensitive information or secrets.', 'abientot' ) ); } if ( BookingLock::is_held() || ! BookingLock::acquire() ) { return $this->limited(); } $claimed = false; try { $now = time(); $keys = array( 'abientot_contact_site_send', 'abientot_contact_user_' . get_current_user_id() ); foreach ( $keys as $key ) { wp_cache_delete( $key, 'options' ); $last = get_option( $key, 0 ); if ( ( ! is_int( $last ) && ( ! is_string( $last ) || ! preg_match( '/^[0-9]{1,12}$/D', $last ) ) ) || $last < 0 || ( (int) $last > 0 && $now - (int) $last < 300 ) ) { return $this->limited(); } } foreach ( $keys as $key ) { if ( ! update_option( $key, $now, false ) ) { return $this->limited(); } } $claimed = true; } catch ( \Throwable $error ) { return $this->limited(); } finally { BookingLock::release(); } if ( ! $claimed || BookingLock::is_held() ) { return $this->limited(); } $body = "Support privacy notice: " . \Abientot\Privacy\SupportNotice::VERSION . "\nTopic: " . $topics[ $input['topic'] ] . "\nSender email: " . $email . "\n\nMessage:\n" . $message; try { $accepted = wp_mail( 'contact@abientot.app', 'Contact request', $body, array( 'Content-Type: text/plain; charset=UTF-8', 'Reply-To: ' . $email ), $attachments ); } catch ( \Throwable $error ) { return array( 'ok' => false, 'attempted' => true, 'message' => __( 'The send result is uncertain. Do not retry automatically; check your mail transport.', 'abientot' ) ); } return array( 'ok' => (bool) $accepted, 'attempted' => true, 'message' => $accepted ? __( 'Your mail transport accepted the contact request. Inbox delivery is not confirmed.', 'abientot' ) : __( 'Your mail transport did not accept the contact request. No automatic retry will occur.', 'abientot' ) ); } catch ( \Throwable $error ) { return $this->failure( __( 'Attachments must be PDF, JPEG, PNG, GIF, WebP or plain CSV files: at most three files, up to 3 MB each.', 'abientot' ) ); } } /** Only server-verified PHP uploads can become attachments. Never use client paths or MIME. */ private function attachments( array $files ): array { if ( ! $files ) { return array(); } $upload = $files['attachments']; if ( ! is_array( $upload ) || array_diff( array_keys( $upload ), array( 'name', 'tmp_name', 'type', 'error', 'size', 'full_path' ) ) ) { throw new \InvalidArgumentException(); } foreach ( array( 'name', 'tmp_name', 'type', 'error', 'size' ) as $key ) { if ( ! isset( $upload[ $key ] ) || ! is_array( $upload[ $key ] ) ) { throw new \InvalidArgumentException(); } } $keys = array_keys( $upload['name'] ); foreach ( array( 'tmp_name', 'type', 'error', 'size' ) as $key ) { if ( array_keys( $upload[ $key ] ) !== $keys ) { throw new \InvalidArgumentException(); } } if ( count( $keys ) > 3 ) { throw new \InvalidArgumentException(); } $accepted = array(); $info = new \finfo( FILEINFO_MIME_TYPE ); foreach ( $keys as $key ) { if ( $upload['error'][ $key ] === UPLOAD_ERR_NO_FILE && count( $keys ) === 1 ) { continue; } $name = $upload['name'][ $key ]; $source = $upload['tmp_name'][ $key ]; if ( $upload['error'][ $key ] !== UPLOAD_ERR_OK || ! is_string( $name ) || ! is_string( $source ) || ! is_uploaded_file( $source ) ) { throw new \InvalidArgumentException(); } $size = filesize( $source ); $extension = strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ); if ( $size === false || $size < 1 || $size > 3 * 1024 * 1024 ) { throw new \InvalidArgumentException(); } $mime = $info->file( $source ); $image = array( 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'gif' => 'image/gif', 'webp' => 'image/webp' ); if ( isset( $image[ $extension ] ) ) { $dimensions = getimagesize( $source ); if ( $mime !== $image[ $extension ] || ! $dimensions || ( $dimensions['mime'] ?? '' ) !== $mime ) { throw new \InvalidArgumentException(); } } elseif ( $extension === 'pdf' ) { $magic = file_get_contents( $source, false, null, 0, 5 ); if ( $mime !== 'application/pdf' || $magic !== '%PDF-' ) { throw new \InvalidArgumentException(); } } elseif ( $extension === 'csv' ) { $text = file_get_contents( $source ); if ( ! in_array( $mime, array( 'text/plain', 'text/csv', 'application/csv' ), true ) || ! is_string( $text ) || preg_match( '//u', $text ) !== 1 || preg_match( '/[\x00-\x08\x0B\x0C\x0E-\x1F]|<\s*[a-z!\/\?]/i', $text ) ) { throw new \InvalidArgumentException(); } } else { throw new \InvalidArgumentException(); } $accepted[] = array( $source, $extension ); } // wp_mail() (WP 6.2+) names attachments from string keys, so the verified PHP upload files are // attached in place: nothing is copied, and PHP deletes them when the request ends. $named = array(); foreach ( $accepted as $index => [$source, $extension] ) { $named[ 'attachment-' . ( $index + 1 ) . '.' . $extension ] = $source; } return $named; } private function failure( string $message ): array { return array( 'ok' => false, 'attempted' => false, 'message' => $message ); } private function expired(): array { return $this->failure( __( 'Your session expired or you do not have permission. Reload the page and try again.', 'abientot' ) ); } private function invalid(): array { return $this->failure( __( 'Choose a topic, enter a valid email and a message of up to 10,000 characters, and give explicit consent.', 'abientot' ) ); } private function limited(): array { return $this->failure( __( 'A contact request was recently attempted or sending is unavailable. Wait at least five minutes; do not retry an uncertain send.', 'abientot' ) ); } }