breznflow/includes/Admin/WizardPage.php
Michael fd83e4810b BreznFlow 1.0.0 — WordPress.org submission
Initial public release of BreznFlow, an n8n workflow renderer for WordPress.
Fully PHPCS-compliant (WordPress Coding Standards), security-hardened,
and ready for WordPress.org plugin review.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 11:27:36 +00:00

420 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Three-step workflow import wizard.
*
* @package BreznFlow
* @since 1.0.0
*/
namespace BreznFlow\Admin;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
use BreznFlow\Security\WorkflowValidator;
use BreznFlow\Security\WorkflowSanitizer;
use BreznFlow\Features\NodeCategorizer;
/**
* Handles the wizard steps, AJAX validation, URL fetching, and publish actions.
*
* @since 1.0.0
*/
class WizardPage {
/**
* Registers all wizard-related hooks.
*
* @since 1.0.0
* @return void
*/
public function register(): void {
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
add_action( 'wp_ajax_breznflow_validate_json', array( $this, 'ajax_validate_json' ) );
add_action( 'wp_ajax_breznflow_fetch_url', array( $this, 'ajax_fetch_url' ) );
add_action( 'admin_post_breznflow_save_step1', array( $this, 'handle_step1' ) );
add_action( 'admin_post_breznflow_save_step2', array( $this, 'handle_step2' ) );
add_action( 'admin_post_breznflow_publish_workflow', array( $this, 'handle_publish' ) );
}
/**
* Enqueues admin CSS and JS for wizard pages.
*
* @since 1.0.0
* @param string $hook Current admin page hook suffix.
* @return void
*/
public function enqueue_assets( string $hook ): void {
if ( ! in_array( $hook, array( 'toplevel_page_breznflow', 'breznflow_page_breznflow-add' ), true ) ) {
return;
}
wp_enqueue_style( 'breznflow-admin', BREZNFLOW_URL . 'assets/admin.css', array(), BREZNFLOW_VERSION );
wp_enqueue_script( 'breznflow-admin', BREZNFLOW_URL . 'assets/admin.js', array(), BREZNFLOW_VERSION, true );
wp_localize_script(
'breznflow-admin',
'breznflowAdmin',
array(
'ajaxUrl' => 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.1631.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;
}
}