From ef9afa816a516229d5dd0b9a50e93c539b3794e0 Mon Sep 17 00:00:00 2001 From: code-snippets-bot <139164393+code-snippets-bot@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:20:54 +0000 Subject: [PATCH 01/23] chore(release): bump version to v3.10.2 --- package-lock.json | 4 ++-- package.json | 2 +- src/code-snippets.php | 6 +++--- src/readme.txt | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index f6a2f8e45..e903c1676 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-snippets", - "version": "3.10.1", + "version": "3.10.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-snippets", - "version": "3.10.1", + "version": "3.10.2", "license": "GPL-2.0-or-later", "dependencies": { "@codemirror/fold": "^0.19.4", diff --git a/package.json b/package.json index b54ca0b1f..ee8f2bb5f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "code-snippets", "description": "Manage code snippets running on a WordPress-powered site through a graphical interface.", "homepage": "https://codesnippets.pro", - "version": "3.10.1", + "version": "3.10.2", "main": "src/dist/edit.js", "directories": { "test": "tests" diff --git a/src/code-snippets.php b/src/code-snippets.php index 93178bec2..3391a3533 100644 --- a/src/code-snippets.php +++ b/src/code-snippets.php @@ -8,11 +8,11 @@ * License: GPL-2.0-or-later * License URI: license.txt * Text Domain: code-snippets - * Version: 3.10.1 + * Version: 3.10.2 * Requires PHP: 7.4 * Requires at least: 5.5 * - * @version 3.10.1 + * @version 3.10.2 * @package Code_Snippets * @author Shea Bunge * @copyright 2012-2026 Code Snippets Pro @@ -37,7 +37,7 @@ * * @const string */ - define( 'CODE_SNIPPETS_VERSION', '3.10.1' ); + define( 'CODE_SNIPPETS_VERSION', '3.10.2' ); /** * The full path to the main file of this plugin. diff --git a/src/readme.txt b/src/readme.txt index 7daf39a13..6f8d0560b 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -4,7 +4,7 @@ Donate link: https://codesnippets.pro Tags: code, snippets, multisite, php, css License: GPL-2.0-or-later License URI: license.txt -Stable tag: 3.10.1 +Stable tag: 3.10.2 Requires at least: 5.5 Tested up to: 7.0.3 Requires PHP: 7.4 From 9fdad70eef818c1df799e6f73de5af0d07783e45 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:39:07 +0100 Subject: [PATCH 02/23] fix: restore the Run Once action for single-use snippets (#487) --- .../ManageMenu/SnippetsTable/TableColumns.tsx | 7 +- src/js/types/Window.ts | 1 + src/php/Admin/Menus/Manage/Manage_Menu.php | 86 +++++++++++++++++++ .../Admin/Menus/Manage/Manage_Menu_Assets.php | 1 + 4 files changed, 94 insertions(+), 1 deletion(-) diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx index d44968392..c6026d95a 100644 --- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -26,7 +26,12 @@ const RunOnceButton: React.FC = ({ snippet }) => {__('Run Once', 'code-snippets')} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index bc20a12be..9a1ebcd38 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -72,6 +72,7 @@ declare global { cloudSearchPerPage: number isSafeModeActive: boolean bulkDownloadNonce: string + runOnceNonce?: string supportsZipDownloads: boolean editorTheme: string } diff --git a/src/php/Admin/Menus/Manage/Manage_Menu.php b/src/php/Admin/Menus/Manage/Manage_Menu.php index ff2279ea8..eaeaaecc1 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu.php @@ -5,6 +5,7 @@ use Code_Snippets\Admin\Contextual_Help; use Code_Snippets\Admin\Menus\Admin_Menu; use Code_Snippets\Controller\Cloud_Search_Controller; +use function Code_Snippets\activate_snippet; use function Code_Snippets\code_snippets; use function Code_Snippets\Settings\get_setting; use const Code_Snippets\PLUGIN_FILE; @@ -46,6 +47,7 @@ public function __construct() { new Manage_Menu_Bulk_Download(); add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 ); + add_action( 'admin_notices', [ $this, 'render_run_once_notice' ] ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_css' ) ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] ); } @@ -177,12 +179,96 @@ public function register_compact_menu() { add_action( 'load-' . $hook, [ $class, 'load' ] ); } + /** + * Nonce action guarding the run-once request. + */ + public const RUN_ONCE_NONCE = 'code_snippets_run_once'; + + /** + * Run a single-use snippet, when asked to by the snippets list. + * + * Activating the snippet is all that is required: single-use snippets are + * executed and then deactivated again on the next page load, so redirecting + * afterwards both runs the code and returns the snippet to its resting + * state. This mirrors what the list table did before the snippets list + * moved to the REST API, at which point the button was left pointing at a + * URL that nothing handled. + * + * @return void + */ + private function handle_run_once(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified immediately below. + $action = isset( $_REQUEST['action'] ) ? sanitize_key( wp_unslash( $_REQUEST['action'] ) ) : ''; + + if ( 'run-once' !== $action ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified immediately below. + $nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : ''; + + if ( ! wp_verify_nonce( $nonce, self::RUN_ONCE_NONCE ) || ! code_snippets()->current_user_can() ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified above. + $snippet_id = isset( $_REQUEST['snippet'] ) ? absint( wp_unslash( $_REQUEST['snippet'] ) ) : 0; + + if ( ! $snippet_id ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified above. + $network = isset( $_REQUEST['network'] ) ? rest_sanitize_boolean( wp_unslash( $_REQUEST['network'] ) ) : null; + + $result = activate_snippet( $snippet_id, $network ); + + wp_safe_redirect( + add_query_arg( + [ 'result' => is_string( $result ) ? 'run-once-failed' : 'executed' ], + remove_query_arg( [ 'action', 'snippet', 'network', '_wpnonce', 'result' ] ) + ) + ); + exit; + } + + /** + * Report the outcome of a run-once request. + * + * @return void + */ + public function render_run_once_notice(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display of an outcome. + $result = isset( $_GET['result'] ) ? sanitize_key( wp_unslash( $_GET['result'] ) ) : ''; + + if ( 'executed' === $result ) { + wp_admin_notice( + __( 'Snippet executed.', 'code-snippets' ), + [ + 'type' => 'success', + 'dismissible' => true, + 'additional_classes' => [ 'code-snippets-run-once-notice' ], + ] + ); + } elseif ( 'run-once-failed' === $result ) { + wp_admin_notice( + __( 'The snippet could not be run. Check that its code is valid and try again.', 'code-snippets' ), + [ + 'type' => 'error', + 'dismissible' => true, + 'additional_classes' => [ 'code-snippets-run-once-notice' ], + ] + ); + } + } + /** * Executed when the admin page is loaded. */ public function load() { parent::load(); + $this->handle_run_once(); $this->screen_options->load(); if ( $this->screen_options->is_upsell_view() ) { diff --git a/src/php/Admin/Menus/Manage/Manage_Menu_Assets.php b/src/php/Admin/Menus/Manage/Manage_Menu_Assets.php index 38a373097..8dee0b3d2 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu_Assets.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu_Assets.php @@ -100,6 +100,7 @@ public function enqueue( array $script_dependencies, array $style_dependencies ) 'cloudSearchPerPage' => Manage_Menu::get_cloud_search_per_page(), 'isSafeModeActive' => Evaluate_Functions::is_safe_mode_active(), 'bulkDownloadNonce' => wp_create_nonce( 'code_snippets_bulk_download' ), + 'runOnceNonce' => wp_create_nonce( Manage_Menu::RUN_ONCE_NONCE ), 'supportsZipDownloads' => class_exists( 'ZipArchive' ), 'editorTheme' => get_setting( 'editor', 'theme' ), 'typeCounts' => $this->get_snippet_type_counts(), From 3a3569bb4d81fcbaebcbf6811c0856d2cb2b5121 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:39:42 +0100 Subject: [PATCH 03/23] fix: scope cached data to the plugin version so downgrades stop fatalling (#485) --- src/php/Core/Uninstaller.php | 1 + src/php/Core/Upgrader.php | 34 +++++++ src/php/Core/load.php | 17 +++- src/php/Settings/Settings_Fields.php | 2 +- src/php/Settings/settings.php | 6 ++ src/php/snippet-ops.php | 44 +++++++++ tests/unit/Core/Versioned_Cache_Test.php | 109 +++++++++++++++++++++++ 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/unit/Core/Versioned_Cache_Test.php diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index 7eff2f6a3..877411b58 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -67,6 +67,7 @@ private function uninstall_current_site() { $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}snippets" ); delete_option( 'code_snippets_version' ); + delete_option( 'code_snippets_cache_version' ); delete_option( 'recently_active_snippets' ); delete_option( 'recently_activated_snippets' ); delete_option( 'code_snippets_settings' ); diff --git a/src/php/Core/Upgrader.php b/src/php/Core/Upgrader.php index 40fa95ebb..817e2ac12 100644 --- a/src/php/Core/Upgrader.php +++ b/src/php/Core/Upgrader.php @@ -6,6 +6,7 @@ use Code_Snippets\Model\Snippet; use WP_User; use function Code_Snippets\clean_snippets_cache; +use function Code_Snippets\flush_versioned_cache_groups; use function Code_Snippets\code_snippets; use function Code_Snippets\save_snippet; @@ -14,6 +15,11 @@ */ class Upgrader { + /** + * Option recording the plugin version that last wrote to the cache. + */ + private const CACHE_VERSION_OPTION = 'code_snippets_cache_version'; + /** * Instance of database class * @@ -45,6 +51,7 @@ public function __construct( string $version, DB $db ) { * Run the upgrade functions */ public function run() { + $this->handle_version_change(); // Always run multisite upgrades, even if not on the main site, as sub-sites depend on the network snippet table. if ( is_multisite() ) { @@ -54,6 +61,33 @@ public function run() { $this->do_site_upgrades(); } + /** + * Discard cached data belonging to a different version of the plugin. + * + * The do_site_upgrades() method only acts when the version has gone up, so + * it never fires on a rollback. Cached data has to be dealt with in both + * directions, so it is tracked separately here. + * + * A dedicated option is used rather than the one that method maintains, + * because that option is left untouched on a downgrade, which would leave + * this flushing on every request for as long as the older version ran. + * + * @return void + */ + private function handle_version_change(): void { + $previous_version = (string) get_option( self::CACHE_VERSION_OPTION, '' ); + + if ( $previous_version === $this->current_version ) { + return; + } + + // Recorded before flushing so that a cache backend which cannot flush + // groups does not leave this repeating on every request. + update_option( self::CACHE_VERSION_OPTION, $this->current_version, false ); + + flush_versioned_cache_groups( $previous_version ); + } + /** * Perform upgrades for the current site */ diff --git a/src/php/Core/load.php b/src/php/Core/load.php index 2a951f7bd..aa6c01083 100644 --- a/src/php/Core/load.php +++ b/src/php/Core/load.php @@ -31,12 +31,27 @@ */ const PLUGIN_FILE = CODE_SNIPPETS_FILE; +/** + * Base name of the group used for caching data. + * + * @var string + */ +const CACHE_GROUP_BASE = 'code_snippets'; + /** * Name of the group used for caching data. * + * Scoped to the plugin version, so data cached by one version is never read by + * another. Snippet objects are cached here, and the Snippet class moved + * namespace in 3.10. A version that cannot resolve the stored class fatals on + * unserialize, which broke the admin for anyone downgrading on a site with a + * persistent object cache. Keeping the group distinct per version means the + * two never see each other's data, in either direction, without relying on the + * other version to clean up after itself. + * * @var string */ -const CACHE_GROUP = 'code_snippets'; +const CACHE_GROUP = CACHE_GROUP_BASE . '_' . PLUGIN_VERSION; /** * Namespace used for REST API endpoints. diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index b11b3e903..27cc4d3d0 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -139,7 +139,7 @@ private function init_fields() { 'reset_caches' => [ 'name' => __( 'Reset Caches', 'code-snippets' ), 'type' => 'action', - 'desc' => __( 'Use this button to manually clear snippets caches.', 'code-snippets' ), + 'desc' => __( 'Use this button to manually clear snippets caches. Worth doing before switching to an older version of the plugin, if your site uses a persistent object cache.', 'code-snippets' ), ], 'enable_version_change' => [ 'name' => __( 'Version Change', 'code-snippets' ), diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 8a9a9901a..e90a29c6d 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -11,6 +11,7 @@ use Code_Snippets\Controller\Cloud_Search_Controller; use function add_action; use function Code_Snippets\clean_snippets_cache; +use function Code_Snippets\flush_cache_group; use function Code_Snippets\code_snippets; use function Code_Snippets\Utils\add_self_option; use function Code_Snippets\Utils\get_self_option; @@ -312,6 +313,11 @@ function process_settings_actions( array $input ): ?array { clean_snippets_cache( code_snippets()->db->get_table_name( true ) ); } + // Deleting known keys cannot reach data written by a different version + // of the plugin, which is what needs clearing before a rollback, so the + // whole group goes too. + flush_cache_group( CACHE_GROUP ); + add_settings_error( OPTION_NAME, 'snippet_caches_reset', diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index e58642919..b3384e989 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -88,6 +88,50 @@ function clean_snippets_cache( string $table_name ) { clean_active_snippets_cache( $table_name ); } +/** + * Flush an entire cache group, where the object cache supports it. + * + * Not all persistent cache drop-ins implement group flushing, and the function + * itself only exists from WordPress 6.1, so both are checked before use. A + * failure is not important: cache groups are scoped to the plugin version, so + * flushing is housekeeping rather than something correctness depends on, and + * anything left behind is evicted by the cache in its own time. + * + * @param string $group Cache group to flush. + * + * @return bool Whether the group was flushed. + */ +function flush_cache_group( string $group ): bool { + if ( ! function_exists( 'wp_cache_flush_group' ) || + ! function_exists( 'wp_cache_supports' ) || + ! wp_cache_supports( 'flush_group' ) ) { + return false; + } + + return (bool) wp_cache_flush_group( $group ); +} + +/** + * Flush the cache groups belonging to other versions of the plugin. + * + * @param string $previous_version Version the site was running beforehand. + * + * @return void + */ +function flush_versioned_cache_groups( string $previous_version ): void { + if ( '' !== $previous_version && PLUGIN_VERSION !== $previous_version ) { + flush_cache_group( CACHE_GROUP_BASE . '_' . $previous_version ); + } + + // Versions before the group was scoped wrote to the unscoped group, and no + // version that scopes it ever writes there again. Clearing it means a site + // upgrading from 3.10.0 or 3.10.1 sheds the objects that would otherwise + // still be waiting to break its next rollback. + flush_cache_group( CACHE_GROUP_BASE ); + + flush_cache_group( CACHE_GROUP ); +} + /** * Retrieve a list of snippets from the database. * Read operation. diff --git a/tests/unit/Core/Versioned_Cache_Test.php b/tests/unit/Core/Versioned_Cache_Test.php new file mode 100644 index 000000000..17ea1da29 --- /dev/null +++ b/tests/unit/Core/Versioned_Cache_Test.php @@ -0,0 +1,109 @@ +assertSame( CACHE_GROUP_BASE . '_' . PLUGIN_VERSION, CACHE_GROUP ); + $this->assertStringContainsString( PLUGIN_VERSION, CACHE_GROUP ); + } + + /** + * A different version reads a different group, so cannot see this data. + * + * @return void + */ + public function test_another_version_does_not_share_cached_data(): void { + wp_cache_set( 'shared_key', 'written by this version', CACHE_GROUP ); + + $other_group = CACHE_GROUP_BASE . '_0.0.1'; + + $this->assertSame( 'written by this version', wp_cache_get( 'shared_key', CACHE_GROUP ) ); + $this->assertFalse( wp_cache_get( 'shared_key', $other_group ) ); + } + + /** + * Flushing a group is safe even when the backend cannot do it. + * + * @return void + */ + public function test_flushing_a_group_never_errors(): void { + wp_cache_set( 'transient_key', 'value', CACHE_GROUP ); + + $this->assertIsBool( flush_cache_group( CACHE_GROUP ) ); + } + + /** + * Flushing on a version change clears the previous version's group. + * + * @return void + */ + public function test_version_change_clears_the_previous_group(): void { + $previous_group = CACHE_GROUP_BASE . '_3.9.6'; + + wp_cache_set( 'stale', 'left by the older version', $previous_group ); + wp_cache_set( 'current', 'ours', CACHE_GROUP ); + + flush_versioned_cache_groups( '3.9.6' ); + + if ( function_exists( 'wp_cache_supports' ) && wp_cache_supports( 'flush_group' ) ) { + $this->assertFalse( wp_cache_get( 'stale', $previous_group ) ); + } else { + $this->markTestSkipped( 'Object cache does not support group flushing.' ); + } + } + + /** + * The legacy unscoped group is cleared on the first upgrade. + * + * Sites coming from 3.10.0 or 3.10.1 have Snippet objects sitting in the + * old unscoped group. Those are what break the next rollback, so upgrading + * to a version that scopes the group has to shed them. + * + * @return void + */ + public function test_legacy_unscoped_group_is_cleared_on_upgrade(): void { + if ( ! function_exists( 'wp_cache_supports' ) || ! wp_cache_supports( 'flush_group' ) ) { + $this->markTestSkipped( 'Object cache does not support group flushing.' ); + } + + wp_cache_set( 'all_snippets_wp_snippets', 'objects from 3.10.1', CACHE_GROUP_BASE ); + + flush_versioned_cache_groups( '' ); + + $this->assertFalse( wp_cache_get( 'all_snippets_wp_snippets', CACHE_GROUP_BASE ) ); + } + + /** + * An empty previous version is tolerated, as on a fresh install. + * + * @return void + */ + public function test_empty_previous_version_is_tolerated(): void { + flush_versioned_cache_groups( '' ); + + $this->assertTrue( true ); + } +} From a26b66e46dc4e1af9299774e4cd1945e29b86a6e Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:41:48 +0100 Subject: [PATCH 04/23] fix: stop aliased field names warning when reading modified fields (#496) --- src/php/Model/Model.php | 6 +++++- tests/unit/Model/Snippet_Test.php | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/php/Model/Model.php b/src/php/Model/Model.php index 74aab9e81..94bdcf392 100644 --- a/src/php/Model/Model.php +++ b/src/php/Model/Model.php @@ -91,7 +91,11 @@ public function get_modified_fields(): array { return array_filter( $this->get_fields(), function ( $value, $field ) { - return $value && $value !== static::$default_values[ $field ]; + // The field list includes aliases, which have no entry of their own + // in the defaults. Compare against the field an alias resolves to, + // so reading a snippet built from alias keys does not warn. + $default = static::$default_values[ static::resolve_field_name( $field ) ] ?? null; + return $value && $value !== $default; }, ARRAY_FILTER_USE_BOTH ); diff --git a/tests/unit/Model/Snippet_Test.php b/tests/unit/Model/Snippet_Test.php index 326b35182..f99e7f789 100644 --- a/tests/unit/Model/Snippet_Test.php +++ b/tests/unit/Model/Snippet_Test.php @@ -66,4 +66,29 @@ public function test_modified_iso_is_null_when_unset(): void { $this->assertNull( $snippet->modified_iso ); } + + /** + * Reading modified fields from a snippet built with alias keys does not warn. + * + * The field list includes aliases such as 'description' and 'language', which + * have no entry of their own in the defaults. Importing builds snippets from + * exactly those keys, so every imported snippet raised a PHP warning. Warnings + * are converted to exceptions for this suite, so a regression fails here. + * + * @return void + */ + public function test_get_modified_fields_handles_aliased_field_names(): void { + $snippet = new Snippet( + [ + 'name' => 'Aliased fields', + 'description' => 'set through the alias', + 'language' => 'php', + ] + ); + + $modified = $snippet->get_modified_fields(); + + $this->assertArrayHasKey( 'desc', $modified ); + $this->assertSame( 'set through the alias', $modified['desc'] ); + } } From 1ad7bb33b1008e9d5973c726d224d1bf0ba8a756 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:43:57 +0100 Subject: [PATCH 05/23] fix: keep saving possible after the session expires (#490) --- src/js/hooks/useRestAPI.tsx | 4 ++- src/js/hooks/useSubmitSnippet.tsx | 5 ++-- src/js/types/Window.ts | 7 +++++ src/js/utils/errors.ts | 33 ++++++++++++++++++++++ src/js/utils/restAPI.ts | 47 ++++++++++++++++++++++++++++++- 5 files changed, 92 insertions(+), 4 deletions(-) diff --git a/src/js/hooks/useRestAPI.tsx b/src/js/hooks/useRestAPI.tsx index bd0f6bad3..6730bc428 100644 --- a/src/js/hooks/useRestAPI.tsx +++ b/src/js/hooks/useRestAPI.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react' import axios from 'axios' import { createContextHook } from '../utils/bootstrap' -import { REST_API_AXIOS_CONFIG, applyMethodOverride } from '../utils/restAPI' +import { REST_API_AXIOS_CONFIG, applyMethodOverride, applyRestNonce, listenForNonceRefresh } from '../utils/restAPI' import type { PropsWithChildren } from 'react' import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios' @@ -60,7 +60,9 @@ const [Context, useRestAPI] = createContextHook('useRestAPI') export const WithRestAPIContext: React.FC = ({ children }) => { const axiosInstance = useMemo(() => { const instance = axios.create(REST_API_AXIOS_CONFIG) + instance.interceptors.request.use(applyRestNonce) instance.interceptors.request.use(applyMethodOverride) + listenForNonceRefresh() return instance }, []) diff --git a/src/js/hooks/useSubmitSnippet.tsx b/src/js/hooks/useSubmitSnippet.tsx index 827ec36d5..4ad2de6a3 100644 --- a/src/js/hooks/useSubmitSnippet.tsx +++ b/src/js/hooks/useSubmitSnippet.tsx @@ -1,6 +1,7 @@ import { __ } from '@wordpress/i18n' import { isAxiosError } from 'axios' import React, { useCallback } from 'react' +import { describeRequestError } from '../utils/errors' import { useSnippetForm } from '../components/EditMenu/SnippetForm/WithSnippetFormContext' import { createSnippetObject, isCondition } from '../utils/snippets/snippets' import { buildUrl } from '../utils/urls' @@ -113,8 +114,8 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { : api.update({ ...request, id })) return response.id ? createSnippetObject(response) : undefined - } catch (error) { - return isAxiosError(error) ? error.message : undefined + } catch (error: unknown) { + return isAxiosError(error) ? describeRequestError(error) : undefined } finally { setIsWorking(false) } diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 9a1ebcd38..494c18401 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -12,6 +12,13 @@ declare global { readonly wp: { readonly editor?: WordPressEditor readonly codeEditor?: WordPressCodeEditor + readonly hooks?: { + addAction: ( + hookName: string, + namespace: string, + callback: (data: { rest_nonce?: string }) => void + ) => void + } } readonly pagenow?: string readonly ajaxurl: string diff --git a/src/js/utils/errors.ts b/src/js/utils/errors.ts index 7df075577..2377090e1 100644 --- a/src/js/utils/errors.ts +++ b/src/js/utils/errors.ts @@ -20,3 +20,36 @@ export const unpackErrorResponse = (error: unknown): string => { return __('An unknown error occurred.', 'code-snippets') } + +/** + * Explain a failed request in terms the reader can act on. + * + * An expired session is the common case worth naming: the snippet editor is a + * screen people leave open, and once the session lapses WordPress rejects every + * write with a 403 that says only "Cookie check failed". Reporting the raw + * status left people believing the plugin had ignored them. + */ +export const describeRequestError = (error: unknown): string => { + if (!isAxiosError(error)) { + return unpackErrorResponse(error) + } + + if (!error.response) { + return __( + 'The request did not reach your site. Check your connection, or whether a security plugin is blocking it.', + 'code-snippets' + ) + } + + const data: unknown = error.response.data + const code = data && 'object' === typeof data && 'code' in data ? String(data.code) : '' + + if ('rest_cookie_invalid_nonce' === code || 'rest_not_logged_in' === code) { + return __( + 'You have been signed out, so nothing was saved. Sign in again in another tab, then save. Your changes are still here.', + 'code-snippets' + ) + } + + return unpackErrorResponse(error) +} diff --git a/src/js/utils/restAPI.ts b/src/js/utils/restAPI.ts index 165fe0377..8f0befe2c 100644 --- a/src/js/utils/restAPI.ts +++ b/src/js/utils/restAPI.ts @@ -41,9 +41,54 @@ export const applyMethodOverride = (config: InternalAxiosRequestConfig): Interna return config } +/** + * The REST nonce to authenticate the next request with. + * + * Held in a variable rather than baked into the axios config, because the value + * the page was rendered with does not stay valid. A nonce expires with the + * session, and the snippet editor is a screen people leave open for a long + * time. Once it lapsed, every save failed with a 403 and the only cure was + * reloading the page, which loses whatever was being written. + */ +let restNonce = window.CODE_SNIPPETS?.restAPI.nonce + +/** + * Keep the REST nonce current for as long as the page is open. + * + * WordPress already sends a freshly minted nonce with every Heartbeat response, + * from `wp_refresh_heartbeat_nonces()`. Core applies it to `wpApiSettings`, + * which our screens do not enqueue, so the value went unused. Listening for the + * tick ourselves means an editor left open stays able to save. + */ +export const listenForNonceRefresh = () => { + // Heartbeat also fires the tick through the hooks API, which avoids + // depending on jQuery being present and typed. + window.wp.hooks?.addAction( + 'heartbeat.tick', + 'code-snippets/refresh-rest-nonce', + (data: { rest_nonce?: string }) => { + if (data.rest_nonce) { + restNonce = data.rest_nonce + } + } + ) +} + +/** + * Attach the current nonce to an outgoing request. + * + * Read per request, so that a nonce refreshed since page load is actually used. + */ +export const applyRestNonce = (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + if (restNonce) { + config.headers.set('X-WP-Nonce', restNonce) + } + + return config +} + export const REST_API_AXIOS_CONFIG: AxiosRequestConfig = { headers: { - 'X-WP-Nonce': window.CODE_SNIPPETS?.restAPI.nonce, 'Access-Control': window.CODE_SNIPPETS?.restAPI.cloud.token } } From 6f1b898ee45cc79f0c14486a865b367cbf324f40 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:47:37 +0100 Subject: [PATCH 06/23] fix: only validate PHP when activating snippets in bulk (#491) --- src/php/Utils/Validator.php | 42 ++++- src/php/snippet-ops.php | 17 +- tests/unit/Snippets/Batch_Activation_Test.php | 144 ++++++++++++++ .../Bulk_Activate_Validation_Test.php | 175 ++++++++++++++++++ 4 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 tests/unit/Snippets/Batch_Activation_Test.php create mode 100644 tests/unit/Snippets/Bulk_Activate_Validation_Test.php diff --git a/src/php/Utils/Validator.php b/src/php/Utils/Validator.php index d034f1c97..324605fb3 100644 --- a/src/php/Utils/Validator.php +++ b/src/php/Utils/Validator.php @@ -51,18 +51,46 @@ class Validator { */ private array $exceptions = []; + /** + * Identifiers already claimed by other snippets being validated alongside + * this one. + * + * A snippet is validated against everything PHP has declared so far, which + * does not include a snippet that is about to be activated in the same + * batch. Two snippets declaring the same function therefore both passed and + * both activated, and the site fataled on the next request. + * + * @var array + */ + private array $claimed_identifiers = []; + /** * Class constructor. * - * @param string $code Snippet code for parsing. + * @param string $code Snippet code for parsing. + * @param array $claimed_identifiers Identifiers already claimed by + * snippets validated alongside this one. */ - public function __construct( string $code ) { + public function __construct( string $code, array $claimed_identifiers = [] ) { + $this->claimed_identifiers = $claimed_identifiers; $this->code = $code; $this->tokens = token_get_all( "code ); $this->length = count( $this->tokens ); $this->current = 0; } + /** + * Retrieve the identifiers claimed so far, including this snippet's own. + * + * Pass the result to the next Validator in a batch so that two snippets + * cannot both claim the same name. + * + * @return array + */ + public function get_claimed_identifiers(): array { + return $this->claimed_identifiers; + } + /** * Determine whether the parser has reached the end of the list of tokens. * @@ -127,13 +155,19 @@ private function check_duplicate_identifier( string $type, string $identifier ): } } - $duplicate_identifier = in_array( $identifier, $this->defined_identifiers[ $type ], true ); - $duplicate_namespaced = in_array( $namespaced_identifier, $this->defined_identifiers[ $type ], true ); + $known = array_merge( + $this->defined_identifiers[ $type ], + $this->claimed_identifiers[ $type ] ?? [] + ); + + $duplicate_identifier = in_array( $identifier, $known, true ); + $duplicate_namespaced = in_array( $namespaced_identifier, $known, true ); $exceptions = $this->exceptions[ $type ] ?? []; $exception_identifier = in_array( $identifier, $exceptions, true ); $exception_namespaced = in_array( $namespaced_identifier, $exceptions, true ); array_unshift( $this->defined_identifiers[ $type ], $identifier ); + $this->claimed_identifiers[ $type ][] = $identifier; return ( $duplicate_identifier && ! $exception_identifier ) || ( $duplicate_namespaced && ! $exception_namespaced ); } diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index b3384e989..deae3755a 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -447,11 +447,26 @@ function activate_snippets( array $ids, ?bool $network = null ): ?array { $valid_ids = []; $valid_snippets = []; + // Names claimed by snippets already accepted into this batch. A snippet is + // otherwise validated only against what PHP has declared so far, which does + // not include the other snippets about to be activated alongside it. + $claimed_identifiers = []; + foreach ( $snippets as $snippet ) { - $validator = new Validator( $snippet->code ); + // Only PHP is validated. The validator looks for redeclarations of + // existing PHP functions and classes, which says nothing meaningful + // about CSS or JavaScript. + if ( 'php' !== $snippet->type ) { + $valid_ids[] = $snippet->id; + $valid_snippets[] = $snippet; + continue; + } + + $validator = new Validator( $snippet->code, $claimed_identifiers ); $code_error = $validator->validate(); if ( ! $code_error ) { + $claimed_identifiers = $validator->get_claimed_identifiers(); $valid_ids[] = $snippet->id; $valid_snippets[] = $snippet; } diff --git a/tests/unit/Snippets/Batch_Activation_Test.php b/tests/unit/Snippets/Batch_Activation_Test.php new file mode 100644 index 000000000..fa1f4dc8d --- /dev/null +++ b/tests/unit/Snippets/Batch_Activation_Test.php @@ -0,0 +1,144 @@ +name = 'Batch test'; + $snippet->scope = $scope; + $snippet->code = $code; + $snippet->active = false; + + return save_snippet( $snippet ); + } + + /** + * Whether a snippet is active, read back from storage. + * + * @param int $id Snippet identifier. + * + * @return bool + */ + private function is_active( int $id ): bool { + return (bool) get_snippet( $id )->active; + } + + /** + * Two snippets declaring the same function are not both activated. + * + * Each was previously validated only against what PHP had declared at the + * time, which did not include the other snippet in the same batch. Both + * passed, both activated, and the next request fataled with + * "Cannot redeclare function". + * + * @return void + */ + public function test_two_snippets_declaring_the_same_function_do_not_both_activate(): void { + $first = $this->make_snippet( 'global', 'function cs_batch_helper() { return 1; }' ); + $second = $this->make_snippet( 'global', 'function cs_batch_helper() { return 2; }' ); + + activate_snippets( [ $first->id, $second->id ] ); + + $this->assertTrue( $this->is_active( $first->id ), 'The first snippet should activate.' ); + $this->assertFalse( $this->is_active( $second->id ), 'The second should be held back.' ); + } + + /** + * The same applies to classes. + * + * @return void + */ + public function test_two_snippets_declaring_the_same_class_do_not_both_activate(): void { + $first = $this->make_snippet( 'global', 'class CS_Batch_Widget {}' ); + $second = $this->make_snippet( 'global', 'class CS_Batch_Widget {}' ); + + activate_snippets( [ $first->id, $second->id ] ); + + $this->assertTrue( $this->is_active( $first->id ) ); + $this->assertFalse( $this->is_active( $second->id ) ); + } + + /** + * Snippets declaring different names both activate. + * + * @return void + */ + public function test_snippets_with_different_names_both_activate(): void { + $first = $this->make_snippet( 'global', 'function cs_batch_one() { return 1; }' ); + $second = $this->make_snippet( 'global', 'function cs_batch_two() { return 2; }' ); + + activate_snippets( [ $first->id, $second->id ] ); + + $this->assertTrue( $this->is_active( $first->id ) ); + $this->assertTrue( $this->is_active( $second->id ) ); + } + + /** + * A guarded redeclaration is still allowed, as it cannot fatal. + * + * @return void + */ + public function test_guarded_declarations_are_allowed(): void { + $first = $this->make_snippet( 'global', 'function cs_batch_guarded() { return 1; }' ); + $second = $this->make_snippet( + 'global', + "if ( ! function_exists( 'cs_batch_guarded' ) ) {\n\tfunction cs_batch_guarded() { return 2; }\n}" + ); + + activate_snippets( [ $first->id, $second->id ] ); + + $this->assertTrue( $this->is_active( $first->id ) ); + $this->assertTrue( $this->is_active( $second->id ) ); + } + + /** + * Scripts are not held back by a name another snippet declares. + * + * @return void + */ + public function test_scripts_are_unaffected_by_php_names(): void { + $php = $this->make_snippet( 'global', 'function cs_batch_shared() { return 1; }' ); + $js = $this->make_snippet( 'site-footer-js', 'function cs_batch_shared() { return 2; }' ); + + activate_snippets( [ $php->id, $js->id ] ); + + $this->assertTrue( $this->is_active( $php->id ) ); + $this->assertTrue( $this->is_active( $js->id ), 'JavaScript shares no namespace with PHP.' ); + } + + /** + * Anonymous functions do not claim a name. + * + * @return void + */ + public function test_anonymous_functions_do_not_collide(): void { + $first = $this->make_snippet( 'global', "add_filter( 'the_content', function ( \$c ) { return \$c; } );" ); + $second = $this->make_snippet( 'global', "add_filter( 'the_title', function ( \$t ) { return \$t; } );" ); + + activate_snippets( [ $first->id, $second->id ] ); + + $this->assertTrue( $this->is_active( $first->id ) ); + $this->assertTrue( $this->is_active( $second->id ) ); + } +} diff --git a/tests/unit/Snippets/Bulk_Activate_Validation_Test.php b/tests/unit/Snippets/Bulk_Activate_Validation_Test.php new file mode 100644 index 000000000..135286bde --- /dev/null +++ b/tests/unit/Snippets/Bulk_Activate_Validation_Test.php @@ -0,0 +1,175 @@ +name = 'Validation test'; + $snippet->scope = $scope; + $snippet->code = $code; + $snippet->active = false; + + return save_snippet( $snippet ); + } + + /** + * Whether a snippet is active, read back from storage. + * + * @param int $id Snippet identifier. + * + * @return bool + */ + private function is_active( int $id ): bool { + return (bool) get_snippet( $id )->active; + } + + /** + * JavaScript naming a PHP built-in can be bulk activated. + * + * `next` and `reset` are ordinary names in a script, and both are PHP + * functions, so the validator reported a redeclaration and the snippet was + * quietly left inactive. + * + * @return void + */ + public function test_javascript_naming_php_builtins_can_be_bulk_activated(): void { + $snippet = $this->make_snippet( + 'site-footer-js', + "function next() {\n\tindex += 1;\n}\n\nfunction reset() {\n\tindex = 0;\n}" + ); + + activate_snippets( [ $snippet->id ] ); + + $this->assertTrue( $this->is_active( $snippet->id ) ); + } + + /** + * The same snippet has always activated on its own, which is the + * inconsistency people run into. + * + * @return void + */ + public function test_single_activation_of_the_same_snippet_already_worked(): void { + $snippet = $this->make_snippet( 'site-footer-js', 'function reset() {}' ); + + activate_snippet( $snippet->id ); + + $this->assertTrue( $this->is_active( $snippet->id ) ); + } + + /** + * Stylesheets are not run through the PHP validator either. + * + * @return void + */ + public function test_stylesheets_can_be_bulk_activated(): void { + $snippet = $this->make_snippet( 'site-css', '.count { color: red; }' ); + + activate_snippets( [ $snippet->id ] ); + + $this->assertTrue( $this->is_active( $snippet->id ) ); + } + + /** + * PHP is still checked: a genuine redeclaration is still refused. + * + * @return void + */ + public function test_php_redeclaring_an_existing_function_is_still_refused(): void { + $snippet = $this->make_snippet( 'global', 'function get_option() { return 1; }' ); + + $result = activate_snippets( [ $snippet->id ] ); + + $this->assertNull( $result ); + $this->assertFalse( $this->is_active( $snippet->id ) ); + } + + /** + * PHP that is fine still activates. + * + * @return void + */ + public function test_valid_php_is_still_bulk_activated(): void { + $snippet = $this->make_snippet( 'global', "add_filter( 'the_content', 'cs_test_cb' );" ); + + activate_snippets( [ $snippet->id ] ); + + $this->assertTrue( $this->is_active( $snippet->id ) ); + } + + /** + * A bad PHP snippet does not prevent the others in the batch activating. + * + * @return void + */ + public function test_one_invalid_php_snippet_does_not_block_the_batch(): void { + $good = $this->make_snippet( 'site-footer-js', 'function count() {}' ); + $bad = $this->make_snippet( 'global', 'function get_option() { return 1; }' ); + + activate_snippets( [ $good->id, $bad->id ] ); + + $this->assertTrue( $this->is_active( $good->id ) ); + $this->assertFalse( $this->is_active( $bad->id ) ); + } + + /** + * The rejected names come from whatever is declared, not a fixed list. + * + * `check_duplicate_identifier()` builds its list from + * `get_defined_functions()`, covering PHP internals and every function + * declared by WordPress, the active plugins and the theme. So the set of + * JavaScript names that used to be refused was specific to each site and + * grew as plugins were added, which is why the behaviour looked arbitrary + * and was hard to reproduce. + * + * Deriving the name here rather than hard-coding one keeps this honest + * whatever is loaded in the test environment. + * + * @return void + */ + public function test_a_name_declared_on_this_install_no_longer_blocks_javascript(): void { + $defined = get_defined_functions(); + $candidates = array_intersect( + [ 'next', 'reset', 'count', 'sort', 'log', 'min', 'max', 'trim' ], + array_map( 'strtolower', array_merge( $defined['internal'], $defined['user'] ) ) + ); + + $this->assertNotEmpty( $candidates, 'Expected at least one common name to be declared.' ); + + $name = (string) reset( $candidates ); + $snippet = $this->make_snippet( 'site-footer-js', "function $name() { return 1; }" ); + + activate_snippets( [ $snippet->id ] ); + + $this->assertTrue( + $this->is_active( $snippet->id ), + "A script declaring $name should still activate." + ); + } +} From 61bb492b60a65fb34202e1a583ff1fbe2ef73932 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Mon, 31 Aug 2026 18:49:59 +0100 Subject: [PATCH 07/23] fix: safe mode fatals with an undefined wp_get_current_user() (#484) --- src/php/Integration/Evaluate_Functions.php | 39 ++++++-- .../Evaluate_Functions_Safe_Mode_Test.php | 91 +++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php diff --git a/src/php/Integration/Evaluate_Functions.php b/src/php/Integration/Evaluate_Functions.php index 7f9d3ea64..3e4e7b2e5 100644 --- a/src/php/Integration/Evaluate_Functions.php +++ b/src/php/Integration/Evaluate_Functions.php @@ -35,30 +35,52 @@ public function __construct( DB $db ) { add_action( 'plugins_loaded', [ $this, 'evaluate_early' ], 1 ); add_filter( 'code_snippets/execute_snippets', [ $this, 'disable_snippet_execution' ], 5 ); - if ( $this->is_safe_mode_requested() ) { + // Only the query var is inspected here. This constructor runs while + // plugins are still being included, which is before WordPress loads + // pluggable.php, so a capability check at this point would call an + // undefined wp_get_current_user() and take the whole request down. + // The capability is checked in the callback instead, which never runs + // before URLs are being generated. + if ( $this->is_safe_mode_query_var_set() ) { add_filter( 'home_url', [ $this, 'add_safe_mode_query_var' ] ); add_filter( 'admin_url', [ $this, 'add_safe_mode_query_var' ] ); } } + /** + * Check whether the safe mode query var is present on this request. + * + * Safe to call at any point, as it reads nothing but the request. + * + * @return bool + */ + public function is_safe_mode_query_var_set(): bool { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return ! empty( $_REQUEST['snippets-safe-mode'] ); + } + /** * Check if safe mode has been requested via query var. * + * Performs a capability check, so must not be called before pluggable + * functions are available. + * * @return bool */ public function is_safe_mode_requested(): bool { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - return ! empty( $_REQUEST['snippets-safe-mode'] ) && code_snippets()->current_user_can(); + return $this->is_safe_mode_query_var_set() && code_snippets()->current_user_can(); } /** * Inject the safe mode query var into URLs * - * @param string $url Original URL. + * @param mixed $url Original URL, from an unknown earlier callback. * * @return string Modified URL. */ - public function add_safe_mode_query_var( string $url ): string { + public function add_safe_mode_query_var( $url ): string { + $url = is_string( $url ) ? $url : ''; + return $this->is_safe_mode_requested() ? add_query_arg( 'snippets-safe-mode', true, $url ) : $url; @@ -109,12 +131,13 @@ public static function is_safe_mode_active(): bool { /** * Disable snippet execution if the necessary query var is set. * - * @param bool $execute_snippets Current filter value. + * @param mixed $execute_snippets Current filter value, from an unknown + * earlier callback. * * @return bool New filter value. */ - public function disable_snippet_execution( bool $execute_snippets ): bool { - return $execute_snippets && ! self::is_safe_mode_requested(); + public function disable_snippet_execution( $execute_snippets ): bool { + return (bool) $execute_snippets && ! $this->is_safe_mode_requested(); } /** diff --git a/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php b/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php new file mode 100644 index 000000000..1f86df8ac --- /dev/null +++ b/tests/unit/Integration/Evaluate_Functions_Safe_Mode_Test.php @@ -0,0 +1,91 @@ +db ); + + $this->assertFalse( $evaluate->is_safe_mode_query_var_set() ); + + $_REQUEST['snippets-safe-mode'] = '1'; + + $this->assertTrue( $evaluate->is_safe_mode_query_var_set() ); + } + + /** + * Constructing the class with the query var set must not be fatal. + * + * @return void + */ + public function test_constructing_with_the_query_var_set_is_not_fatal(): void { + $_REQUEST['snippets-safe-mode'] = '1'; + + $evaluate = new Evaluate_Functions( code_snippets()->db ); + + $this->assertTrue( $evaluate->is_safe_mode_query_var_set() ); + $this->assertSame( 10, has_filter( 'admin_url', [ $evaluate, 'add_safe_mode_query_var' ] ) ); + } + + /** + * The URL callback tolerates a non-string from an earlier callback. + * + * @return void + */ + public function test_url_callback_tolerates_a_null_from_an_earlier_callback(): void { + $evaluate = new Evaluate_Functions( code_snippets()->db ); + + $this->assertIsString( $evaluate->add_safe_mode_query_var( null ) ); + } + + /** + * The execution callback tolerates a non-bool from an earlier callback. + * + * @return void + */ + public function test_execution_callback_tolerates_a_null_from_an_earlier_callback(): void { + $evaluate = new Evaluate_Functions( code_snippets()->db ); + + $this->assertFalse( $evaluate->disable_snippet_execution( null ) ); + $this->assertTrue( $evaluate->disable_snippet_execution( true ) ); + } +} From 4733a0f92f59873aa3f7bc9d2818c1c9915c2ce8 Mon Sep 17 00:00:00 2001 From: Imants Date: Mon, 31 Aug 2026 22:53:33 +0300 Subject: [PATCH 08/23] docs: rewrite the 3.10.1 changelog and set tested-up-to to 7.1 The 3.10.1 entry listed only the cosmetic changes and omitted the substantive fixes that shipped in that release: the fatal on snippets using namespace or declare, the blank snippets page when another plugin's screen settings filter returned an invalid value, REST writes falling back to POST with a method override, and the Snippets List Order setting being applied again. Rewrite the entry so it reflects what actually changed, and update the readme tested-up-to header to 7.1. --- CHANGELOG.md | 20 ++++++++++++-------- src/readme.txt | 18 +++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a79f1cf..fb2582276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,18 @@ ## [3.10.1] (2026-08-28) ### Changed -* Enhanced active-link visibility in the toolbar with border-based styling -* Standardized sidebar label font sizing for consistency - -### Fixed -* Fixed admin bar script loading on core to work correctly on front-end -* Fixed snippet modification dates to send with proper UTC offset -* Fixed row truncation screen option to persist correctly across sessions -* Fixed sticky sidebar from displaying unwanted horizontal scrollbar +* Enhanced active-link visibility in the toolbar with border-based styling. + +### Fixed +* Fixed a fatal error affecting snippets that use a `namespace` or `declare` statement. +* Fixed the snippets page rendering blank when another plugin's screen settings filter returned an invalid value. +* Fixed snippet saving on hosts that block REST API `PUT` and `PATCH` requests, by sending writes as `POST` with a method override. +* Fixed the Snippets List Order setting not being applied to the snippets list. +* Fixed admin bar snippet scripts failing to load on the free version, including on the site front end. +* Fixed snippet modified dates being sent without the correct UTC offset. +* Fixed the row truncation screen option not persisting across sessions. +* Fixed inconsistent sidebar label sizes. +* Fixed the sticky editor sidebar showing an unwanted horizontal scrollbar. ## [3.10.0] (2026-08-24) diff --git a/src/readme.txt b/src/readme.txt index 6f8d0560b..7ee860ba3 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -6,7 +6,7 @@ License: GPL-2.0-or-later License URI: license.txt Stable tag: 3.10.2 Requires at least: 5.5 -Tested up to: 7.0.3 +Tested up to: 7.1 Requires PHP: 7.4 An easy, clean, and simple way to enhance your site with code snippets. @@ -110,15 +110,19 @@ You can report security bugs found in the source code of this plugin through the __Changed__ -* Enhanced active-link visibility in the toolbar with border-based styling -* Standardized sidebar label font sizing for consistency +* Enhanced active-link visibility in the toolbar with border-based styling. __Fixed__ -* Fixed admin bar script loading on core to work correctly on front-end -* Fixed snippet modification dates to send with proper UTC offset -* Fixed row truncation screen option to persist correctly across sessions -* Fixed sticky sidebar from displaying unwanted horizontal scrollbar +* Fixed a fatal error affecting snippets that use a `namespace` or `declare` statement. +* Fixed the snippets page rendering blank when another plugin's screen settings filter returned an invalid value. +* Fixed snippet saving on hosts that block REST API `PUT` and `PATCH` requests, by sending writes as `POST` with a method override. +* Fixed the Snippets List Order setting not being applied to the snippets list. +* Fixed admin bar snippet scripts failing to load on the free version, including on the site front end. +* Fixed snippet modified dates being sent without the correct UTC offset. +* Fixed the row truncation screen option not persisting across sessions. +* Fixed inconsistent sidebar label sizes. +* Fixed the sticky editor sidebar showing an unwanted horizontal scrollbar. = 3.10.0 (2026-08-24) = From 2f0981b995b9d692df3c4b84e823752669758f4d Mon Sep 17 00:00:00 2001 From: Imants Date: Mon, 31 Aug 2026 23:35:31 +0300 Subject: [PATCH 09/23] fix: show the run-once confirmation and harden the handler The run-once outcome now flows through the manage screen's existing React notice channel instead of a dedicated admin_notices callback. The old callback emitted a notice class that the plugin's own notice filter hides, so the confirmation never appeared, and it called wp_admin_notice() (WordPress 6.4+) on a plugin that supports 5.5, which fataled admin screens on older versions. Harden the handler as well: take the network context from the current screen rather than the request, so a subsite administrator cannot target a network snippet; run only single-use snippets; and treat an already-active snippet as a successful no-op instead of a failure. Add the run-once nonce, added to the localized manage data, to the assets test so its key list matches again. --- src/js/components/ManageMenu/ManageMenu.tsx | 27 +++++++---- src/php/Admin/Menus/Manage/Manage_Menu.php | 48 ++++++------------- .../Menus/Manage/Manage_Menu_Assets_Test.php | 1 + 3 files changed, 33 insertions(+), 43 deletions(-) diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx index a74100c1a..68903fd1d 100644 --- a/src/js/components/ManageMenu/ManageMenu.tsx +++ b/src/js/components/ManageMenu/ManageMenu.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react' import { __ } from '@wordpress/i18n' import { createInterpolateElement } from '@wordpress/element' import { fetchConstQueryParam, fetchQueryParam, updateQueryParams } from '../../utils/urls' -import { DismissibleNotice } from '../common/Notice' +import { DismissibleNotice, type NoticeType } from '../common/Notice' import { SUBPAGES, Toolbar } from '../common/Toolbar' import { UpsellPage } from '../common/UpsellDialog' import { CommunityCloud } from './CommunityCloud/CommunityCloud' @@ -24,10 +24,19 @@ const repositionTableOptionsSettings = () => { } } -const getNoticeText = (result: string) => { +const getNotice = (result: string): { text: string, type: NoticeType } | undefined => { switch (result) { case 'deleted': - return __('Snippet deleted.', 'code-snippets') + return { text: __('Snippet deleted.', 'code-snippets'), type: 'success' } + + case 'executed': + return { text: __('Snippet executed.', 'code-snippets'), type: 'success' } + + case 'run-once-failed': + return { + text: __('The snippet could not be run. Check that its code is valid and try again.', 'code-snippets'), + type: 'error' + } default: return undefined @@ -35,20 +44,20 @@ const getNoticeText = (result: string) => { } const PageNotices = () => { - const [noticeText, setNoticeText] = useState(() => { + const [notice, setNotice] = useState(() => { const result = fetchQueryParam('result') updateQueryParams({ result: undefined }) - return result && getNoticeText(result) + return result ? getNotice(result) : undefined }) - return noticeText + return notice ? { - setNoticeText(undefined) + setNotice(undefined) }} - type="success"> -

{createInterpolateElement(noticeText, { strong: })}

+ type={notice.type}> +

{createInterpolateElement(notice.text, { strong: })}

: null } diff --git a/src/php/Admin/Menus/Manage/Manage_Menu.php b/src/php/Admin/Menus/Manage/Manage_Menu.php index eaeaaecc1..93cd46162 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu.php @@ -7,6 +7,7 @@ use Code_Snippets\Controller\Cloud_Search_Controller; use function Code_Snippets\activate_snippet; use function Code_Snippets\code_snippets; +use function Code_Snippets\get_snippet; use function Code_Snippets\Settings\get_setting; use const Code_Snippets\PLUGIN_FILE; use const Code_Snippets\PLUGIN_VERSION; @@ -47,7 +48,6 @@ public function __construct() { new Manage_Menu_Bulk_Download(); add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 ); - add_action( 'admin_notices', [ $this, 'render_run_once_notice' ] ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_css' ) ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] ); } @@ -218,10 +218,20 @@ private function handle_run_once(): void { return; } - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Verified above. - $network = isset( $_REQUEST['network'] ) ? rest_sanitize_boolean( wp_unslash( $_REQUEST['network'] ) ) : null; + // The network context comes from the current screen, never the request, + // so a subsite administrator cannot target a network snippet. + $network = is_network_admin(); + $snippet = get_snippet( $snippet_id, $network ); + + // Only single-use snippets are run this way. Activating anything else + // would leave it permanently on while the notice claimed it ran once. + if ( ! $snippet || 0 === $snippet->id || 'single-use' !== $snippet->scope ) { + wp_safe_redirect( remove_query_arg( [ 'action', 'snippet', 'network', '_wpnonce', 'result' ] ) ); + exit; + } - $result = activate_snippet( $snippet_id, $network ); + // An already-active snippet has effectively run, so treat it as success. + $result = $snippet->active ? $snippet : activate_snippet( $snippet_id, $network ); wp_safe_redirect( add_query_arg( @@ -232,36 +242,6 @@ private function handle_run_once(): void { exit; } - /** - * Report the outcome of a run-once request. - * - * @return void - */ - public function render_run_once_notice(): void { - // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display of an outcome. - $result = isset( $_GET['result'] ) ? sanitize_key( wp_unslash( $_GET['result'] ) ) : ''; - - if ( 'executed' === $result ) { - wp_admin_notice( - __( 'Snippet executed.', 'code-snippets' ), - [ - 'type' => 'success', - 'dismissible' => true, - 'additional_classes' => [ 'code-snippets-run-once-notice' ], - ] - ); - } elseif ( 'run-once-failed' === $result ) { - wp_admin_notice( - __( 'The snippet could not be run. Check that its code is valid and try again.', 'code-snippets' ), - [ - 'type' => 'error', - 'dismissible' => true, - 'additional_classes' => [ 'code-snippets-run-once-notice' ], - ] - ); - } - } - /** * Executed when the admin page is loaded. */ diff --git a/tests/unit/Admin/Menus/Manage/Manage_Menu_Assets_Test.php b/tests/unit/Admin/Menus/Manage/Manage_Menu_Assets_Test.php index 04cb9edd0..61569f2f1 100644 --- a/tests/unit/Admin/Menus/Manage/Manage_Menu_Assets_Test.php +++ b/tests/unit/Admin/Menus/Manage/Manage_Menu_Assets_Test.php @@ -51,6 +51,7 @@ public function test_enqueue_localizes_manage_data(): void { 'cloudSearchPerPage', 'isSafeModeActive', 'bulkDownloadNonce', + 'runOnceNonce', 'supportsZipDownloads', 'editorTheme', 'typeCounts', From c04f3470ea130024a3b4867eba20fc8763f70ee1 Mon Sep 17 00:00:00 2001 From: Imants Date: Mon, 31 Aug 2026 23:51:55 +0300 Subject: [PATCH 10/23] fix: unblock changelog generation for release PRs Use the workflow token for the labeler permission check so it can read repository collaborator permission, and pass the branch input the changelog generation requires. The check previously refused every labeler, and generation then failed for a missing input. --- .github/workflows/prepare-release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 78e175ffd..d2b49fe1a 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -100,6 +100,8 @@ jobs: GH_TOKEN: ${{ secrets.CHANGELOG_PAT || github.token }} steps: - name: Authorize labeler + env: + GH_TOKEN: ${{ github.token }} run: | actor='${{ github.event.sender.login }}' perm=$(gh api "repos/${{ github.repository }}/collaborators/$actor/permission" --jq '.permission' 2>/dev/null || echo none) @@ -116,6 +118,7 @@ jobs: --ref main \ --field mode=generate \ --field repo="${{ github.repository }}" \ + --field branch="${{ github.event.pull_request.head.ref }}" \ --field pr_number="${{ github.event.pull_request.number }}" \ --field readme_path="./src/readme.txt" echo "::notice::Dispatched changelog generation for PR #${{ github.event.pull_request.number }}" From 0e0d11b14992cbc6af85853a27c9a9192d92db38 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 00:05:56 +0300 Subject: [PATCH 11/23] docs: add the 3.10.2 changelog Add the 3.10.2 release notes covering the user-facing fixes. --- CHANGELOG.md | 10 ++++++++++ src/readme.txt | 11 +++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2582276..3a124109b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [3.10.2] (2026-08-31) + +### Fixed +* Fixed a critical error when loading a site with Safe Mode active. +* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. +* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. +* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. +* Fixed snippet saving stopping after the WordPress login session expired. +* Fixed a PHP warning when reading snippet fields referenced by an alias. + ## [3.10.1] (2026-08-28) ### Changed diff --git a/src/readme.txt b/src/readme.txt index 7ee860ba3..8afd172d4 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -106,6 +106,17 @@ You can report security bugs found in the source code of this plugin through the == Changelog == += 3.10.2 (2026-08-31) = + +__Fixed__ + +* Fixed a critical error when loading a site with Safe Mode active. +* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. +* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. +* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. +* Fixed snippet saving stopping after the WordPress login session expired. +* Fixed a PHP warning when reading snippet fields referenced by an alias. + = 3.10.1 (2026-08-28) = __Changed__ From bfd57dc9d180d8971976fdb9d99928e2b40caadc Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 00:10:54 +0300 Subject: [PATCH 12/23] docs: update contributors for the 3.10.2 release --- src/readme.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readme.txt b/src/readme.txt index 8afd172d4..4b7e25159 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -1,5 +1,5 @@ === Code Snippets === -Contributors: bungeshea, ver3, lightbulbman, 0aksmith, johnpixle, louiswol94, carolinaop +Contributors: bungeshea, ver3, lightbulbman, 0aksmith, johnpixle, carolinaop, tallblokeuk Donate link: https://codesnippets.pro Tags: code, snippets, multisite, php, css License: GPL-2.0-or-later From 5693475ec096d9fcd585f8395bd691c798aa141a Mon Sep 17 00:00:00 2001 From: code-snippets-bot <139164393+code-snippets-bot@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:17:40 +0000 Subject: [PATCH 13/23] chore(release): regenerate changelog for v3.10.2 --- CHANGELOG.md | 19 +++++++++++++------ src/readme.txt | 20 +++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a124109b..e450d718f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,21 @@ # Changelog + ## [3.10.2] (2026-08-31) +### Added +* Restored the Run Once action for single-use snippets and added a confirmation step before executing a snippet once. + +### Changed +* Improved overall snippet stability by tightening validation and version-scoped cache handling, preventing errors during activation and after plugin downgrades. + ### Fixed -* Fixed a critical error when loading a site with Safe Mode active. -* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. -* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. -* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. -* Fixed snippet saving stopping after the WordPress login session expired. -* Fixed a PHP warning when reading snippet fields referenced by an alias. +* Fixed a fatal error in safe mode when wp_get_current_user() was undefined. +* Fixed bulk activation incorrectly validating PHP for all snippets instead of only the snippets being activated. +* Fixed snippet saving failing after the session expired. +* Fixed warnings caused by aliased field names when reading modified fields. +* Fixed cached data not being scoped to the plugin version, preventing downgrade-related fatal errors. +* Restored the Run Once action for single-use snippets and hardened the execution handler against invalid states. ## [3.10.1] (2026-08-28) diff --git a/src/readme.txt b/src/readme.txt index 4b7e25159..58ab150c1 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -106,16 +106,22 @@ You can report security bugs found in the source code of this plugin through the == Changelog == + = 3.10.2 (2026-08-31) = -__Fixed__ +**Added** +* Restored the Run Once action for single-use snippets and added a confirmation step before executing a snippet once. + +**Changed** +* Improved overall snippet stability by tightening validation and version-scoped cache handling, preventing errors during activation and after plugin downgrades. -* Fixed a critical error when loading a site with Safe Mode active. -* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. -* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. -* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. -* Fixed snippet saving stopping after the WordPress login session expired. -* Fixed a PHP warning when reading snippet fields referenced by an alias. +**Fixed** +* Fixed a fatal error in safe mode when wp_get_current_user() was undefined. +* Fixed bulk activation incorrectly validating PHP for all snippets instead of only the snippets being activated. +* Fixed snippet saving failing after the session expired. +* Fixed warnings caused by aliased field names when reading modified fields. +* Fixed cached data not being scoped to the plugin version, preventing downgrade-related fatal errors. +* Restored the Run Once action for single-use snippets and hardened the execution handler against invalid states. = 3.10.1 (2026-08-28) = From 73c821a2c3dcc57461248361e23c069929f6c641 Mon Sep 17 00:00:00 2001 From: code-snippets-bot <139164393+code-snippets-bot@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:19:38 +0000 Subject: [PATCH 14/23] chore(release): regenerate changelog for v3.10.2 --- CHANGELOG.md | 19 +++++++------------ src/readme.txt | 19 +++++++------------ 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e450d718f..91f690c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,21 +1,16 @@ # Changelog -## [3.10.2] (2026-08-31) - -### Added -* Restored the Run Once action for single-use snippets and added a confirmation step before executing a snippet once. -### Changed -* Improved overall snippet stability by tightening validation and version-scoped cache handling, preventing errors during activation and after plugin downgrades. +## [3.10.2] (2026-08-31) ### Fixed -* Fixed a fatal error in safe mode when wp_get_current_user() was undefined. -* Fixed bulk activation incorrectly validating PHP for all snippets instead of only the snippets being activated. -* Fixed snippet saving failing after the session expired. -* Fixed warnings caused by aliased field names when reading modified fields. -* Fixed cached data not being scoped to the plugin version, preventing downgrade-related fatal errors. -* Restored the Run Once action for single-use snippets and hardened the execution handler against invalid states. +* Fixed a critical error when loading a site with Safe Mode active. +* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. +* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. +* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. +* Fixed snippet saving stopping after the WordPress login session expired. +* Fixed a PHP warning when reading snippet fields referenced by an alias. ## [3.10.1] (2026-08-28) diff --git a/src/readme.txt b/src/readme.txt index 58ab150c1..9e9f35b2e 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -107,21 +107,16 @@ You can report security bugs found in the source code of this plugin through the == Changelog == -= 3.10.2 (2026-08-31) = - -**Added** -* Restored the Run Once action for single-use snippets and added a confirmation step before executing a snippet once. -**Changed** -* Improved overall snippet stability by tightening validation and version-scoped cache handling, preventing errors during activation and after plugin downgrades. += 3.10.2 (2026-08-31) = **Fixed** -* Fixed a fatal error in safe mode when wp_get_current_user() was undefined. -* Fixed bulk activation incorrectly validating PHP for all snippets instead of only the snippets being activated. -* Fixed snippet saving failing after the session expired. -* Fixed warnings caused by aliased field names when reading modified fields. -* Fixed cached data not being scoped to the plugin version, preventing downgrade-related fatal errors. -* Restored the Run Once action for single-use snippets and hardened the execution handler against invalid states. +* Fixed a critical error when loading a site with Safe Mode active. +* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. +* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. +* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. +* Fixed snippet saving stopping after the WordPress login session expired. +* Fixed a PHP warning when reading snippet fields referenced by an alias. = 3.10.1 (2026-08-28) = From 2b2ade1713534f0d56fafd7b373d2d4d57bdc2c3 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 1 Sep 2026 11:16:17 +0100 Subject: [PATCH 15/23] fix: strip unprefixed vendor namespaces from the autoloader (#505) --- src/php/Core/load.php | 4 +- tests/unit/Core/Autoloader_Prefixes_Test.php | 68 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Core/Autoloader_Prefixes_Test.php diff --git a/src/php/Core/load.php b/src/php/Core/load.php index aa6c01083..3ad4b6034 100644 --- a/src/php/Core/load.php +++ b/src/php/Core/load.php @@ -74,7 +74,9 @@ if ( $autoloader instanceof ClassLoader ) { $vendor_prefix = __NAMESPACE__ . '\\Vendor\\'; - foreach ( $autoloader->getPrefixesPsr4() as $namespace => $paths ) { + $prefixes = $autoloader->getPrefixesPsr4(); + + foreach ( $prefixes as $namespace => $paths ) { // Remove any non-Code_Snippets namespace that has a corresponding prefixed version. if ( false === strpos( $namespace, $vendor_prefix ) ) { if ( isset( $prefixes[ $vendor_prefix . $namespace ] ) ) { diff --git a/tests/unit/Core/Autoloader_Prefixes_Test.php b/tests/unit/Core/Autoloader_Prefixes_Test.php new file mode 100644 index 000000000..84be398e9 --- /dev/null +++ b/tests/unit/Core/Autoloader_Prefixes_Test.php @@ -0,0 +1,68 @@ +getPrefixesPsr4(); + + if ( isset( $prefixes['Code_Snippets\\'] ) ) { + return $callback[0]; + } + } + } + + return null; + } + + /** + * Vendor packages are prefixed by Imposter, but Composer still registers the + * original namespace against the same directory. Left in place, our autoloader + * answers for the unprefixed name, includes the prefixed file a second time, + * and PHP raises "cannot declare interface, because the name is already in use" + * on any site running another plugin that bundles the same library. + * + * @return void + */ + public function test_no_unprefixed_vendor_namespace_remains_registered(): void { + $autoloader = $this->get_plugin_autoloader(); + + if ( ! $autoloader ) { + $this->markTestSkipped( 'The plugin Composer autoloader is not registered in this environment.' ); + } + + $vendor_prefix = 'Code_Snippets\\Vendor\\'; + $prefixes = $autoloader->getPrefixesPsr4(); + $leftovers = []; + + foreach ( array_keys( $prefixes ) as $namespace ) { + if ( false === strpos( $namespace, $vendor_prefix ) && + isset( $prefixes[ $vendor_prefix . $namespace ] ) && + ! empty( $prefixes[ $namespace ] ) ) { + $leftovers[] = $namespace; + } + } + + $this->assertSame( + [], + $leftovers, + 'Unprefixed vendor namespaces are still registered: ' . implode( ', ', $leftovers ) + ); + } +} From b977c7ed15fda821c8210d6e2c69c8d3a34abd4b Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 1 Sep 2026 13:09:09 +0100 Subject: [PATCH 16/23] fix: correct the nonce argument in the version switch AJAX handlers (3.10.2) (#506) --- src/php/Settings/Version_Switch.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/php/Settings/Version_Switch.php b/src/php/Settings/Version_Switch.php index e2588cfe4..786a14a4c 100644 --- a/src/php/Settings/Version_Switch.php +++ b/src/php/Settings/Version_Switch.php @@ -423,7 +423,7 @@ public static function render_version_switch_field(): void { * @return void */ public static function ajax_switch_version(): void { - check_ajax_referer( 'code_snippets_version_switch', sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ) ); + check_ajax_referer( 'code_snippets_version_switch', 'nonce' ); if ( ! current_user_can( 'update_plugins' ) ) { wp_send_json_error( [ 'message' => __( 'You do not have permission to update plugins.', 'code-snippets' ) ] ); @@ -467,7 +467,7 @@ public static function render_refresh_versions_field(): void { * @return void */ public static function ajax_refresh_versions(): void { - check_ajax_referer( 'code_snippets_refresh_versions', sanitize_text_field( wp_unslash( $_POST['nonce'] ?? '' ) ) ); + check_ajax_referer( 'code_snippets_refresh_versions', 'nonce' ); if ( ! code_snippets()->current_user_can() ) { wp_send_json_error( [ 'message' => __( 'You do not have permission to manage options.', 'code-snippets' ) ] ); From 419a2fc608a5bd0ce39f4a7376c8699f1bcdb2cd Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Tue, 1 Sep 2026 13:09:26 +0100 Subject: [PATCH 17/23] fix: apply the row truncation Screen Option to snippet names (3.10.2) (#507) --- src/css/common/list-table/_layout.scss | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/css/common/list-table/_layout.scss b/src/css/common/list-table/_layout.scss index 713742696..b216ad90e 100644 --- a/src/css/common/list-table/_layout.scss +++ b/src/css/common/list-table/_layout.scss @@ -278,9 +278,11 @@ text-align: start; } - // Snippet names always stay on a single line, truncating gracefully; - // the full name is exposed through the title attribute. - td.column-name > .snippet-name { + // Names and descriptions are both governed by the "Truncate long snippet + // names and descriptions" Screen Option. When it is off, the full name is + // shown; when on, it stays on a single line and the full value remains + // available through the title attribute. + &.truncate-row-values td.column-name > .snippet-name { display: block; max-inline-size: min(15rem, 30vw); white-space: nowrap; From ad76f5f5bd33fbfc698d6667296fa5b15385ecf3 Mon Sep 17 00:00:00 2001 From: code-snippets-bot <139164393+code-snippets-bot@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:12:13 +0000 Subject: [PATCH 18/23] chore(release): regenerate changelog for v3.10.2 --- CHANGELOG.md | 22 +++++++++++++++------- src/readme.txt | 22 +++++++++++++++------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f690c2b..0dc52e2be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,23 @@ -## [3.10.2] (2026-08-31) + +## [3.10.2] (2026-09-01) + +### Added +* Added a confirmation flow for run-once snippet execution and hardened the handler to prevent failed or confusing actions. + +### Changed +* Snippet names now respect the row truncation Screen Option in the admin list for better readability. +* Version switching AJAX requests now validate the correct nonce, improving reliability when updating snippet versions. ### Fixed -* Fixed a critical error when loading a site with Safe Mode active. -* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. -* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. -* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. -* Fixed snippet saving stopping after the WordPress login session expired. -* Fixed a PHP warning when reading snippet fields referenced by an alias. +* Fixed safe mode fatal errors caused by an undefined wp_get_current_user() call. +* Fixed PHP validation being triggered incorrectly when activating snippets in bulk. +* Fixed saving issues after a user session expires. +* Fixed warnings caused by aliased field names when reading modified snippet fields. +* Fixed vendor autoloader issues by stripping unprefixed namespace references. +* Resolved issues where snippet names and version switches could behave inconsistently in the admin UI. ## [3.10.1] (2026-08-28) diff --git a/src/readme.txt b/src/readme.txt index 9e9f35b2e..000c9681d 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -108,15 +108,23 @@ You can report security bugs found in the source code of this plugin through the -= 3.10.2 (2026-08-31) = + += 3.10.2 (2026-09-01) = + +**Added** +* Added a confirmation flow for run-once snippet execution and hardened the handler to prevent failed or confusing actions. + +**Changed** +* Snippet names now respect the row truncation Screen Option in the admin list for better readability. +* Version switching AJAX requests now validate the correct nonce, improving reliability when updating snippet versions. **Fixed** -* Fixed a critical error when loading a site with Safe Mode active. -* Fixed a fatal error after downgrading the plugin, caused by cached data saved by a newer version. -* Fixed the Run Once action for single-use snippets, which had stopped running the snippet since 3.10.0, and restored its confirmation message. -* Fixed activating several snippets at once when two of them declare the same function, which could take the site down. -* Fixed snippet saving stopping after the WordPress login session expired. -* Fixed a PHP warning when reading snippet fields referenced by an alias. +* Fixed safe mode fatal errors caused by an undefined wp_get_current_user() call. +* Fixed PHP validation being triggered incorrectly when activating snippets in bulk. +* Fixed saving issues after a user session expires. +* Fixed warnings caused by aliased field names when reading modified snippet fields. +* Fixed vendor autoloader issues by stripping unprefixed namespace references. +* Resolved issues where snippet names and version switches could behave inconsistently in the admin UI. = 3.10.1 (2026-08-28) = From 5ea86b2dad25553b4dfcd12349c2462cf046352f Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 19:56:10 +0300 Subject: [PATCH 19/23] docs: normalise 3.10.2 changelog and readme formatting Collapse the redundant leading blank lines and normalise the readme changelog labels to the __Type__ form. Runs the repo changelog and readme linters, which the release-branch generation did not apply. --- CHANGELOG.md | 3 --- src/readme.txt | 12 ++++++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dc52e2be..f947b9eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,5 @@ # Changelog - - - ## [3.10.2] (2026-09-01) ### Added diff --git a/src/readme.txt b/src/readme.txt index 000c9681d..ed504b943 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -106,19 +106,19 @@ You can report security bugs found in the source code of this plugin through the == Changelog == - - - = 3.10.2 (2026-09-01) = -**Added** +__Added__ + * Added a confirmation flow for run-once snippet execution and hardened the handler to prevent failed or confusing actions. -**Changed** +__Changed__ + * Snippet names now respect the row truncation Screen Option in the admin list for better readability. * Version switching AJAX requests now validate the correct nonce, improving reliability when updating snippet versions. -**Fixed** +__Fixed__ + * Fixed safe mode fatal errors caused by an undefined wp_get_current_user() call. * Fixed PHP validation being triggered incorrectly when activating snippets in bulk. * Fixed saving issues after a user session expires. From af4e34e9460143548e3315cf63b8edd6ef619437 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 19:56:47 +0300 Subject: [PATCH 20/23] ci: pass the release branch through env to prevent shell injection The branch name (github.event.pull_request.head.ref) was interpolated directly into the gh command, so a crafted branch name could run on the runner. Move it and the other inputs to env and reference them quoted. --- .github/workflows/prepare-release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d2b49fe1a..5e2aa2e81 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -112,13 +112,17 @@ jobs: esac - name: Dispatch changelog generation + env: + REPO: ${{ github.repository }} + BRANCH: ${{ github.event.pull_request.head.ref }} + PR_NUMBER: ${{ github.event.pull_request.number }} run: | gh workflow run changelog.yml \ --repo codesnippetspro/.github-private \ --ref main \ --field mode=generate \ - --field repo="${{ github.repository }}" \ - --field branch="${{ github.event.pull_request.head.ref }}" \ - --field pr_number="${{ github.event.pull_request.number }}" \ + --field repo="$REPO" \ + --field branch="$BRANCH" \ + --field pr_number="$PR_NUMBER" \ --field readme_path="./src/readme.txt" - echo "::notice::Dispatched changelog generation for PR #${{ github.event.pull_request.number }}" + echo "::notice::Dispatched changelog generation for PR #$PR_NUMBER" From 1da40c20b33eafa27e80d435594a7f2ba4e59f16 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 19:59:21 +0300 Subject: [PATCH 21/23] fix: flush versioned snippet cache on complete uninstall Deleting the cache-version option left persistent cache objects in place, so a same-version reinstall could read snippets a complete uninstall had removed. Flush the recorded version's cache groups (current, previous and legacy) before dropping the option. --- src/php/Core/Uninstaller.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index 877411b58..8c8cf3983 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -67,6 +67,10 @@ private function uninstall_current_site() { $wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}snippets" ); delete_option( 'code_snippets_version' ); + + // Shed cached snippet objects before dropping the recorded cache version, + // otherwise a same-version reinstall can read data this uninstall removed. + \Code_Snippets\flush_versioned_cache_groups( (string) get_option( 'code_snippets_cache_version', '' ) ); delete_option( 'code_snippets_cache_version' ); delete_option( 'recently_active_snippets' ); delete_option( 'recently_activated_snippets' ); From badcd3688afbd738158be0f26aac6ced17ebd822 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 19:59:21 +0300 Subject: [PATCH 22/23] fix: clear all versioned cache groups on a snippet cache reset The reset only flushed the running version's group, so a rollback could still read stale cached snippets from the previous or legacy groups. Flush the recorded version's groups instead of only the current one. --- src/php/Settings/settings.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index e90a29c6d..7ad4684ab 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -11,7 +11,7 @@ use Code_Snippets\Controller\Cloud_Search_Controller; use function add_action; use function Code_Snippets\clean_snippets_cache; -use function Code_Snippets\flush_cache_group; +use function Code_Snippets\flush_versioned_cache_groups; use function Code_Snippets\code_snippets; use function Code_Snippets\Utils\add_self_option; use function Code_Snippets\Utils\get_self_option; @@ -315,8 +315,8 @@ function process_settings_actions( array $input ): ?array { // Deleting known keys cannot reach data written by a different version // of the plugin, which is what needs clearing before a rollback, so the - // whole group goes too. - flush_cache_group( CACHE_GROUP ); + // versioned groups (current, previous and legacy) all go too. + flush_versioned_cache_groups( (string) get_option( 'code_snippets_cache_version', '' ) ); add_settings_error( OPTION_NAME, From 847667c536b14ab2c14da4b9057db552193f54b7 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 1 Sep 2026 20:01:36 +0300 Subject: [PATCH 23/23] fix: do not report a run-once snippet as executed in safe mode Safe mode skips snippet execution, but the run-once handler still activated an inactive snippet and redirected with result=executed, showing a false success and leaving the snippet active. Short-circuit when safe mode is active and show a distinct notice instead. --- src/js/components/ManageMenu/ManageMenu.tsx | 6 ++++++ src/php/Admin/Menus/Manage/Manage_Menu.php | 12 ++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx index 68903fd1d..462106379 100644 --- a/src/js/components/ManageMenu/ManageMenu.tsx +++ b/src/js/components/ManageMenu/ManageMenu.tsx @@ -38,6 +38,12 @@ const getNotice = (result: string): { text: string, type: NoticeType } | undefin type: 'error' } + case 'run-once-safe-mode': + return { + text: __('Safe mode is active, so the snippet was not run.', 'code-snippets'), + type: 'warning' + } + default: return undefined } diff --git a/src/php/Admin/Menus/Manage/Manage_Menu.php b/src/php/Admin/Menus/Manage/Manage_Menu.php index 93cd46162..59458a2ea 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu.php @@ -230,6 +230,18 @@ private function handle_run_once(): void { exit; } + // Safe mode skips execution, so activating here would leave the snippet on + // without ever running it, behind a false success notice. Report it instead. + if ( \Code_Snippets\Integration\Evaluate_Functions::is_safe_mode_active() ) { + wp_safe_redirect( + add_query_arg( + [ 'result' => 'run-once-safe-mode' ], + remove_query_arg( [ 'action', 'snippet', 'network', '_wpnonce', 'result' ] ) + ) + ); + exit; + } + // An already-active snippet has effectively run, so treat it as success. $result = $snippet->active ? $snippet : activate_snippet( $snippet_id, $network );