diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index be55c623..3f574355 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -202,8 +202,25 @@ public function enqueue_gutenberg_scripts() { * Hook server side rendering into render callback */ public function register_block_type() { + $asset_path = VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/index.asset.php'; + $version = $this->version; + if ( file_exists( $asset_path ) ) { + // @phpstan-ignore-next-line + $asset = require $asset_path; + $version = isset( $asset['version'] ) ? $asset['version'] : $version; + } + if ( ! wp_style_is( 'visualizer-datatables', 'registered' ) ) { + wp_register_style( 'visualizer-datatables', VISUALIZER_ABSURL . 'css/lib/datatables.min.css', array(), Visualizer_Plugin::VERSION ); + } + if ( ! wp_style_is( 'visualizer-gutenberg-block', 'registered' ) ) { + wp_register_style( 'visualizer-gutenberg-block', VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/style-index.css', array( 'visualizer-datatables' ), $version ); + } register_block_type( 'visualizer/chart', array( + // The editor_style registration is what gets the stylesheet into the + // iframed editor canvas; styles enqueued via enqueue_block_editor_assets + // only reach the parent document. + 'editor_style' => 'visualizer-gutenberg-block', 'render_callback' => array( $this, 'gutenberg_block_callback' ), 'attributes' => array( 'id' => array( diff --git a/classes/Visualizer/Module/Admin.php b/classes/Visualizer/Module/Admin.php index e003f2e6..e7ef36e5 100644 --- a/classes/Visualizer/Module/Admin.php +++ b/classes/Visualizer/Module/Admin.php @@ -81,7 +81,6 @@ public function __construct( Visualizer_Plugin $plugin ) { $this->_addFilter( 'media_view_strings', 'setupMediaViewStrings' ); $this->_addFilter( 'plugin_action_links', 'getPluginActionLinks', 10, 2 ); $this->_addFilter( 'plugin_row_meta', 'getPluginMetaLinks', 10, 2 ); - $this->_addFilter( 'visualizer_logger_data', 'getLoggerData' ); $this->_addFilter( 'visualizer_feedback_review_trigger', 'feedbackReviewTrigger' ); $this->_addFilter( 'themeisle_sdk_blackfriday_data', 'add_black_friday_data' ); diff --git a/classes/Visualizer/Module/Setup.php b/classes/Visualizer/Module/Setup.php index f0cc81f4..2ef61c60 100644 --- a/classes/Visualizer/Module/Setup.php +++ b/classes/Visualizer/Module/Setup.php @@ -113,7 +113,7 @@ public function getUsage( $data, $meta_keys = array() ) { $lib = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true ); $charts['library'][ $lib ] = isset( $charts['library'][ $lib ] ) ? $charts['library'][ $lib ] + 1 : 1; $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ); - if ( array_key_exists( 'manual', $settings ) && ! empty( $settings['manual'] ) ) { + if ( is_array( $settings ) && ! empty( $settings['manual'] ) ) { $charts['manual_config'] = $charts['manual_config'] + 1; } @@ -124,7 +124,7 @@ public function getUsage( $data, $meta_keys = array() ) { if ( Visualizer_Module::is_pro() ) { $permissions = get_post_meta( $chart_id, Visualizer_Pro::CF_PERMISSIONS, true ); - if ( empty( $permissions ) ) { + if ( ! is_array( $permissions ) || empty( $permissions['permissions'] ) || ! is_array( $permissions['permissions'] ) ) { continue; } $permissions = $permissions['permissions']; @@ -132,7 +132,7 @@ public function getUsage( $data, $meta_keys = array() ) { foreach ( $default_perms as $key => $val ) { if ( ! is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && $permissions[ $key ] !== $val ) { $customized = true; - } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { + } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && is_array( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { $customized = true; } } diff --git a/classes/Visualizer/Remote/Fetch.php b/classes/Visualizer/Remote/Fetch.php index 03040c4c..b1923cae 100644 --- a/classes/Visualizer/Remote/Fetch.php +++ b/classes/Visualizer/Remote/Fetch.php @@ -257,6 +257,15 @@ private static function validate_url( $url, &$ips = array() ) { $ips = array(); $validated_url = wp_http_validate_url( $url ); if ( false === $validated_url ) { + // WordPress 7.1+ rejects non-public IP literals inside wp_http_validate_url() + // itself; older cores let them through to our is_global_ip() check below. Keep + // the distinct "unsafe destination" error on every core version so callers can + // tell a policy block from a malformed URL. + $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); + $host = (string) wp_parse_url( $url, PHP_URL_HOST ); + if ( in_array( $scheme, array( 'http', 'https' ), true ) && filter_var( $host, FILTER_VALIDATE_IP ) && ! self::is_global_ip( $host ) ) { + return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' ); + } return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' ); } diff --git a/composer.lock b/composer.lock index df2b7b77..dd538ab7 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.57", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/3c761b0bddda8d5963a47d14a40811869131030b", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.57" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-23T13:31:25+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "neitanod/forceutf8", diff --git a/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php index 5222e9de..5a07d4a0 100644 --- a/tests/e2e/config/mu-plugins/plant-chart-settings.php +++ b/tests/e2e/config/mu-plugins/plant-chart-settings.php @@ -38,5 +38,21 @@ function () { }, ) ); + + // Runs the SDK usage logger on demand, so specs can verify it + // tolerates whatever chart meta they planted (issue #1359). + register_rest_route( + 'visualizer-e2e/v1', + '/usage', + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function () { + return apply_filters( 'visualizer_logger_data', array() ); + }, + ) + ); } ); diff --git a/tests/e2e/specs/gutenberg-editor.spec.js b/tests/e2e/specs/gutenberg-editor.spec.js index c5ad0157..efb91693 100644 --- a/tests/e2e/specs/gutenberg-editor.spec.js +++ b/tests/e2e/specs/gutenberg-editor.spec.js @@ -18,34 +18,37 @@ test.describe( 'Charts with Gutenberg Editor', () => { page.setDefaultTimeout( 5000 ); } ); - test('check available action on block creation', async ( { admin, editor, page } ) => { + test('check available action on block creation', async ( { admin, editor } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - // Check chart selection options are available. - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); - await expect( page.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); + // The block renders inside the editor canvas, which WordPress serves in + // an iframe, so every locator must go through editor.canvas. + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + await expect( editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }) ).toBeVisible(); + await expect( editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); } ); test('new chart creation', async ( { admin, editor, page } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + const createOption = editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }); + await expect( createOption ).toBeVisible(); - await page.getByLabel('Editor content').locator('a').click({ force: true}); + await createOption.click({ force: true }); - // Create chart via popup. - await page.frameLocator('iframe').getByRole('button', { name: 'Next' }).click(); - await page.frameLocator('iframe').getByRole('button', { name: 'Create Chart' }).click(); + // Create chart via popup; target the wizard frame, not the canvas iframe. + const wizard = page.frameLocator('iframe[src*="visualizer-create-chart"]'); + await wizard.getByRole('button', { name: 'Next' }).click(); + await wizard.getByRole('button', { name: 'Create Chart' }).click(); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeVisible(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); - await expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeHidden(); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeHidden(); } ); @@ -55,15 +58,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { // Create a new post and insert the first available chart. await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); // Check if it was inserted correctly then enter view mode for the block. - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); // Check if the Chart did not crash the editor. - expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); } ); test( 'check block Edit new button', async ( { admin, editor, page } ) => { @@ -73,13 +76,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Edit Chart' })).toBeVisible(); - await page.getByRole('button', { name: 'Edit Chart' }).click(); + // The Edit Chart button lives in the block toolbar / inspector, which + // render in the parent document, not the canvas iframe. + await expect( page.getByRole('button', { name: 'Edit Chart' }).first() ).toBeVisible(); + await page.getByRole('button', { name: 'Edit Chart' }).first().click(); //await page.goto('http://localhost:8889/wp-admin/post.php?post=29&action=edit'); await expect(page.getByLabel('Visualizer', { exact: true }).locator('h1')).toContainText('Visualizer'); await page.getByRole('button', { name: 'Close dialog' }).click(); diff --git a/tests/e2e/specs/usage-logger.spec.js b/tests/e2e/specs/usage-logger.spec.js new file mode 100644 index 00000000..d502e15b --- /dev/null +++ b/tests/e2e/specs/usage-logger.spec.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { deleteAllCharts } = require( '../utils/common' ); + +/** + * Regression tests for https://github.com/Codeinwp/visualizer/issues/1359 + * + * A published chart whose `visualizer-settings` meta is a string (instead of + * the sanitized settings array) crashed `Visualizer_Module_Setup::getUsage()` + * with a PHP 8 TypeError, aborting the whole SDK usage collection request. + * The logger must tolerate such charts and still report the others. + */ +test.describe( 'Usage logger', () => { + let corruptedId; + let manualId; + + test.beforeAll( async ( { requestUtils } ) => { + // The assertions below count charts, so start from a clean library. + await deleteAllCharts( requestUtils ); + + // A chart whose settings meta is a corrupted string value. + const corrupted = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Corrupted settings chart', status: 'publish' }, + } ); + corruptedId = corrupted.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ corruptedId }`, + data: { settings: 'corrupted string settings' }, + } ); + + // A healthy chart with a manual configuration, which must still be counted. + const manual = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Manual config chart', status: 'publish' }, + } ); + manualId = manual.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ manualId }`, + data: { settings: { manual: '{"colors": ["#000"]}' } }, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + for ( const id of [ corruptedId, manualId ] ) { + if ( id ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ id }`, params: { force: true } } ); + } + } + } ); + + test( 'survives a chart whose settings meta is a string', async ( { requestUtils } ) => { + // Before the fix this request died with a TypeError (HTTP 500). + const usage = await requestUtils.rest( { method: 'GET', path: '/visualizer-e2e/v1/usage' } ); + + expect( usage.manual_config ).toBe( 1 ); + expect( Object.values( usage.types ).reduce( ( a, b ) => a + b, 0 ) ).toBe( 2 ); + } ); +} ); diff --git a/tests/test-usage-logger.php b/tests/test-usage-logger.php new file mode 100644 index 00000000..2abf25a9 --- /dev/null +++ b/tests/test-usage-logger.php @@ -0,0 +1,107 @@ +post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', + 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ), + ) + ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $settings ); + return $chart_id; + } + + /** + * A chart whose settings meta is a string must not abort usage collection. + */ + public function test_string_settings_meta_does_not_crash_logger() { + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * A chart with no settings meta at all must not abort usage collection. + */ + public function test_missing_settings_meta_does_not_crash_logger() { + $chart_id = $this->create_chart( array() ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * On pro, permission meta that is not shaped like a map of arrays must not + * abort usage collection — a string entry fatals on count(), and a nested + * object fatals on the array offset read. + */ + public function test_malformed_permissions_meta_does_not_crash_logger() { + // The stub stays defined for the rest of the PHPUnit process. That only + // affects code gating on class_exists( 'Visualizer_Pro' ) — the legacy + // license fallback in proFeaturesEnabled() — which no test exercises. + if ( ! class_exists( 'Visualizer_Pro' ) ) { + eval( 'class Visualizer_Pro { const CF_PERMISSIONS = "visualizer-permissions"; }' ); + } + + $chart_id = $this->create_chart( array() ); + update_post_meta( + $chart_id, + Visualizer_Pro::CF_PERMISSIONS, + array( 'permissions' => array( 'edit-specific' => 'administrator' ) ) + ); + + $object_chart_id = $this->create_chart( array() ); + update_post_meta( $object_chart_id, Visualizer_Pro::CF_PERMISSIONS, array( 'permissions' => new stdClass() ) ); + + add_filter( 'visualizer_is_pro', '__return_true' ); + $usage = apply_filters( 'visualizer_logger_data', array() ); + remove_filter( 'visualizer_is_pro', '__return_true' ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['permissions'] ); + } + + /** + * Valid array settings still count manual configurations. + */ + public function test_manual_config_still_counted_for_array_settings() { + $this->create_chart( array( 'manual' => '{"colors": ["#000"]}' ) ); + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertSame( 1, $usage['manual_config'] ); + $this->assertSame( 2, $usage['types']['line'] ); + } +}