diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 78e175ffd..5e2aa2e81 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) @@ -110,12 +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 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 50a79f1cf..f947b9eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,37 @@ # Changelog +## [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 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) ### 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) 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/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; diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx index a74100c1a..462106379 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,25 @@ 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' + } + + case 'run-once-safe-mode': + return { + text: __('Safe mode is active, so the snippet was not run.', 'code-snippets'), + type: 'warning' + } default: return undefined @@ -35,20 +50,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/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/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 bc20a12be..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 @@ -72,6 +79,7 @@ declare global { cloudSearchPerPage: number isSafeModeActive: boolean bulkDownloadNonce: string + runOnceNonce?: string supportsZipDownloads: boolean editorTheme: 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 } } diff --git a/src/php/Admin/Menus/Manage/Manage_Menu.php b/src/php/Admin/Menus/Manage/Manage_Menu.php index ff2279ea8..59458a2ea 100644 --- a/src/php/Admin/Menus/Manage/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage/Manage_Menu.php @@ -5,7 +5,9 @@ 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\get_snippet; use function Code_Snippets\Settings\get_setting; use const Code_Snippets\PLUGIN_FILE; use const Code_Snippets\PLUGIN_VERSION; @@ -177,12 +179,88 @@ 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; + } + + // 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; + } + + // 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 ); + + wp_safe_redirect( + add_query_arg( + [ 'result' => is_string( $result ) ? 'run-once-failed' : 'executed' ], + remove_query_arg( [ 'action', 'snippet', 'network', '_wpnonce', 'result' ] ) + ) + ); + exit; + } + /** * 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(), diff --git a/src/php/Core/Uninstaller.php b/src/php/Core/Uninstaller.php index 7eff2f6a3..8c8cf3983 100644 --- a/src/php/Core/Uninstaller.php +++ b/src/php/Core/Uninstaller.php @@ -67,6 +67,11 @@ 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' ); 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..3ad4b6034 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. @@ -59,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/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/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/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/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' ) ] ); diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 8a9a9901a..7ad4684ab 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_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; @@ -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 + // 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, 'snippet_caches_reset', 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 e58642919..deae3755a 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. @@ -403,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/src/readme.txt b/src/readme.txt index 7daf39a13..ed504b943 100644 --- a/src/readme.txt +++ b/src/readme.txt @@ -1,12 +1,12 @@ === 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 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 +Tested up to: 7.1 Requires PHP: 7.4 An easy, clean, and simple way to enhance your site with code snippets. @@ -106,19 +106,43 @@ You can report security bugs found in the source code of this plugin through the == Changelog == += 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 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) = __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) = 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', 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 ) + ); + } +} 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 ); + } +} 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 ) ); + } +} 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'] ); + } } 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." + ); + } +}