{$unread}" : ''; add_menu_page( __( 'Submissions', 'ago-contact' ), __( 'Submissions', 'ago-contact' ) . $badge, 'manage_options', 'agocontact-submissions', [ Admin\Submissions::class, 'render' ], 'dashicons-email-alt', 26 ); } /* ───── Admin Assets ───── */ public function admin_assets( string $hook ): void { if ( ! str_ends_with( $hook, '_page_agocontact' ) && $hook !== 'toplevel_page_agocontact-submissions' && ! str_ends_with( $hook, '_page_agocontact-submissions' ) ) { return; } wp_enqueue_style( 'agocontact-admin', AGOCONTACT_URL . 'assets/css/admin.css', [], AGOCONTACT_VERSION ); wp_enqueue_script( 'agocontact-admin', AGOCONTACT_URL . 'assets/js/admin.js', [], AGOCONTACT_VERSION, true ); wp_localize_script( 'agocontact-admin', 'agocontactAdmin', [ 'restUrl' => rest_url( 'agocontact/v1' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'exportUrl' => wp_nonce_url( admin_url( 'admin-post.php?action=agocontact_export' ), 'agocontact_export' ), 'settings' => self::get_settings(), ] ); } /* ───── Frontend Assets ───── */ public function frontend_assets(): void { // Only enqueue if shortcode or block is used global $post; if ( ! is_a( $post, 'WP_Post' ) ) { return; } if ( ! has_shortcode( $post->post_content, 'agocontact' ) && ! has_block( 'agocontact/form', $post ) ) { return; } $settings = self::get_settings(); wp_enqueue_style( 'agocontact-form', AGOCONTACT_URL . 'assets/css/form.css', [], AGOCONTACT_VERSION ); wp_enqueue_script( 'agocontact-form', AGOCONTACT_URL . 'assets/js/form.js', [], AGOCONTACT_VERSION, true ); $captcha_type = $settings['captcha_type'] ?? 'none'; wp_localize_script( 'agocontact-form', 'agocontactForm', [ 'ajaxUrl' => rest_url( 'agocontact/v1/submit' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'captchaType' => $captcha_type, 'turnstile' => $captcha_type === 'turnstile' && ! empty( $settings['turnstile_site_key'] ), 'siteKey' => $settings['turnstile_site_key'] ?? '', 'i18n' => [ 'sending' => __( 'Sending...', 'ago-contact' ), 'success' => __( 'Message sent successfully!', 'ago-contact' ), 'error' => __( 'Error sending message. Please try again.', 'ago-contact' ), 'required' => __( 'Please fill in all required fields.', 'ago-contact' ), ], ] ); // Turnstile script. Cloudflare requires their CDN for the widget; documented in readme External Services. if ( $captcha_type === 'turnstile' && ! empty( $settings['turnstile_site_key'] ) ) { // phpcs:ignore PluginCheck.CodeAnalysis.EnqueuedResourceOffloading.OffloadedContent -- Turnstile must load from Cloudflare CDN; declared as external service in readme.txt. wp_enqueue_script( 'agocontact-turnstile', 'https://challenges.cloudflare.com/turnstile/v0/api.js', [], AGOCONTACT_VERSION, true ); } } /* ───── Shortcode ───── */ public function render_shortcode( $atts ): string { return Form::render( self::get_settings() ); } /* ───── Gutenberg Block ───── */ public function register_block(): void { if ( ! function_exists( 'register_block_type' ) ) { return; } wp_register_script( 'agocontact-block', AGOCONTACT_URL . 'blocks/form/index.js', [ 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-i18n' ], AGOCONTACT_VERSION, true ); register_block_type( AGOCONTACT_PATH . 'blocks/form', [ 'render_callback' => function () { return Form::render( self::get_settings() ); }, ] ); } /* ───── REST API ───── */ public function register_routes(): void { $ns = 'agocontact/v1'; // Admin: settings register_rest_route( $ns, '/settings', [ [ 'methods' => 'GET', 'callback' => [ $this, 'rest_get_settings' ], 'permission_callback' => [ $this, 'can_manage' ], ], [ 'methods' => 'POST', 'callback' => [ $this, 'rest_save_settings' ], 'permission_callback' => [ $this, 'can_manage' ], ], ] ); // Public by design: front-end visitors submit the contact form without // authentication. rest_submit() enforces honeypot, per-IP rate limiting, // optional captcha, and sanitizes every field before any side effect. register_rest_route( $ns, '/submit', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_submit' ], 'permission_callback' => '__return_true', ] ); // Admin: submissions register_rest_route( $ns, '/submissions', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_get_submissions' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); // Admin: single submission actions register_rest_route( $ns, '/submissions/(?P\d+)', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_get_submission' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( $ns, '/submissions/(?P\d+)/status', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_update_status' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); register_rest_route( $ns, '/submissions/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [ $this, 'rest_delete_submission' ], 'permission_callback' => [ $this, 'can_manage' ], ] ); } public function can_manage(): bool { return current_user_can( 'manage_options' ); } /* ── REST: Settings ── */ public function rest_get_settings(): \WP_REST_Response { return new \WP_REST_Response( [ 'settings' => self::get_settings() ] ); } public function rest_save_settings( \WP_REST_Request $request ): \WP_REST_Response { $data = $request->get_json_params(); $settings = self::sanitize_settings( $data ); update_option( 'agocontact_settings', $settings ); return new \WP_REST_Response( [ 'saved' => true, 'settings' => $settings ] ); } /* ── REST: Submit (public) ── */ public function rest_submit( \WP_REST_Request $request ): \WP_REST_Response { $data = $request->get_json_params(); $settings = self::get_settings(); // Honeypot check if ( ! empty( $data['website'] ) ) { // Bot detected, silently accept return new \WP_REST_Response( [ 'ok' => true ] ); } // Rate limiting $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; $rate_key = 'agocontact_rate_' . md5( $ip ); $count = (int) get_transient( $rate_key ); $limit = (int) ( $settings['rate_limit'] ?? 5 ); if ( $count >= $limit ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'Too many submissions. Please try again later.', 'ago-contact' ) ], 429 ); } // Captcha verification $captcha_type = $settings['captcha_type'] ?? 'none'; if ( $captcha_type === 'math' ) { $answer = $data['math_answer'] ?? ''; $hash = $data['math_hash'] ?? ''; if ( ! Spam::verify_math( (string) $answer, $hash ) ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'Incorrect answer. Please try again.', 'ago-contact' ) ], 403 ); } } elseif ( $captcha_type === 'turnstile' ) { // Turnstile selected but no secret configured: reject instead of // silently accepting unverified submissions. if ( empty( $settings['turnstile_secret_key'] ) ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'Spam verification is misconfigured. Please contact the site administrator.', 'ago-contact' ) ], 403 ); } $token = $data['cf-turnstile-response'] ?? ''; if ( ! Spam::verify_turnstile( $token, $settings['turnstile_secret_key'] ) ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'Spam verification failed. Please try again.', 'ago-contact' ) ], 403 ); } } // Validate required fields $fields = $settings['fields'] ?? self::default_fields(); $submission = []; foreach ( $fields as $key => $field ) { if ( empty( $field['enabled'] ) ) { continue; } $value = sanitize_text_field( $data[ $key ] ?? '' ); if ( $key === 'message' ) { $value = sanitize_textarea_field( $data['message'] ?? '' ); } if ( $key === 'email' ) { $value = sanitize_email( $data['email'] ?? '' ); } if ( ! empty( $field['required'] ) && empty( $value ) ) { return new \WP_REST_Response( [ 'ok' => false, /* translators: %s: name of the required field */ 'error' => sprintf( __( 'The field "%s" is required.', 'ago-contact' ), $field['label'] ), ], 400 ); } $submission[ $key ] = $value; } // GDPR consent check if ( ! empty( $fields['gdpr']['enabled'] ) && empty( $data['gdpr'] ) ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'You must accept the privacy policy.', 'ago-contact' ), ], 400 ); } // Save to DB $id = Submission::insert( $submission, $ip ); if ( ! $id ) { return new \WP_REST_Response( [ 'ok' => false, 'error' => __( 'Could not save your message. Please try again later.', 'ago-contact' ) ], 500 ); } // Update rate limit set_transient( $rate_key, $count + 1, HOUR_IN_SECONDS ); // Send email notification to admin $this->send_notification( $submission, $settings ); // Send auto-reply if ( ! empty( $settings['autoreply_enabled'] ) && ! empty( $submission['email'] ) ) { $this->send_autoreply( $submission, $settings ); } return new \WP_REST_Response( [ 'ok' => true ] ); } /* ── REST: Submissions ── */ public function rest_get_submissions( \WP_REST_Request $request ): \WP_REST_Response { $status = $request->get_param( 'status' ) ?: ''; $page = (int) ( $request->get_param( 'page' ) ?: 1 ); $result = Submission::get_list( $status, $page, 20 ); return new \WP_REST_Response( $result ); } public function rest_get_submission( \WP_REST_Request $request ): \WP_REST_Response { $id = (int) $request['id']; $item = Submission::get( $id ); if ( ! $item ) { return new \WP_REST_Response( [ 'error' => 'Not found' ], 404 ); } // Mark as read if ( $item->status === 'new' ) { Submission::update_status( $id, 'read' ); $item->status = 'read'; } return new \WP_REST_Response( $item ); } public function rest_update_status( \WP_REST_Request $request ): \WP_REST_Response { $id = (int) $request['id']; $status = sanitize_text_field( $request->get_json_params()['status'] ?? '' ); if ( ! in_array( $status, [ 'new', 'read', 'replied', 'spam' ], true ) ) { return new \WP_REST_Response( [ 'error' => 'Invalid status' ], 400 ); } Submission::update_status( $id, $status ); return new \WP_REST_Response( [ 'ok' => true ] ); } public function rest_delete_submission( \WP_REST_Request $request ): \WP_REST_Response { $id = (int) $request['id']; Submission::delete( $id ); return new \WP_REST_Response( [ 'ok' => true ] ); } /** * CSV export via admin-post.php (not REST): a REST response is always * JSON-encoded by core, so it cannot serve a raw CSV download. */ public function export_csv(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to export submissions.', 'ago-contact' ), '', [ 'response' => 403 ] ); } check_admin_referer( 'agocontact_export' ); $items = Submission::get_all(); $csv = Submission::to_csv( $items ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="ago-contact-submissions-' . gmdate( 'Y-m-d' ) . '.csv"' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV body; values neutralized against formula injection in Submission::to_csv(). echo $csv; exit; } /* ───── Email ───── */ private function send_notification( array $submission, array $settings ): void { $to = $settings['notification_email'] ?: get_option( 'admin_email' ); $subject = sprintf( '[%s] %s', get_bloginfo( 'name' ), $submission['subject'] ?? __( 'New contact message', 'ago-contact' ) ); $body = __( 'New contact form submission:', 'ago-contact' ) . "\n\n"; foreach ( $submission as $key => $value ) { if ( $key === 'gdpr' ) continue; $label = ucfirst( str_replace( '_', ' ', $key ) ); $body .= "{$label}: {$value}\n"; } $body .= "\n" . __( 'Sent from:', 'ago-contact' ) . ' ' . home_url(); $headers = []; if ( ! empty( $submission['email'] ) && is_email( $submission['email'] ) ) { $name = str_replace( [ '"', "\r", "\n" ], '', (string) ( $submission['name'] ?? '' ) ); $headers[] = 'Reply-To: "' . $name . '" <' . $submission['email'] . '>'; } wp_mail( $to, $subject, $body, $headers ); } private function send_autoreply( array $submission, array $settings ): void { $to = $submission['email']; if ( ! is_email( $to ) ) { return; } /* translators: %s: site name */ $subject = $settings['autoreply_subject'] ?: sprintf( __( 'We received your message, %s', 'ago-contact' ), get_bloginfo( 'name' ) ); $body = $settings['autoreply_body'] ?: __( "Thank you for contacting us. We have received your message and will get back to you as soon as possible.\n\nBest regards.", 'ago-contact' ); $from = get_bloginfo( 'name' ); $headers = [ "From: {$from} <" . get_option( 'admin_email' ) . ">" ]; wp_mail( $to, $subject, $body, $headers ); } /* ───── Settings Helpers ───── */ public static function get_settings(): array { $defaults = [ 'fields' => self::default_fields(), 'theme' => 'modern', 'notification_email' => get_option( 'admin_email' ), 'autoreply_enabled' => false, 'autoreply_subject' => '', 'autoreply_body' => '', 'captcha_type' => 'none', 'turnstile_site_key' => '', 'turnstile_secret_key'=> '', 'rate_limit' => 5, 'gdpr_text' => __( 'I accept the privacy policy', 'ago-contact' ), 'success_message' => __( 'Message sent successfully!', 'ago-contact' ), 'department_options' => "General\nSales\nSupport", ]; $saved = get_option( 'agocontact_settings', [] ); return wp_parse_args( $saved, $defaults ); } public static function default_fields(): array { return [ 'name' => [ 'enabled' => true, 'required' => true, 'label' => __( 'Name', 'ago-contact' ) ], 'email' => [ 'enabled' => true, 'required' => true, 'label' => __( 'Email', 'ago-contact' ) ], 'phone' => [ 'enabled' => false, 'required' => false, 'label' => __( 'Phone', 'ago-contact' ) ], 'subject' => [ 'enabled' => true, 'required' => false, 'label' => __( 'Subject', 'ago-contact' ) ], 'company' => [ 'enabled' => false, 'required' => false, 'label' => __( 'Company', 'ago-contact' ) ], 'department' => [ 'enabled' => false, 'required' => false, 'label' => __( 'Department', 'ago-contact' ) ], 'message' => [ 'enabled' => true, 'required' => true, 'label' => __( 'Message', 'ago-contact' ) ], 'gdpr' => [ 'enabled' => false, 'required' => true, 'label' => __( 'I accept the privacy policy', 'ago-contact' ) ], ]; } private static function sanitize_settings( array $data ): array { $clean = []; $clean['fields'] = []; $allowed_fields = array_keys( self::default_fields() ); if ( ! empty( $data['fields'] ) && is_array( $data['fields'] ) ) { foreach ( $allowed_fields as $key ) { if ( isset( $data['fields'][ $key ] ) ) { $clean['fields'][ $key ] = [ 'enabled' => ! empty( $data['fields'][ $key ]['enabled'] ), 'required' => ! empty( $data['fields'][ $key ]['required'] ), 'label' => sanitize_text_field( $data['fields'][ $key ]['label'] ?? '' ), ]; } } } $clean['theme'] = in_array( $data['theme'] ?? '', [ 'modern', 'classic', 'minimal' ], true ) ? $data['theme'] : 'modern'; $clean['notification_email'] = sanitize_email( $data['notification_email'] ?? '' ); $clean['autoreply_enabled'] = ! empty( $data['autoreply_enabled'] ); $clean['autoreply_subject'] = sanitize_text_field( $data['autoreply_subject'] ?? '' ); $clean['autoreply_body'] = sanitize_textarea_field( $data['autoreply_body'] ?? '' ); $clean['captcha_type'] = in_array( $data['captcha_type'] ?? '', [ 'none', 'math', 'turnstile' ], true ) ? $data['captcha_type'] : 'none'; $clean['turnstile_site_key'] = sanitize_text_field( $data['turnstile_site_key'] ?? '' ); $clean['turnstile_secret_key'] = sanitize_text_field( $data['turnstile_secret_key'] ?? '' ); $clean['rate_limit'] = max( 1, min( 100, (int) ( $data['rate_limit'] ?? 5 ) ) ); $clean['gdpr_text'] = sanitize_text_field( $data['gdpr_text'] ?? '' ); $clean['success_message'] = sanitize_text_field( $data['success_message'] ?? '' ); $clean['department_options'] = sanitize_textarea_field( $data['department_options'] ?? '' ); return $clean; } }