admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( 'breznflow_wizard' ), 'i18n' => array( 'validating' => __( 'Validating...', 'breznflow' ), 'valid' => __( 'Valid n8n workflow', 'breznflow' ), 'invalid' => __( 'Invalid workflow', 'breznflow' ), 'fetching' => __( 'Fetching URL...', 'breznflow' ), 'copyShortcode' => __( 'Copy', 'breznflow' ), 'copied' => __( 'Copied!', 'breznflow' ), 'validateJson' => __( 'Validate JSON', 'breznflow' ), 'fetch' => __( 'Fetch', 'breznflow' ), 'fetchFailed' => __( 'Fetch failed', 'breznflow' ), 'pasteFirst' => __( 'Please paste a workflow JSON first.', 'breznflow' ), 'nodes' => __( 'nodes', 'breznflow' ), ), ) ); } /** * AJAX: Validate JSON without saving. */ public function ajax_validate_json(): void { check_ajax_referer( 'breznflow_wizard', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'breznflow' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified above; sanitize_textarea_field() is not used because strip_tags() corrupts JSON (strips HTML inside string values); sanitization happens after json_decode() in WorkflowSanitizer $raw = isset( $_POST['json'] ) ? trim( wp_unslash( (string) $_POST['json'] ) ) : ''; if ( '' === $raw ) { wp_send_json_error( array( 'message' => __( 'No JSON provided.', 'breznflow' ) ) ); } $result = WorkflowValidator::validate( $raw ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } wp_send_json_success( array( 'name' => esc_html( $result['name'] ), 'nodes' => count( $result['nodes'] ), ) ); } /** * AJAX: Fetch JSON from a URL. */ public function ajax_fetch_url(): void { check_ajax_referer( 'breznflow_wizard', 'nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'breznflow' ) ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_ajax_referer() above $url = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : ''; if ( '' === $url ) { wp_send_json_error( array( 'message' => __( 'No URL provided.', 'breznflow' ) ) ); } if ( ! $this->is_ssrf_safe_url( $url ) ) { wp_send_json_error( array( 'message' => __( 'This URL is not allowed.', 'breznflow' ) ) ); } $response = wp_remote_get( $url, array( 'timeout' => 15, 'user-agent' => 'BreznFlow/' . BREZNFLOW_VERSION . '; WordPress/' . get_bloginfo( 'version' ), ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => $response->get_error_message() ) ); } $body = wp_remote_retrieve_body( $response ); if ( '' === $body ) { wp_send_json_error( array( 'message' => __( 'Empty response from URL.', 'breznflow' ) ) ); } wp_send_json_success( array( 'json' => $body ) ); } /** * Checks whether a URL is safe to fetch (no SSRF risk). * Blocks loopback, private RFC-1918, and link-local (cloud metadata) ranges. * * @param string $url The URL to check. * @return bool True if safe to fetch, false if blocked. */ private function is_ssrf_safe_url( string $url ): bool { $host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $host || '' === $host ) { return false; } // Strip IPv6 brackets: [::1] → ::1. $host = trim( $host, '[]' ); $ip = gethostbyname( $host ); $blocked = array( '/^127\./', // 127.x.x.x — Loopback. '/^10\./', // 10.x.x.x — RFC 1918. '/^192\.168\./', // 192.168.x.x — RFC 1918. '/^172\.(1[6-9]|2[0-9]|3[01])\./', // 172.16–31.x.x — RFC 1918. '/^169\.254\./', // 169.254.x.x — Link-local / AWS metadata. '/^0\./', // 0.x.x.x — Invalid. '/^::1$/', // IPv6 loopback. '/^fc[0-9a-f]{2}:/i', // IPv6 ULA fc::/7. '/^fd[0-9a-f]{2}:/i', // IPv6 ULA fd::/8. '/^fe80:/i', // IPv6 link-local. ); foreach ( $blocked as $pattern ) { if ( preg_match( $pattern, $ip ) ) { return false; } } return true; } /** * POST handler: Step 1 — validate, sanitize, create draft. */ public function handle_step1(): void { check_admin_referer( 'breznflow_step1', 'breznflow_nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified above; sanitize_textarea_field() is not used because strip_tags() corrupts JSON (strips HTML inside string values); sanitization happens after json_decode() in WorkflowSanitizer $raw = isset( $_POST['breznflow_json'] ) ? trim( wp_unslash( (string) $_POST['breznflow_json'] ) ) : ''; if ( '' === $raw ) { wp_safe_redirect( add_query_arg( array( 'page' => 'breznflow-add', 'error' => 'empty', ), admin_url( 'admin.php' ) ) ); exit; } $validated = WorkflowValidator::validate( $raw ); if ( is_wp_error( $validated ) ) { wp_safe_redirect( add_query_arg( array( 'page' => 'breznflow-add', 'error' => 'validation', 'message' => rawurlencode( $validated->get_error_message() ), ), admin_url( 'admin.php' ) ) ); exit; } // Sanitize + mask. $sanitizer = new WorkflowSanitizer(); $processed = $sanitizer->process( $validated ); $data = $processed['data']; $mask_log = $processed['mask_log']; // Fallback for nodes whose name was stripped entirely by sanitization. if ( isset( $data['nodes'] ) && is_array( $data['nodes'] ) ) { foreach ( $data['nodes'] as &$node ) { if ( isset( $node['name'] ) && '' === $node['name'] ) { $node['name'] = isset( $node['type'] ) ? sanitize_text_field( $node['type'] ) : __( 'Unnamed Node', 'breznflow' ); } } unset( $node ); } // Categorize nodes. $categorized = NodeCategorizer::categorize( $data['nodes'] ); $node_summary = array(); foreach ( $categorized['counts'] as $label => $count ) { $node_summary[ $label ] = $count; } // Create or update draft post. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_admin_referer() above $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; $settings = \BreznFlow\Admin\SettingsPage::get_defaults(); $saved = get_option( 'breznflow_settings', array() ); $settings = array_merge( $settings, $saved ); $post_data = array( 'post_type' => 'breznflow_workflow', 'post_status' => 'draft', 'post_title' => sanitize_text_field( $data['name'] ), ); if ( $post_id > 0 ) { $post_data['ID'] = $post_id; $post_id = wp_update_post( $post_data ); } else { $post_id = wp_insert_post( $post_data ); } if ( is_wp_error( $post_id ) || 0 === $post_id ) { wp_die( esc_html__( 'Failed to create workflow post.', 'breznflow' ) ); } // Store sanitized JSON (never raw). // wp_slash() counteracts update_metadata()'s internal wp_unslash() which would corrupt JSON backslashes. update_post_meta( $post_id, '_breznflow_raw_json', wp_slash( wp_json_encode( $data ) ) ); update_post_meta( $post_id, '_breznflow_original_name', sanitize_text_field( $data['name'] ) ); update_post_meta( $post_id, '_breznflow_node_count', (int) $categorized['total'] ); update_post_meta( $post_id, '_breznflow_node_summary', wp_slash( wp_json_encode( $node_summary ) ) ); update_post_meta( $post_id, '_breznflow_has_ai_nodes', (int) $categorized['has_ai'] ); update_post_meta( $post_id, '_breznflow_ai_node_types', wp_slash( wp_json_encode( $categorized['ai_nodes'] ) ) ); update_post_meta( $post_id, '_breznflow_mask_log', wp_slash( wp_json_encode( $mask_log ) ) ); // Defaults. update_post_meta( $post_id, '_breznflow_default_zoom', (int) $settings['default_zoom'] ); update_post_meta( $post_id, '_breznflow_show_title', (int) $settings['show_title_default'] ); update_post_meta( $post_id, '_breznflow_show_infobox', (int) $settings['show_infobox_default'] ); update_post_meta( $post_id, '_breznflow_show_download', (int) $settings['allow_download'] ); update_post_meta( $post_id, '_breznflow_show_embed', (int) $settings['allow_embed'] ); update_post_meta( $post_id, '_breznflow_show_minimap', 1 ); update_post_meta( $post_id, '_breznflow_default_mode', sanitize_text_field( $settings['default_mode'] ) ); update_post_meta( $post_id, '_breznflow_default_theme', $settings['default_theme'] ?? 'dark' ); // Invalidate related workflow caches. delete_transient( 'breznflow_related_' . $post_id ); wp_safe_redirect( add_query_arg( array( 'page' => 'breznflow-add', 'step' => '2', 'post_id' => $post_id, ), admin_url( 'admin.php' ) ) ); exit; } /** * POST handler: Step 2 — save settings, redirect to step 3. */ public function handle_step2(): void { check_admin_referer( 'breznflow_step2', 'breznflow_nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified via check_admin_referer() above $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; if ( $post_id <= 0 || 'breznflow_workflow' !== get_post_type( $post_id ) ) { wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above $title = isset( $_POST['post_title'] ) ? sanitize_text_field( wp_unslash( $_POST['post_title'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above $mode = isset( $_POST['default_mode'] ) ? sanitize_text_field( wp_unslash( $_POST['default_mode'] ) ) : 'visual'; $mode = in_array( $mode, array( 'visual', 'info', 'compact' ), true ) ? $mode : 'visual'; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above $zoom = isset( $_POST['default_zoom'] ) ? max( 10, min( 200, (int) $_POST['default_zoom'] ) ) : 100; if ( $title ) { wp_update_post( array( 'ID' => $post_id, 'post_title' => $title, ) ); } update_post_meta( $post_id, '_breznflow_default_mode', $mode ); update_post_meta( $post_id, '_breznflow_default_zoom', $zoom ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above update_post_meta( $post_id, '_breznflow_show_title', ! empty( $_POST['show_title'] ) ? 1 : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above update_post_meta( $post_id, '_breznflow_show_infobox', ! empty( $_POST['show_infobox'] ) ? 1 : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above update_post_meta( $post_id, '_breznflow_show_download', ! empty( $_POST['show_download'] ) ? 1 : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above update_post_meta( $post_id, '_breznflow_show_embed', ! empty( $_POST['show_embed'] ) ? 1 : 0 ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above update_post_meta( $post_id, '_breznflow_show_minimap', ! empty( $_POST['show_minimap'] ) ? 1 : 0 ); $allowed_themes = \BreznFlow\Features\ThemeRegistry::get_theme_ids(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above $posted_theme = isset( $_POST['default_theme'] ) ? sanitize_text_field( wp_unslash( $_POST['default_theme'] ) ) : 'dark'; $theme_val = in_array( $posted_theme, $allowed_themes, true ) ? $posted_theme : 'dark'; update_post_meta( $post_id, '_breznflow_default_theme', $theme_val ); // Categories. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above if ( isset( $_POST['breznflow_categories'] ) && is_array( $_POST['breznflow_categories'] ) ) { $cat_ids = array_map( 'intval', wp_unslash( $_POST['breznflow_categories'] ) ); wp_set_post_terms( $post_id, $cat_ids, 'breznflow_category' ); } wp_safe_redirect( add_query_arg( array( 'page' => 'breznflow-add', 'step' => '3', 'post_id' => $post_id, ), admin_url( 'admin.php' ) ) ); exit; } /** * POST handler: Publish workflow. */ public function handle_publish(): void { check_admin_referer( 'breznflow_publish', 'breznflow_nonce' ); if ( ! current_user_can( 'edit_posts' ) ) { wp_die( esc_html__( 'Insufficient permissions.', 'breznflow' ) ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above $post_id = isset( $_POST['breznflow_post_id'] ) ? (int) $_POST['breznflow_post_id'] : 0; if ( $post_id <= 0 || 'breznflow_workflow' !== get_post_type( $post_id ) ) { wp_die( esc_html__( 'Invalid workflow ID.', 'breznflow' ) ); } wp_update_post( array( 'ID' => $post_id, 'post_status' => 'publish', ) ); // Invalidate caches. delete_transient( 'breznflow_related_' . $post_id ); delete_transient( 'breznflow_stats_summary' ); wp_safe_redirect( add_query_arg( array( 'page' => 'breznflow', 'published' => 1, 'post_id' => $post_id, ), admin_url( 'admin.php' ) ) ); exit; } }