PluginProbe
Code Snippets / 4.0.0-beta.3
Code Snippets v4.0.0-beta.3
4.0.0-beta.3 4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 All 66 releases
← All changes | php/snippet-ops.php +912 -254 2.13.2 → 4.0.0-beta.3 View file →
@@ -1,6 +1,5 @@
1 1 <?php
2 -
3 2 /**
4 3 * Functions to perform snippet operations
5 4 *
6 5 * @package Code_Snippets
@@ -5,171 +4,448 @@
5 4 *
6 5 * @package Code_Snippets
7 6 */
8 7
8 +namespace Code_Snippets;
9 +
10 +use Exception;
11 +use Code_Snippets\Model\Snippet;
12 +use Code_Snippets\Utils\Validator;
13 +use Throwable;
14 +use function Code_Snippets\Utils\get_self_option;
15 +use function Code_Snippets\Utils\validate_network_param;
16 +use function Code_Snippets\Utils\update_self_option;
17 +
9 18 /**
10 - * Retrieve a list of snippets from the database
19 + * Get the locked status for a snippet from wp_options.
11 20 *
12 - * @since 2.0
21 + * @param int $snippet_id Snippet ID.
22 + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false).
13 23 *
14 - * @uses $wpdb to query the database for snippets
15 - * @uses code_snippets()->db->get_table_name() to dynamically retrieve the snippet table name
24 + * @return bool Whether the snippet is locked.
25 + */
26 +function is_snippet_locked( int $snippet_id, ?bool $network = null ): bool {
27 + $network = validate_network_param( $network );
28 + $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
29 +
30 + return isset( $locked_snippets[ $snippet_id ] ) && $locked_snippets[ $snippet_id ];
31 +}
32 +
33 +/**
34 + * Set the locked status for a snippet in wp_options.
16 35 *
17 - * @param array $ids The IDs of the snippets to fetch
18 - * @param bool|null $multisite Retrieve multisite-wide or site-wide snippets?
36 + * @param int $snippet_id Snippet ID.
37 + * @param bool $locked Whether the snippet should be locked.
38 + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false).
19 39 *
20 - * @return array An array of Snippet objects
40 + * @return void
21 41 */
22 -function get_snippets( array $ids = array(), $multisite = null ) {
23 - /** @var wpdb $wpdb */
24 - global $wpdb;
42 +function set_snippet_locked( int $snippet_id, bool $locked, ?bool $network = null ): void {
43 + $network = validate_network_param( $network );
44 + $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
25 45
26 - $db = code_snippets()->db;
27 - $multisite = $db->validate_network_param( $multisite );
28 - $table = $db->get_table_name( $multisite );
46 + if ( $locked ) {
47 + $locked_snippets[ $snippet_id ] = true;
48 + } else {
49 + unset( $locked_snippets[ $snippet_id ] );
50 + }
29 51
30 - $ids_count = count( $ids );
52 + update_self_option( $network, 'code_snippets_locked', $locked_snippets );
53 +}
31 54
32 - /* If only one ID has been passed in, defer to the get_snippet() function */
33 - if ( 1 == $ids_count ) {
34 - return array( get_snippet( $ids[0] ) );
55 +/**
56 + * Clean the cache where active snippets are stored.
57 + *
58 + * @param string $table_name Snippets table name.
59 + * @param array<string>|false $scopes List of scopes. Optional. If not provided, will flush the cache for all scopes.
60 + *
61 + * @return void
62 + */
63 +function clean_active_snippets_cache( string $table_name, $scopes = false ) {
64 + $scope_groups = $scopes
65 + ? [ $scopes ]
66 + : [
67 + // Content snippets.
68 + [ 'head-content', 'body-content', 'footer-content' ],
69 +
70 + // Function snippets.
71 + [ 'global', 'single-use', 'front-end' ],
72 + [ 'global', 'single-use', 'admin' ],
73 + ];
74 +
75 + foreach ( $scope_groups as $scopes ) {
76 + wp_cache_delete( sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ), CACHE_GROUP );
35 77 }
78 +}
36 79
37 - /* Build a query containing the specified IDs if there are any */
38 - if ( $ids_count > 1 ) {
39 - $sql = sprintf(
40 - 'SELECT * FROM %s WHERE id IN (%s);',
41 - $table,
42 - implode( ',', array_fill( 0, $ids_count, '%d' ) )
43 - );
44 - $sql = $wpdb->prepare( $sql, $ids );
80 +/**
81 + * Flush all snippets caches for a given database table.
82 + *
83 + * @param string $table_name Snippets table name.
84 + *
85 + * @return void
86 + */
87 +function clean_snippets_cache( string $table_name ) {
88 + wp_cache_delete( "all_snippet_tags_$table_name", CACHE_GROUP );
89 + wp_cache_delete( "all_snippets_$table_name", CACHE_GROUP );
90 + clean_active_snippets_cache( $table_name );
91 +}
45 92
46 - } else {
47 - $sql = "SELECT * FROM $table;";
93 +/**
94 + * Flush an entire cache group, where the object cache supports it.
95 + *
96 + * Not all persistent cache drop-ins implement group flushing, and the function
97 + * itself only exists from WordPress 6.1, so both are checked before use. A
98 + * failure is not important: cache groups are scoped to the plugin version, so
99 + * flushing is housekeeping rather than something correctness depends on, and
100 + * anything left behind is evicted by the cache in its own time.
101 + *
102 + * @param string $group Cache group to flush.
103 + *
104 + * @return bool Whether the group was flushed.
105 + */
106 +function flush_cache_group( string $group ): bool {
107 + /**
108 + * Short-circuits flushing a cache group.
109 + *
110 + * Returning a boolean skips the object cache entirely: false makes the
111 + * caller fall back to deleting the known keys one by one, for a cache
112 + * that reports group support it does not really have.
113 + *
114 + * @param bool|null $flushed Whether the group was flushed, or null to let the cache try.
115 + * @param string $group Cache group.
116 + */
117 + $flushed = apply_filters( 'code_snippets/pre_flush_cache_group', null, $group );
118 +
119 + if ( null !== $flushed ) {
120 + return (bool) $flushed;
48 121 }
49 122
50 - /* Retrieve the results from the database */
51 - $snippets = $wpdb->get_results( $sql, ARRAY_A );
123 + if ( ! function_exists( 'wp_cache_flush_group' ) ||
124 + ! function_exists( 'wp_cache_supports' ) ||
125 + ! wp_cache_supports( 'flush_group' ) ) {
126 + return false;
127 + }
52 128
53 - /* Convert snippets to snippet objects */
54 - foreach ( $snippets as $index => $snippet ) {
55 - $snippet['network'] = $multisite;
56 - $snippets[ $index ] = new Code_Snippet( $snippet );
129 + return wp_cache_flush_group( $group );
130 +}
131 +
132 +/**
133 + * Flush the cache groups belonging to other versions of the plugin.
134 + *
135 + * @param string $previous_version Version the site was running beforehand.
136 + *
137 + * @return void
138 + */
139 +function flush_versioned_cache_groups( string $previous_version ): void {
140 + if ( '' !== $previous_version && PLUGIN_VERSION !== $previous_version ) {
141 + flush_cache_group( CACHE_GROUP_BASE . '_' . $previous_version );
57 142 }
58 143
59 - return apply_filters( 'code_snippets/get_snippets', $snippets, $multisite );
144 + // Versions before the group was scoped wrote to the unscoped group, and no
145 + // version that scopes it ever writes there again. Clearing it means a site
146 + // upgrading from 3.10.0 or 3.10.1 sheds the objects that would otherwise
147 + // still be waiting to break its next rollback.
148 + flush_cache_group( CACHE_GROUP_BASE );
149 +
150 + // Where the cache cannot flush a whole group, the keys this plugin writes
151 + // are deleted one by one instead, so an uninstall followed by a reinstall
152 + // of the same version cannot read snippets that no longer exist.
153 + if ( ! flush_cache_group( CACHE_GROUP ) ) {
154 + flush_known_cache_keys();
155 + }
60 156 }
61 157
62 158 /**
63 - * Gets all of the used tags from the database
159 + * Delete every key this plugin is known to write in its current cache group.
160 + *
161 + * @return void
162 + */
163 +function flush_known_cache_keys(): void {
164 + // Both tables' keys go, whether this is a network: deleting a key
165 + // that was never written does not cost anything, and it keeps one path to test.
166 + $tables = [ code_snippets()->db->get_table_name( false ), code_snippets()->db->get_table_name( true ) ];
167 +
168 + foreach ( array_unique( $tables ) as $table ) {
169 + clean_snippets_cache( $table );
170 + }
171 +
172 + wp_cache_delete( Settings\CACHE_KEY, CACHE_GROUP );
173 +}
174 +
175 +/**
176 + * Retrieve a list of snippets from the database.
177 + * Read operation.
178 + *
179 + * @param array<string> $ids The IDs of the snippets to fetch.
180 + * @param bool|null $network Retrieve multisite-wide snippets (true) or site-wide snippets (false).
181 + *
182 + * @return Snippet[] List of Snippet objects.
183 + *
64 184 * @since 2.0
65 185 */
186 +function get_snippets( array $ids = [], ?bool $network = null ): array {
187 + global $wpdb;
188 +
189 + // If only one ID has been passed in, defer to the get_snippet() function.
190 + $ids_count = count( $ids );
191 + if ( 1 === $ids_count ) {
192 + return [ get_snippet( $ids[0], $network ) ];
193 + }
194 +
195 + $network = validate_network_param( $network );
196 + $table_name = code_snippets()->db->get_table_name( $network );
197 +
198 + $snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
199 +
200 + // Fetch all snippets from the database if none are cached.
201 + if ( ! is_array( $snippets ) ) {
202 + $results = $wpdb->get_results( "SELECT * FROM $table_name", ARRAY_A );
203 +
204 + $snippets = $results
205 + ? array_map(
206 + function ( $snippet_data ) use ( $network ) {
207 + $snippet_data['network'] = $network;
208 + $snippet = new Snippet( $snippet_data );
209 + // Load locked from wp_options.
210 + if ( $snippet->id > 0 ) {
211 + $snippet->locked = is_snippet_locked( $snippet->id, $network );
212 + }
213 + return $snippet;
214 + },
215 + $results
216 + )
217 + : [];
218 +
219 + $snippets = apply_filters( 'code_snippets/get_snippets', $snippets, $network );
220 +
221 + if ( 0 === $ids_count ) {
222 + wp_cache_set( "all_snippets_$table_name", $snippets, CACHE_GROUP );
223 + }
224 + }
225 +
226 + // If a list of IDs are provided, narrow down the snippets list.
227 + if ( $ids_count > 0 ) {
228 + $ids = array_map( 'intval', $ids );
229 + return array_values(
230 + array_filter(
231 + $snippets,
232 + function ( Snippet $snippet ) use ( $ids ) {
233 + return in_array( $snippet->id, $ids, true );
234 + }
235 + )
236 + );
237 + }
238 +
239 + return $snippets;
240 +}
241 +
242 +/**
243 + * Gets all used tags from the database.
244 + * Read operation.
245 + *
246 + * @since 2.0
247 + */
66 248 function get_all_snippet_tags() {
67 - /** @var wpdb $wpdb */
68 249 global $wpdb;
250 + $table_name = code_snippets()->db->get_table_name();
251 + $cache_key = "all_snippet_tags_$table_name";
69 252
70 - /* Grab all tags from the database */
253 + $tags = wp_cache_get( $cache_key, CACHE_GROUP );
254 + if ( $tags ) {
255 + return $tags;
256 + }
257 +
258 + // Grab all tags from the database.
71 259 $tags = array();
72 - $table = code_snippets()->db->get_table_name();
73 - $all_tags = $wpdb->get_col( "SELECT `tags` FROM $table" );
260 + $all_tags = $wpdb->get_col( "SELECT tags FROM $table_name" );
74 261
75 - /* Merge all tags into a single array */
262 + // Merge all tags into a single array.
76 263 foreach ( $all_tags as $snippet_tags ) {
77 264 $snippet_tags = code_snippets_build_tags_array( $snippet_tags );
78 265 $tags = array_merge( $snippet_tags, $tags );
79 266 }
80 267
81 - /* Remove duplicate tags */
82 -
83 - return array_values( array_unique( $tags, SORT_REGULAR ) );
268 + // Remove duplicate tags.
269 + $tags = array_values( array_unique( $tags, SORT_REGULAR ) );
270 + wp_cache_set( $cache_key, $tags, CACHE_GROUP );
271 + return $tags;
84 272 }
85 273
86 274 /**
87 - * Make sure that the tags are a valid array
88 - * @since 2.0
275 + * Make sure that the tags are a valid array.
89 276 *
90 - * @param mixed $tags The tags to convert into an array
277 + * @param array|string $tags The tags to convert into an array.
91 278 *
92 - * @return array The converted tags
279 + * @return array<string> The converted tags.
280 + *
281 + * @since 2.0.0
93 282 */
94 -function code_snippets_build_tags_array( $tags ) {
283 +function code_snippets_build_tags_array( $tags ): array {
95 284
96 - /* If there are no tags set, return an empty array */
285 + /* If there are no tags set, return an empty array. */
97 286 if ( empty( $tags ) ) {
98 287 return array();
99 288 }
100 289
101 - /* If the tags are set as a string, convert them into an array */
290 + /* If the tags are set as a string, convert them into an array. */
102 291 if ( is_string( $tags ) ) {
103 - $tags = strip_tags( $tags );
292 + $tags = wp_strip_all_tags( $tags );
104 293 $tags = str_replace( ', ', ',', $tags );
105 294 $tags = explode( ',', $tags );
106 295 }
107 296
108 - /* If we still don't have an array, just convert whatever we do have into one */
109 -
297 + /* If we still don't have an array, just convert whatever we do have into one. */
110 298 return (array) $tags;
111 299 }
112 300
113 301 /**
114 302 * Retrieve a single snippets from the database.
115 - * Will return empty snippet object if no snippet
116 - * ID is specified
303 + * Will return empty snippet object if no snippet ID is specified.
304 + * Read operation.
117 305 *
118 - * @since 2.0
306 + * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet.
307 + * @param bool|null $network Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
119 308 *
120 - * @uses $wpdb to query the database for snippets
121 - * @uses code_snippets()->db->get_table_name() to dynamically retrieve the snippet table name
309 + * @return ?Snippet A single snippet object.
122 310 *
123 - * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet
124 - * @param boolean|null $multisite Retrieve a multisite-wide or site-wide snippet?
125 - *
126 - * @return Code_Snippet A single snippet object
311 + * @since 2.0.0
127 312 */
128 -function get_snippet( $id = 0, $multisite = null ) {
129 - /** @var wpdb $wpdb */
313 +function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
130 314 global $wpdb;
131 315
132 316 $id = absint( $id );
133 - $table = code_snippets()->db->get_table_name( $multisite );
317 + $network = validate_network_param( $network );
318 + $table_name = code_snippets()->db->get_table_name( $network );
134 319
135 - if ( 0 !== $id ) {
320 + if ( 0 === $id ) {
321 + // If an invalid ID is provided, then return an empty snippet object.
322 + $snippet = new Snippet();
136 323
137 - /* Retrieve the snippet from the database */
138 - $snippet = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ) );
324 + } else {
325 + $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
139 326
140 - /* Unescape the snippet data, ready for use */
141 - $snippet = new Code_Snippet( $snippet );
327 + // Attempt to fetch snippet from the cached list, if it exists.
328 + if ( is_array( $cached_snippets ) ) {
329 + foreach ( $cached_snippets as $snippet ) {
330 + if ( $snippet->id === $id ) {
331 + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network );
332 + }
333 + }
334 + }
142 335
143 - } else {
336 + // Otherwise, retrieve the snippet from the database.
337 + // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
338 + $snippet_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE id = %d", $id ) );
339 + $snippet = new Snippet( $snippet_data );
340 + }
144 341
145 - /* Get an empty snippet object */
146 - $snippet = new Code_Snippet();
342 + $snippet->network = $network;
343 +
344 + // Load locked from wp_options if snippet has an ID.
345 + if ( $snippet->id > 0 ) {
346 + $snippet->locked = is_snippet_locked( $snippet->id, $network );
147 347 }
148 348
149 - $snippet->network = $multisite;
349 + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network );
350 +}
150 351
151 - return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $multisite );
352 +
353 +/**
354 + * Ensure the list of shared network snippets is correct if one has been recently active or deactivated.
355 + * Write operation.
356 + *
357 + * @access private
358 + *
359 + * @param Snippet[] $snippets Snippets that was recently updated.
360 + *
361 + * @return bool Whether an update was performed.
362 + */
363 +function update_shared_network_snippets( array $snippets ): bool {
364 + $shared_ids = [];
365 + $unshared_ids = [];
366 +
367 + if ( ! is_multisite() ) {
368 + return false;
369 + }
370 +
371 + foreach ( $snippets as $snippet ) {
372 + if ( $snippet->network ) {
373 + if ( $snippet->shared_network ) {
374 + $shared_ids[] = $snippet->id;
375 + } else {
376 + $unshared_ids[] = $snippet->id;
377 + }
378 + }
379 + }
380 +
381 + if ( ! $shared_ids && ! $unshared_ids ) {
382 + return false;
383 + }
384 +
385 + $existing_shared_ids = get_site_option( 'shared_network_snippets', [] );
386 + $updated_shared_ids = array_values( array_diff( array_merge( $existing_shared_ids, $shared_ids ), $unshared_ids ) );
387 +
388 + if ( $existing_shared_ids === $updated_shared_ids ) {
389 + return false;
390 + }
391 +
392 + update_site_option( 'shared_network_snippets', $updated_shared_ids );
393 +
394 + // Deactivate the snippet on all sites if necessary.
395 + if ( $unshared_ids ) {
396 + $sites = get_sites( [ 'fields' => 'ids' ] );
397 +
398 + foreach ( $sites as $site ) {
399 + switch_to_blog( $site );
400 + $active_shared_ids = get_option( 'active_shared_network_snippets' );
401 +
402 + if ( is_array( $active_shared_ids ) ) {
403 + $active_shared_ids = array_diff( $active_shared_ids, $unshared_ids );
404 + update_option( 'active_shared_network_snippets', $active_shared_ids );
405 + }
406 +
407 + clean_active_snippets_cache( code_snippets()->db->ms_table );
408 + }
409 +
410 + restore_current_blog();
411 + }
412 +
413 + return true;
152 414 }
153 415
154 416 /**
155 - * Activates a snippet
417 + * Activates a snippet.
418 + * Write operation.
156 419 *
157 - * @since 2.0
420 + * @param int $id ID of the snippet to activate.
421 + * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
158 422 *
159 - * @uses $wpdb to set the snippet's active status
160 - *
161 - * @param int $id The ID of the snippet to activate
162 - * @param bool|null $multisite Are the snippets multisite-wide or site-wide?
423 + * @return Snippet|string Snippet object on success, error message on failure.
424 + * @since 2.0.0
163 425 */
164 -function activate_snippet( $id, $multisite = null ) {
165 - /** @var wpdb $wpdb */
426 +function activate_snippet( int $id, ?bool $network = null ) {
166 427 global $wpdb;
167 - $db = code_snippets()->db;
168 - $table = $db->get_table_name( $multisite );
428 + $network = validate_network_param( $network );
429 + $table_name = code_snippets()->db->get_table_name( $network );
169 430
170 - $wpdb->update(
171 - $table,
431 + // Retrieve the snippet code from the database for validation before activating.
432 + $snippet = get_snippet( $id, $network );
433 +
434 + if ( 0 === $snippet->id ) {
435 + // translators: %d: snippet identifier.
436 + return sprintf( __( 'Could not locate snippet with ID %d.', 'code-snippets' ), $id );
437 + }
438 +
439 + if ( 'php' === $snippet->type ) {
440 + $validator = new Validator( $snippet->code );
441 + if ( $validator->validate() ) {
442 + return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' );
443 + }
444 + }
445 +
446 + $result = $wpdb->update(
447 + $table_name,
172 448 array( 'active' => '1' ),
173 449 array( 'id' => $id ),
174 450 array( '%d' ),
175 451 array( '%d' )
@@ -174,36 +450,115 @@
174 450 array( '%d' ),
175 451 array( '%d' )
176 452 );
177 453
178 - /* Remove snippet from shared network snippet list if it was Network Activated */
179 - if ( $table === $db && $shared_network_snippets = get_site_option( 'shared_network_snippets', false ) ) {
180 - $shared_network_snippets = array_diff( $shared_network_snippets, array( $id ) );
181 - update_site_option( 'shared_network_snippets', $shared_network_snippets );
454 + if ( ! $result ) {
455 + return __( 'Could not activate snippet.', 'code-snippets' );
182 456 }
183 457
184 - do_action( 'code_snippets/activate_snippet', $id, $multisite );
458 + // Read back over a cleared cache, so what follows sees the snippet as
459 + // active rather than as it was fetched for validation above.
460 + clean_snippets_cache( $table_name );
461 + $snippet = get_snippet( $id, $network );
462 +
463 + update_shared_network_snippets( [ $snippet ] );
464 + do_action( 'code_snippets/activate_snippet', $snippet, $network );
465 + clean_snippets_cache( $table_name );
466 + return $snippet;
185 467 }
186 468
187 469 /**
188 - * Deactivate a snippet
470 + * Activates multiple snippets.
471 + * Write operation.
189 472 *
190 - * @since 2.0
473 + * @param array<int> $ids The IDs of the snippets to activate.
474 + * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
191 475 *
192 - * @uses $wpdb to set the snippets' active status
476 + * @return Snippet[]|null Snippets which were successfully activated, or null on failure.
193 477 *
194 - * @param int $id The ID of the snippet to deactivate
195 - * @param bool|null $multisite Are the snippets multisite-wide or site-wide?
478 + * @since 2.0.0
196 479 */
197 -function deactivate_snippet( $id, $multisite = null ) {
198 - /** @var wpdb $wpdb */
480 +function activate_snippets( array $ids, ?bool $network = null ): ?array {
199 481 global $wpdb;
200 - $db = code_snippets()->db;
201 - $table = $db->get_table_name( $multisite );
482 + $network = validate_network_param( $network );
483 + $table_name = code_snippets()->db->get_table_name( $network );
202 484
203 - /* Set the snippet to active */
485 + $snippets = get_snippets( $ids, $network );
204 486
205 - $wpdb->update(
487 + if ( ! $snippets ) {
488 + return null;
489 + }
490 +
491 + // Loop through each snippet code and validate individually.
492 + $valid_ids = [];
493 + $valid_snippets = [];
494 +
495 + // Names claimed by snippets already accepted into this batch. A snippet is
496 + // otherwise validated only against what PHP has declared so far, which does
497 + // not include the other snippets about to be activated alongside it.
498 + $claimed_identifiers = [];
499 +
500 + foreach ( $snippets as $snippet ) {
501 + // Only PHP is validated. The validator looks for redeclarations of
502 + // existing PHP functions and classes, which says nothing meaningful
503 + // about CSS or JavaScript.
504 + if ( 'php' !== $snippet->type ) {
505 + $valid_ids[] = $snippet->id;
506 + $valid_snippets[] = $snippet;
507 + continue;
508 + }
509 +
510 + $validator = new Validator( $snippet->code, $claimed_identifiers );
511 + $code_error = $validator->validate();
512 +
513 + if ( ! $code_error ) {
514 + $claimed_identifiers = $validator->get_claimed_identifiers();
515 + $valid_ids[] = $snippet->id;
516 + $valid_snippets[] = $snippet;
517 + }
518 + }
519 +
520 + // If there are no valid snippets, then we're done.
521 + if ( ! $valid_ids ) {
522 + return null;
523 + }
524 +
525 + // Build a SQL query containing all IDs, as wpdb::update does not support OR conditionals.
526 + $ids_format = implode( ',', array_fill( 0, count( $valid_ids ), '%d' ) );
527 +
528 + // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
529 + $rows_updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET active = 1 WHERE id IN ($ids_format)", $valid_ids ) );
530 +
531 + if ( ! $rows_updated ) {
532 + return null;
533 + }
534 +
535 + clean_snippets_cache( $table_name );
536 +
537 + update_shared_network_snippets( $valid_snippets );
538 + do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name );
539 + clean_snippets_cache( $table_name );
540 + return $valid_ids;
541 +}
542 +
543 +/**
544 + * Deactivate a snippet.
545 + * Write operation.
546 + *
547 + * @param int $id ID of the snippet to deactivate.
548 + * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
549 + *
550 + * @return Snippet|null Snippet that was deactivated on success, or null on failure.
551 + *
552 + * @since 2.0.0
553 + */
554 +function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
555 + global $wpdb;
556 + $network = validate_network_param( $network );
557 + $table = code_snippets()->db->get_table_name( $network );
558 +
559 + // Set the snippet to inactive.
560 + $result = $wpdb->update(
206 561 $table,
207 562 array( 'active' => '0' ),
208 563 array( 'id' => $id ),
209 564 array( '%d' ),
@@ -209,248 +564,551 @@
209 564 array( '%d' ),
210 565 array( '%d' )
211 566 );
212 567
213 - /* Update the recently active list */
568 + if ( ! $result ) {
569 + return null;
570 + }
214 571
215 - $recently_active = array( $id => time() );
572 + // Read back over a cleared cache, so the snippet is seen as inactive by
573 + // everything below rather than as it stood before the write.
574 + clean_snippets_cache( $table );
575 + $snippet = get_snippet( $id, $network );
216 576
217 - if ( $table === $db->table ) {
577 + // Update the recently active list.
578 + $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
579 + $recently_active[ $id ] = time();
580 + update_self_option( $network, 'recently_active_snippets', $recently_active );
218 581
219 - update_option(
220 - 'recently_activated_snippets',
221 - $recently_active + (array) get_option( 'recently_activated_snippets', array() )
222 - );
582 + update_shared_network_snippets( [ $snippet ] );
583 + do_action( 'code_snippets/deactivate_snippet', $id, $network );
584 + clean_snippets_cache( $table );
223 585
224 - } elseif ( $table === $db->ms_table ) {
225 -
226 - update_site_option(
227 - 'recently_activated_snippets',
228 - $recently_active + (array) get_site_option( 'recently_activated_snippets', array() )
229 - );
230 - }
231 -
232 - do_action( 'code_snippets/deactivate_snippet', $id, $multisite );
586 + return $snippet;
233 587 }
234 588
235 589 /**
236 - * Deletes a snippet from the database
590 + * Deletes a snippet from the database.
591 + * Write operation.
237 592 *
238 - * @since 2.0
239 - * @uses $wpdb to access the database
240 - * @uses code_snippets()->db->get_table_name() to dynamically retrieve the name of the snippet table
593 + * @param int $id ID of the snippet to delete.
594 + * @param bool|null $network Delete from network-wide (true) or site-wide (false) table.
241 595 *
242 - * @param int $id The ID of the snippet to delete
243 - * @param bool|null $multisite Delete from site-wide or network-wide table?
596 + * @return bool Whether the snippet was deleted successfully.
597 + *
598 + * @since 2.0.0
244 599 */
245 -function delete_snippet( $id, $multisite = null ) {
246 - /** @var wpdb $wpdb */
600 +function delete_snippet( int $id, ?bool $network = null ): bool {
247 601 global $wpdb;
602 + $network = validate_network_param( $network );
603 + $table = code_snippets()->db->get_table_name( $network );
248 604
249 - $wpdb->delete(
250 - code_snippets()->db->get_table_name( $multisite ),
605 + $snippet = get_snippet( $id, $network );
606 +
607 + // Prevent deletion of locked snippets.
608 + if ( $snippet->locked ) {
609 + return false;
610 + }
611 +
612 + $result = $wpdb->delete(
613 + $table,
251 614 array( 'id' => $id ),
252 615 array( '%d' )
253 616 );
254 617
255 - do_action( 'code_snippets/delete_snippet', $id, $multisite );
618 + if ( $result ) {
619 + clean_snippets_cache( $table );
620 + do_action( 'code_snippets/delete_snippet', $snippet, $network );
621 +
622 + $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
623 +
624 + if ( isset( $recently_active[ $id ] ) ) {
625 + unset( $recently_active[ $id ] );
626 + update_self_option( $network, 'recently_active_snippets', $recently_active );
627 + }
628 + }
629 +
630 + return (bool) $result;
256 631 }
257 632
258 633 /**
259 - * Saves a snippet to the database.
634 + * Trashes a snippet from the database.
635 + * Write operation.
260 636 *
261 - * @since 2.0
637 + * @param int $id ID of the snippet to trash.
638 + * @param bool|null $network Trash from network-wide (true) or site-wide (false) table.
262 639 *
263 - * @uses $wpdb to update/add the snippet to the database
264 - * @uses code_snippets()->db->get_table_name() To dynamically retrieve the name of the snippet table
640 + * @return bool Whether the snippet was trashed successfully.
265 641 *
266 - * @param Code_Snippet $snippet The snippet to add/update to the database
642 + * @since 3.8.0
643 + */
644 +function trash_snippet( int $id, ?bool $network = null ): bool {
645 + global $wpdb;
646 + $network = validate_network_param( $network );
647 + $table = code_snippets()->db->get_table_name( $network );
648 +
649 + $snippet = get_snippet( $id, $network );
650 +
651 + // Prevent trashing of locked snippets.
652 + if ( $snippet->locked ) {
653 + return false;
654 + }
655 +
656 + $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] );
657 +
658 + clean_snippets_cache( $table );
659 + do_action( 'code_snippets/trash_snippet', $snippet, $network );
660 +
661 + return true;
662 +}
663 +
664 +/**
665 + * Restore a trashed snippet by setting its active status back to 0 (inactive).
666 + * Write operation.
267 667 *
268 - * @return int The ID of the snippet
668 + * @param int $id Snippet ID to restore.
669 + * @param bool|null $network Whether the snippet is multisite-wide (true) or site-wide (false).
670 + *
671 + * @return bool Whether the restore was successful.
672 + *
673 + * @since 3.8.0
269 674 */
270 -function save_snippet( Code_Snippet $snippet ) {
271 - /** @var wpdb $wpdb */
675 +function restore_snippet( int $id, ?bool $network = null ): bool {
272 676 global $wpdb;
677 + $network = validate_network_param( $network );
678 + $table = code_snippets()->db->get_table_name( $network );
273 679
274 - $table = code_snippets()->db->get_table_name( $snippet->network );
680 + $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
275 681
276 - /* Build array of data to insert */
277 - $data = array(
278 - 'name' => $snippet->name,
279 - 'description' => $snippet->desc,
280 - 'code' => $snippet->code,
281 - 'tags' => $snippet->tags_list,
282 - 'scope' => $snippet->scope,
283 - 'priority' => $snippet->priority,
284 - 'active' => intval( $snippet->active ),
285 - );
682 + if ( $result ) {
683 + clean_snippets_cache( $table );
684 + do_action( 'code_snippets/restore_snippet', $id, $network );
685 + }
286 686
287 - /* Create a new snippet if the ID is not set */
288 - if ( 0 == $snippet->id ) {
687 + return (bool) $result;
688 +}
289 689
290 - $wpdb->insert( $table, $data, '%s' );
291 - $snippet->id = $wpdb->insert_id;
690 +/**
691 + * Test snippet code for errors, augmenting the snippet object.
692 + *
693 + * @param Snippet $snippet Snippet object.
694 + */
695 +function test_snippet_code( Snippet $snippet ) {
696 + $snippet->code_error = null;
697 + $snippet->code_error_trace = null;
292 698
293 - do_action( 'code_snippets/create_snippet', $snippet->id, $table );
294 - } else {
699 + if ( 'php' !== $snippet->type ) {
700 + return;
701 + }
295 702
296 - /* Otherwise update the snippet data */
297 - $wpdb->update( $table, $data, array( 'id' => $snippet->id ), null, array( '%d' ) );
703 + $validator = new Validator( $snippet->code );
704 + $result = $validator->validate();
298 705
299 - do_action( 'code_snippets/update_snippet', $snippet->id, $table );
706 + if ( $result ) {
707 + $snippet->code_error = [ $result['message'], $result['line'] ];
708 + $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
300 709 }
301 710
302 - return $snippet->id;
711 + if ( ! $snippet->code_error && 'single-use' !== $snippet->scope ) {
712 + $result = execute_snippet( $snippet->code, $snippet->id, true );
713 +
714 + if ( $result instanceof Throwable ) {
715 + $snippet->code_error = [
716 + ucfirst( rtrim( $result->getMessage(), '.' ) ) . '.',
717 + $result->getLine(),
718 + ];
719 + $snippet->code_error_trace = $result->getTraceAsString();
720 + }
721 + }
303 722 }
304 723
305 724 /**
306 - * Update a snippet entry given a list of fields
725 + * Saves a snippet to the database.
726 + * Write operation.
307 727 *
308 - * @param int $snippet_id The ID of the snippet to update
309 - * @param array $fields An array of fields mapped to their values
310 - * @param bool $network Whether the snippet is network-wide or site-wide
728 + * @param Snippet|array<string, mixed> $snippet The snippet to add/update to the database.
729 + *
730 + * @return Snippet|null Updated snippet.
731 + *
732 + * @since 2.0.0
311 733 */
312 -function update_snippet_fields( $snippet_id, $fields, $network = null ) {
313 - /** @var wpdb $wpdb */
734 +function save_snippet( $snippet ): ?Snippet {
314 735 global $wpdb;
736 + $table = code_snippets()->db->get_table_name( $snippet->network );
315 737
316 - $table = code_snippets()->db->get_table_name( $network );
738 + if ( ! $snippet instanceof Snippet ) {
739 + $snippet = new Snippet( $snippet );
740 + }
317 741
318 - /* Build a new snippet object for the validation */
319 - $snippet = new Code_Snippet();
320 - $snippet->id = $snippet_id;
742 + // Prevent modification of locked snippets (allow unlocking itself).
743 + if ( 0 !== $snippet->id ) {
744 + $old_snippet = get_snippet( $snippet->id, $snippet->network );
321 745
322 - /* Validate fields through the snippet class and copy them into a clean array */
323 - $clean_fields = array();
746 + if ( $old_snippet->locked && $snippet->locked ) {
747 + // If it was locked and the new request still wants it locked,
748 + // prevent changes to sensitive fields (code and name).
749 + $snippet->code = $old_snippet->code;
750 + $snippet->name = $old_snippet->name;
751 + }
752 + }
324 753
325 - foreach ( $fields as $field => $value ) {
754 + // Update the last modification date if necessary.
755 + $snippet->update_modified();
326 756
327 - if ( $snippet->set_field( $field, $value ) ) {
328 - $clean_fields[ $field ] = $snippet->$field;
757 + // Strip any wrapper markup that came along with the pasted code.
758 + $snippet->code = normalize_snippet_code( $snippet->code, $snippet->type );
759 +
760 + if ( 'php' === $snippet->type ) {
761 + // Deactivate snippet if code contains errors.
762 + if ( $snippet->active && 'single-use' !== $snippet->scope ) {
763 + test_snippet_code( $snippet );
764 +
765 + if ( $snippet->code_error ) {
766 + $snippet->active = 0;
767 + }
329 768 }
330 769 }
331 770
332 - /* Update the snippet in the database */
333 - $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) );
334 - do_action( 'code_snippets/update_snippet', $snippet->id, $table );
771 + // Increment the revision number unless revision = 1 or revision is not set.
772 + if ( $snippet->revision && $snippet->revision > 1 ) {
773 + $snippet->increment_revision();
774 + }
775 +
776 + // Shared network snippets are always considered inactive.
777 + $snippet->active = $snippet->active && ! $snippet->shared_network;
778 +
779 + // Snippet authorship: track who created and who last edited each snippet.
780 + // `created_by` is fixed at insert time; `updated_by` reflects every save.
781 + $current_user_id = get_current_user_id();
782 + $author_id = $current_user_id > 0 ? $current_user_id : null;
783 +
784 + // Build the list of data to insert (excluding locked, which is stored in wp_options).
785 + $data = [
786 + 'name' => $snippet->name,
787 + 'description' => $snippet->desc,
788 + 'code' => $snippet->code,
789 + 'tags' => $snippet->tags_list,
790 + 'scope' => $snippet->scope,
791 + 'condition_id' => intval( $snippet->condition_id ),
792 + 'priority' => $snippet->priority,
793 + 'active' => intval( $snippet->active ),
794 + 'modified' => $snippet->modified,
795 + 'revision' => $snippet->revision,
796 + 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null,
797 + 'updated_by' => $author_id,
798 + ];
799 +
800 + // Create a new snippet if the ID is not set.
801 + if ( 0 === $snippet->id ) {
802 + $data['created_by'] = $author_id;
803 + $result = $wpdb->insert( $table, $data, '%s' );
804 + if ( false === $result ) {
805 + return null;
806 + }
807 +
808 + $snippet->id = $wpdb->insert_id;
809 + $updated = get_snippet( $snippet->id, $snippet->network );
810 + $updated->code_error = $snippet->code_error;
811 + $updated->code_error_trace = $snippet->code_error_trace;
812 + do_action( 'code_snippets/create_snippet', $updated, $table );
813 +
814 + if ( $updated->id > 0 ) {
815 + set_snippet_locked( $updated->id, $updated->locked, $updated->network );
816 + }
817 + } else {
818 + // Otherwise, update the snippet data.
819 + $existing = get_snippet( $snippet->id, $snippet->network );
820 +
821 + set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network );
822 + $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );
823 +
824 + // The row has changed, so the cached list no longer describes it. It is
825 + // dropped before the snippet is read back, because everything below —
826 + // the value this returns and what its hooks are handed — has to be the
827 + // saved snippet rather than the one that was there beforehand.
828 + clean_snippets_cache( $table );
829 +
830 + $updated = get_snippet( $snippet->id, $snippet->network );
831 + $updated->code_error = $snippet->code_error;
832 + $updated->code_error_trace = $snippet->code_error_trace;
833 +
834 + do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet );
835 +
836 + if ( ! $updated->active && $existing->active ) {
837 + $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
838 + $recently_active[ $updated->id ] = time();
839 + update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
840 + } elseif ( ! $updated->active ) {
841 + $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
842 +
843 + if ( isset( $recently_active[ $updated->id ] ) ) {
844 + unset( $recently_active[ $updated->id ] );
845 + update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
846 + }
847 + }
848 + }
849 +
850 + update_shared_network_snippets( [ $updated ] );
851 + clean_snippets_cache( $table );
852 + return $updated;
335 853 }
336 854
337 855 /**
338 - * Execute a snippet
856 + * Resolve a user ID to a compact author object for display.
339 857 *
340 - * Code must NOT be escaped, as
341 - * it will be executed directly
858 + * Returns the user's ID, display name, and avatar URL, or null when the ID is
859 + * empty or the user no longer exists. Results are cached per request, so a list
860 + * of snippets sharing authors only triggers one lookup per distinct user.
342 861 *
343 - * @since 2.0
862 + * @param int $user_id User ID to resolve.
344 863 *
345 - * @param string $code The snippet code to execute
346 - * @param int $id The snippet ID
347 - * @param bool $catch_output Whether to attempt to suppress the output of execution using buffers
348 - *
349 - * @return mixed The result of the code execution
864 + * @return array{id: int, display_name: string, avatar_url: string}|null
350 865 */
351 -function execute_snippet( $code, $id = 0, $catch_output = true ) {
866 +function get_snippet_author( int $user_id ): ?array {
867 + static $cache = [];
352 868
353 - if ( empty( $code ) ) {
354 - return false;
869 + if ( $user_id <= 0 ) {
870 + return null;
355 871 }
356 872
357 - if ( $catch_output ) {
358 - ob_start();
873 + if ( ! array_key_exists( $user_id, $cache ) ) {
874 + $user = get_userdata( $user_id );
875 + $cache[ $user_id ] = $user ?
876 + [
877 + 'id' => $user_id,
878 + 'display_name' => $user->display_name,
879 + 'avatar_url' => (string) get_avatar_url( $user_id, [ 'size' => 32 ] ),
880 + ] :
881 + null;
359 882 }
360 883
361 - $result = eval( $code );
884 + return $cache[ $user_id ];
885 +}
362 886
363 - if ( $catch_output ) {
364 - ob_end_clean();
887 +/**
888 + * Execute a snippet.
889 + * Execute operation.
890 + *
891 + * Code must NOT be escaped, as it will be executed directly.
892 + *
893 + * @param string $code Snippet code to execute.
894 + * @param int $id Snippet ID.
895 + * @param bool $force Force snippet execution, even if save mode is active.
896 + *
897 + * @return Throwable|mixed Code error if encountered during execution, or result of snippet execution otherwise.
898 + *
899 + * @since 2.0.0
900 + * @noinspection PhpUndefinedConstantInspection
901 + *
902 + * phpcs:disable Squiz.PHP.Eval.Discouraged
903 + */
904 +function execute_snippet( string $code, int $id = 0, bool $force = false ) {
905 + /**
906 + * Do not continue if safe mode is active.
907 + *
908 + * @noinspection PhpUndefinedConstantInspection
909 + */
910 + if ( empty( $code ) || ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) ) {
911 + return false;
365 912 }
366 913
367 - do_action( 'code_snippets/after_execute_snippet', $id, $code, $result );
914 + ob_start();
368 915
916 + try {
917 + $result = eval( $code );
918 + } catch ( Throwable $throwable ) {
919 + $result = $throwable;
920 + }
921 +
922 + ob_end_clean();
923 +
924 + do_action( 'code_snippets/after_execute_snippet', $code, $id, $result );
369 925 return $result;
370 926 }
371 927
372 928 /**
373 - * Run the active snippets
929 + * Retrieve a single snippets from the database using its cloud ID.
374 930 *
375 - * @since 2.0
931 + * Read operation.
376 932 *
377 - * @return bool true on success, false on failure
933 + * @param string $cloud_id The Cloud ID of the snippet to retrieve.
934 + * @param bool|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
935 + *
936 + * @return Snippet|null A single snippet object or null if no snippet was found.
937 + *
938 + * @since 3.5.0
378 939 */
379 -function execute_active_snippets() {
940 +function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
941 + global $wpdb;
380 942
381 - /* Bail early if safe mode is active */
382 - if ( defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE || ! apply_filters( 'code_snippets/execute_snippets', true ) ) {
383 - return false;
943 + $multisite = validate_network_param( $multisite );
944 + $table_name = code_snippets()->db->get_table_name( $multisite );
945 +
946 + $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
947 +
948 + // Attempt to fetch snippet from the cached list, if it exists.
949 + if ( is_array( $cached_snippets ) ) {
950 + foreach ( $cached_snippets as $snippet ) {
951 + if ( $snippet->cloud_id === $cloud_id ) {
952 + return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
953 + }
954 + }
384 955 }
385 956
386 - /** @var wpdb $wpdb */
387 - global $wpdb;
388 - $db = code_snippets()->db;
957 + // Otherwise, search for the snippet from the database.
958 + $snippet_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE cloud_id = %s", $cloud_id ) ); // cache pass, db call ok.
959 + $snippet = $snippet_data ? new Snippet( $snippet_data ) : null;
389 960
390 - $current_scope = is_admin() ? 'admin' : 'front-end';
391 - $queries = array();
961 + // Load locked from wp_options if snippet exists.
962 + if ( $snippet && $snippet->id > 0 ) {
963 + $snippet->network = $multisite;
964 + $snippet->locked = is_snippet_locked( $snippet->id, $multisite );
965 + }
392 966
393 - $sql_format = "SELECT id, code, scope FROM %s WHERE scope IN ('global', 'single-use', %%s) ";
394 - $order = 'ORDER BY priority ASC, id ASC';
967 + return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
968 +}
395 969
396 - /* Fetch snippets from site table */
397 - if ( $wpdb->get_var( "SHOW TABLES LIKE '$db->table'" ) === $db->table ) {
398 - $queries[ $db->table ] = $wpdb->prepare( sprintf( $sql_format, $db->table ) . 'AND active=1 ' . $order, $current_scope );
970 +/**
971 + * Remove the wrapper markup that a snippet's code does not need.
972 + *
973 + * Snippet code is stored bare: PHP is evaluated already inside PHP, and CSS and
974 + * JavaScript are wrapped in their own tags when printed. People increasingly
975 + * paste code generated by an AI assistant, which almost always arrives wrapped
976 + * in the tags for its language and sometimes in a markdown code fence as well.
977 + *
978 + * Leaving that markup in place fails differently depending on the type, and all
979 + * three ways are unhelpful. PHP raises a syntax error, so the snippet saves and
980 + * is then quietly deactivated. CSS and JavaScript have no syntax check at all,
981 + * so they save as active and emit doubled tags on the front end with nothing
982 + * reported anywhere.
983 + *
984 + * Only a wrapper around the whole snippet is removed. Tags appearing partway
985 + * through the code are left alone, since those are the author's own.
986 + *
987 + * @param string $code Snippet code as provided.
988 + * @param string $type Snippet type: php, css, js or html.
989 + *
990 + * @return string Code with any surrounding wrapper markup removed.
991 + */
992 +function normalize_snippet_code( string $code, string $type ): string {
993 + // A markdown fence around the whole snippet, as copied from a chat window.
994 + // The closing fence only goes when an opening one was there: on its own it
995 + // is the author's content, as in an HTML snippet ending in backticks.
996 + $code = preg_replace( '/\A\s*```[a-z]*[ \t]*\R/i', '', $code, 1, $fenced );
997 +
998 + if ( $fenced ) {
999 + $code = preg_replace( '/\R\s*```\s*\z/', '', $code );
399 1000 }
400 1001
401 - /* Fetch snippets from the network table */
402 - if ( is_multisite() && $wpdb->get_var( "SHOW TABLES LIKE '$db->ms_table'" ) === $db->ms_table ) {
403 - $active_shared_ids = get_option( 'active_shared_network_snippets', array() );
1002 + switch ( $type ) {
1003 + case 'php':
1004 + // `php` is matched as a whole word so that `<?phpinfo()` is not
1005 + // mistaken for an opening tag followed by `info()`.
1006 + $code = preg_replace( '/\A\s*<\?(?:php\b)?/i', '', $code );
1007 + $code = preg_replace( '/\?>\s*\z/', '', $code );
1008 + break;
404 1009
405 - /* If there are active shared snippets, include them in the query */
406 - if ( is_array( $active_shared_ids ) && count( $active_shared_ids ) ) {
1010 + case 'css':
1011 + $code = preg_replace( '/\A\s*<style\b[^>]*>/i', '', $code );
1012 + $code = preg_replace( '/<\/style\s*>\s*\z/i', '', $code );
1013 + break;
407 1014
408 - /* Build a list of "%d, %d, %d ..." for every active network shared snippet we have */
409 - $active_shared_ids_format = implode( ',', array_fill( 0, count( $active_shared_ids ), '%d' ) );
1015 + case 'js':
1016 + $code = preg_replace( '/\A\s*<script\b[^>]*>/i', '', $code );
1017 + $code = preg_replace( '/<\/script\s*>\s*\z/i', '', $code );
1018 + break;
1019 + }
410 1020
411 - /* Include them in the query */
412 - $sql = sprintf( $sql_format, $db->ms_table ) . " AND (active=1 OR id IN ($active_shared_ids_format)) $order";
1021 + // Drop the single line break left behind by an opening tag on its own line,
1022 + // so the stored code does not gain a blank first line each time.
1023 + return preg_replace( '/\A\R/', '', $code );
1024 +}
413 1025
414 - /* Add the scope number to the IDs array, so that it is the first variable in the query */
415 - array_unshift( $active_shared_ids, $current_scope );
416 - $queries[ $db->ms_table ] = $wpdb->prepare( $sql, $active_shared_ids );
417 - array_shift( $active_shared_ids ); // remove it afterwards as we need this variable later
1026 +/**
1027 + * Update a snippet entry given a list of fields.
1028 + * Write operation.
1029 + *
1030 + * @param int $snippet_id ID of the snippet to update.
1031 + * @param array<string, mixed> $fields An array of fields mapped to their values.
1032 + * @param bool|null $network Update in network-wide (true) or site-wide (false) table.
1033 + */
1034 +function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) {
1035 + global $wpdb;
418 1036
419 - } else {
420 - $sql = sprintf( $sql_format, $db->ms_table ) . 'AND active=1 ' . $order;
421 - $queries[ $db->ms_table ] = $wpdb->prepare( $sql, $current_scope );
1037 + $network = validate_network_param( $network );
1038 + $table = code_snippets()->db->get_table_name( $network );
1039 +
1040 + // Build a new snippet object for the validation.
1041 + $snippet = new Snippet();
1042 + $snippet->id = $snippet_id;
1043 +
1044 + // Validate fields through the snippet class and copy them into a clean array.
1045 + $clean_fields = array();
1046 + $locked_value = null;
1047 +
1048 + foreach ( $fields as $field => $value ) {
1049 + // Handle locked separately (stored in wp_options).
1050 + if ( 'locked' === $field ) {
1051 + if ( $snippet->set_field( $field, $value ) ) {
1052 + $locked_value = $snippet->$field;
1053 + }
1054 + continue;
422 1055 }
1056 +
1057 + if ( $snippet->set_field( $field, $value ) ) {
1058 + $clean_fields[ $field ] = $snippet->$field;
1059 + }
423 1060 }
424 1061
425 - foreach ( $queries as $table_name => $query ) {
426 - $active_snippets = $wpdb->get_results( $query, 'ARRAY_A' );
1062 + // Update the snippet in the database (excluding locked).
1063 + if ( ! empty( $clean_fields ) ) {
1064 + $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) );
1065 + }
427 1066
428 - if ( ! is_array( $active_snippets ) ) {
429 - continue;
430 - }
1067 + // Save locked to wp_options if it was provided.
1068 + if ( null !== $locked_value ) {
1069 + set_snippet_locked( $snippet->id, $locked_value, $network );
1070 + }
431 1071
432 - /* Loop through the returned snippets and execute the PHP code */
433 - foreach ( $active_snippets as $snippet ) {
434 - $snippet_id = intval( $snippet['id'] );
435 - $code = $snippet['code'];
1072 + clean_snippets_cache( $table );
1073 + $updated = get_snippet( $snippet->id, $network );
1074 + if ( $updated->id ) {
1075 + do_action( 'code_snippets/update_snippet', $updated, $table );
1076 + }
1077 +}
436 1078
437 - // if the snippet is a single-use snippet, deactivate it before execution to ensure that the process always happens
438 - if ( 'single-use' === $snippet['scope'] ) {
439 - if ( $table_name === $db->ms_table && isset( $active_shared_ids ) &&
440 - false !== ( $key = array_search( $snippet_id, $active_shared_ids ) ) ) {
441 - unset( $active_shared_ids[ $key ] );
442 - $active_shared_ids = array_values( $active_shared_ids );
443 - update_option( 'active_shared_network_snippets', $active_shared_ids );
444 - } else {
445 - $wpdb->update( $table_name, array( 'active' => '0' ), array( 'id' => $snippet_id ), array( '%d' ), array( '%d' ) );
446 - }
447 - }
1079 +/**
1080 + * Evaluate a snippet by loading it from the filesystem.
1081 + *
1082 + * @param string $code Snippet code.
1083 + * @param string $file Snippet filename.
1084 + * @param int $id Snippet ID.
1085 + * @param bool $force Force snippet execution, even if save mode is active.
1086 + *
1087 + * @return bool|Exception|Throwable|null Code error if encountered during execution, or result of snippet execution otherwise.
1088 + */
1089 +function execute_snippet_from_flat_file( string $code, string $file, int $id = 0, bool $force = false ) {
1090 + if ( ! is_file( $file ) ) {
1091 + execute_snippet( $code, $id, $force );
1092 + return true;
1093 + }
448 1094
449 - if ( apply_filters( 'code_snippets/allow_execute_snippet', true, $snippet_id, $table_name ) ) {
450 - execute_snippet( $code, $snippet_id );
451 - }
452 - }
1095 + /* @noinspection PhpUndefinedConstantInspection */
1096 + if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) {
1097 + return false;
453 1098 }
454 1099
455 - return true;
1100 + ob_start();
1101 +
1102 + try {
1103 + require_once $file;
1104 + $result = null;
1105 + } catch ( Throwable $throwable ) {
1106 + $result = $throwable;
1107 + }
1108 +
1109 + ob_end_clean();
1110 +
1111 + do_action( 'code_snippets/after_execute_snippet_from_flat_file', $file, $id );
1112 +
1113 + return $result ?? null;
456 1114 }