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>
420 lines
15 KiB
PHP
420 lines
15 KiB
PHP
<?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.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;
|
||
}
|
||
}
|