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 +436 -93 3.9.6 → 4.0.0-beta.3 View file →
@@ -6,13 +6,54 @@
6 6 */
7 7
8 8 namespace Code_Snippets;
9 9
10 -use ParseError;
11 -use function Code_Snippets\Settings\get_self_option;
12 -use function Code_Snippets\Settings\update_self_option;
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;
13 17
14 18 /**
19 + * Get the locked status for a snippet from wp_options.
20 + *
21 + * @param int $snippet_id Snippet ID.
22 + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false).
23 + *
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.
35 + *
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).
39 + *
40 + * @return void
41 + */
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', [] );
45 +
46 + if ( $locked ) {
47 + $locked_snippets[ $snippet_id ] = true;
48 + } else {
49 + unset( $locked_snippets[ $snippet_id ] );
50 + }
51 +
52 + update_self_option( $network, 'code_snippets_locked', $locked_snippets );
53 +}
54 +
55 +/**
15 56 * Clean the cache where active snippets are stored.
16 57 *
17 58 * @param string $table_name Snippets table name.
18 59 * @param array<string>|false $scopes List of scopes. Optional. If not provided, will flush the cache for all scopes.
@@ -19,14 +60,19 @@
19 60 *
20 61 * @return void
21 62 */
22 63 function clean_active_snippets_cache( string $table_name, $scopes = false ) {
23 - $scope_groups = $scopes ? [ $scopes ] : [
24 - [ 'head-content', 'footer-content' ],
25 - [ 'global', 'single-use', 'front-end' ],
26 - [ 'global', 'single-use', 'admin' ],
27 - ];
64 + $scope_groups = $scopes
65 + ? [ $scopes ]
66 + : [
67 + // Content snippets.
68 + [ 'head-content', 'body-content', 'footer-content' ],
28 69
70 + // Function snippets.
71 + [ 'global', 'single-use', 'front-end' ],
72 + [ 'global', 'single-use', 'admin' ],
73 + ];
74 +
29 75 foreach ( $scope_groups as $scopes ) {
30 76 wp_cache_delete( sprintf( 'active_snippets_%s_%s', sanitize_key( join( '_', $scopes ) ), $table_name ), CACHE_GROUP );
31 77 }
32 78 }
@@ -44,8 +90,90 @@
44 90 clean_active_snippets_cache( $table_name );
45 91 }
46 92
47 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;
121 + }
122 +
123 + if ( ! function_exists( 'wp_cache_flush_group' ) ||
124 + ! function_exists( 'wp_cache_supports' ) ||
125 + ! wp_cache_supports( 'flush_group' ) ) {
126 + return false;
127 + }
128 +
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 );
142 + }
143 +
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 + }
156 +}
157 +
158 +/**
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 +/**
48 176 * Retrieve a list of snippets from the database.
49 177 * Read operation.
50 178 *
51 179 * @param array<string> $ids The IDs of the snippets to fetch.
@@ -50,22 +178,22 @@
50 178 *
51 179 * @param array<string> $ids The IDs of the snippets to fetch.
52 180 * @param bool|null $network Retrieve multisite-wide snippets (true) or site-wide snippets (false).
53 181 *
54 - * @return array<Snippet> List of Snippet objects.
182 + * @return Snippet[] List of Snippet objects.
55 183 *
56 184 * @since 2.0
57 185 */
58 -function get_snippets( array $ids = array(), ?bool $network = null ): array {
186 +function get_snippets( array $ids = [], ?bool $network = null ): array {
59 187 global $wpdb;
60 188
61 189 // If only one ID has been passed in, defer to the get_snippet() function.
62 190 $ids_count = count( $ids );
63 191 if ( 1 === $ids_count ) {
64 - return array( get_snippet( $ids[0], $network ) );
192 + return [ get_snippet( $ids[0], $network ) ];
65 193 }
66 194
67 - $network = DB::validate_network_param( $network );
195 + $network = validate_network_param( $network );
68 196 $table_name = code_snippets()->db->get_table_name( $network );
69 197
70 198 $snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
71 199
@@ -72,17 +200,22 @@
72 200 // Fetch all snippets from the database if none are cached.
73 201 if ( ! is_array( $snippets ) ) {
74 202 $results = $wpdb->get_results( "SELECT * FROM $table_name", ARRAY_A );
75 203
76 - $snippets = $results ?
77 - array_map(
204 + $snippets = $results
205 + ? array_map(
78 206 function ( $snippet_data ) use ( $network ) {
79 207 $snippet_data['network'] = $network;
80 - return new Snippet( $snippet_data );
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;
81 214 },
82 215 $results
83 - ) :
84 - array();
216 + )
217 + : [];
85 218
86 219 $snippets = apply_filters( 'code_snippets/get_snippets', $snippets, $network );
87 220
88 221 if ( 0 === $ids_count ) {
@@ -172,17 +305,17 @@
172 305 *
173 306 * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet.
174 307 * @param bool|null $network Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
175 308 *
176 - * @return Snippet A single snippet object.
309 + * @return ?Snippet A single snippet object.
177 310 *
178 311 * @since 2.0.0
179 312 */
180 -function get_snippet( int $id = 0, ?bool $network = null ): Snippet {
313 +function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
181 314 global $wpdb;
182 315
183 316 $id = absint( $id );
184 - $network = DB::validate_network_param( $network );
317 + $network = validate_network_param( $network );
185 318 $table_name = code_snippets()->db->get_table_name( $network );
186 319
187 320 if ( 0 === $id ) {
188 321 // If an invalid ID is provided, then return an empty snippet object.
@@ -206,14 +339,20 @@
206 339 $snippet = new Snippet( $snippet_data );
207 340 }
208 341
209 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 );
347 + }
348 +
210 349 return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network );
211 350 }
212 351
213 352
214 353 /**
215 - * Ensure the list of shared network snippets is correct if one has been recently activated or deactivated.
354 + * Ensure the list of shared network snippets is correct if one has been recently active or deactivated.
216 355 * Write operation.
217 356 *
218 357 * @access private
219 358 *
@@ -218,9 +357,9 @@
218 357 * @access private
219 358 *
220 359 * @param Snippet[] $snippets Snippets that was recently updated.
221 360 *
222 - * @return boolean Whether an update was performed.
361 + * @return bool Whether an update was performed.
223 362 */
224 363 function update_shared_network_snippets( array $snippets ): bool {
225 364 $shared_ids = [];
226 365 $unshared_ids = [];
@@ -285,9 +424,9 @@
285 424 * @since 2.0.0
286 425 */
287 426 function activate_snippet( int $id, ?bool $network = null ) {
288 427 global $wpdb;
289 - $network = DB::validate_network_param( $network );
428 + $network = validate_network_param( $network );
290 429 $table_name = code_snippets()->db->get_table_name( $network );
291 430
292 431 // Retrieve the snippet code from the database for validation before activating.
293 432 $snippet = get_snippet( $id, $network );
@@ -295,10 +434,10 @@
295 434 if ( 0 === $snippet->id ) {
296 435 // translators: %d: snippet identifier.
297 436 return sprintf( __( 'Could not locate snippet with ID %d.', 'code-snippets' ), $id );
298 437 }
299 -
300 - if('php' == $snippet->type ){
438 +
439 + if ( 'php' === $snippet->type ) {
301 440 $validator = new Validator( $snippet->code );
302 441 if ( $validator->validate() ) {
303 442 return __( 'Could not activate snippet: code did not pass validation.', 'code-snippets' );
304 443 }
@@ -315,8 +454,13 @@
315 454 if ( ! $result ) {
316 455 return __( 'Could not activate snippet.', 'code-snippets' );
317 456 }
318 457
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 +
319 463 update_shared_network_snippets( [ $snippet ] );
320 464 do_action( 'code_snippets/activate_snippet', $snippet, $network );
321 465 clean_snippets_cache( $table_name );
322 466 return $snippet;
@@ -325,10 +469,10 @@
325 469 /**
326 470 * Activates multiple snippets.
327 471 * Write operation.
328 472 *
329 - * @param array<integer> $ids The IDs of the snippets to activate.
330 - * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
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).
331 475 *
332 476 * @return Snippet[]|null Snippets which were successfully activated, or null on failure.
333 477 *
334 478 * @since 2.0.0
@@ -334,9 +478,9 @@
334 478 * @since 2.0.0
335 479 */
336 480 function activate_snippets( array $ids, ?bool $network = null ): ?array {
337 481 global $wpdb;
338 - $network = DB::validate_network_param( $network );
482 + $network = validate_network_param( $network );
339 483 $table_name = code_snippets()->db->get_table_name( $network );
340 484
341 485 $snippets = get_snippets( $ids, $network );
342 486
@@ -347,13 +491,28 @@
347 491 // Loop through each snippet code and validate individually.
348 492 $valid_ids = [];
349 493 $valid_snippets = [];
350 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 +
351 500 foreach ( $snippets as $snippet ) {
352 - $validator = new Validator( $snippet->code );
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 );
353 511 $code_error = $validator->validate();
354 512
355 513 if ( ! $code_error ) {
514 + $claimed_identifiers = $validator->get_claimed_identifiers();
356 515 $valid_ids[] = $snippet->id;
357 516 $valid_snippets[] = $snippet;
358 517 }
359 518 }
@@ -372,8 +531,10 @@
372 531 if ( ! $rows_updated ) {
373 532 return null;
374 533 }
375 534
535 + clean_snippets_cache( $table_name );
536 +
376 537 update_shared_network_snippets( $valid_snippets );
377 538 do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name );
378 539 clean_snippets_cache( $table_name );
379 540 return $valid_ids;
@@ -391,9 +552,9 @@
391 552 * @since 2.0.0
392 553 */
393 554 function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
394 555 global $wpdb;
395 - $network = DB::validate_network_param( $network );
556 + $network = validate_network_param( $network );
396 557 $table = code_snippets()->db->get_table_name( $network );
397 558
398 559 // Set the snippet to inactive.
399 560 $result = $wpdb->update(
@@ -407,12 +568,17 @@
407 568 if ( ! $result ) {
408 569 return null;
409 570 }
410 571
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 );
576 +
411 577 // Update the recently active list.
412 - $snippet = get_snippet( $id );
413 - $recently_active = [ $id => time() ] + get_self_option( $network, 'recently_activated_snippets', [] );
414 - update_self_option( $network, 'recently_activated_snippets', $recently_active );
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 );
415 581
416 582 update_shared_network_snippets( [ $snippet ] );
417 583 do_action( 'code_snippets/deactivate_snippet', $id, $network );
418 584 clean_snippets_cache( $table );
@@ -432,13 +598,18 @@
432 598 * @since 2.0.0
433 599 */
434 600 function delete_snippet( int $id, ?bool $network = null ): bool {
435 601 global $wpdb;
436 - $network = DB::validate_network_param( $network );
602 + $network = validate_network_param( $network );
437 603 $table = code_snippets()->db->get_table_name( $network );
438 604
439 605 $snippet = get_snippet( $id, $network );
440 606
607 + // Prevent deletion of locked snippets.
608 + if ( $snippet->locked ) {
609 + return false;
610 + }
611 +
441 612 $result = $wpdb->delete(
442 613 $table,
443 614 array( 'id' => $id ),
444 615 array( '%d' )
@@ -444,11 +615,17 @@
444 615 array( '%d' )
445 616 );
446 617
447 618 if ( $result ) {
619 + clean_snippets_cache( $table );
448 620 do_action( 'code_snippets/delete_snippet', $snippet, $network );
449 - clean_snippets_cache( $table );
450 - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id );
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 + }
451 628 }
452 629
453 630 return (bool) $result;
454 631 }
@@ -465,27 +642,24 @@
465 642 * @since 3.8.0
466 643 */
467 644 function trash_snippet( int $id, ?bool $network = null ): bool {
468 645 global $wpdb;
469 - $network = DB::validate_network_param( $network );
646 + $network = validate_network_param( $network );
470 647 $table = code_snippets()->db->get_table_name( $network );
471 648
472 649 $snippet = get_snippet( $id, $network );
473 650
474 - $result = $wpdb->update(
475 - $table,
476 - array( 'active' => '-1' ),
477 - array( 'id' => $id ),
478 - array( '%d' )
479 - );
651 + // Prevent trashing of locked snippets.
652 + if ( $snippet->locked ) {
653 + return false;
654 + }
480 655
481 - if ( $result ) {
482 - do_action( 'code_snippets/trash_snippet', $snippet, $network );
483 - clean_snippets_cache( $table );
484 - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id );
485 - }
656 + $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] );
486 657
487 - return (bool) $result;
658 + clean_snippets_cache( $table );
659 + do_action( 'code_snippets/trash_snippet', $snippet, $network );
660 +
661 + return true;
488 662 }
489 663
490 664 /**
491 665 * Restore a trashed snippet by setting its active status back to 0 (inactive).
@@ -499,21 +673,16 @@
499 673 * @since 3.8.0
500 674 */
501 675 function restore_snippet( int $id, ?bool $network = null ): bool {
502 676 global $wpdb;
503 - $network = DB::validate_network_param( $network );
677 + $network = validate_network_param( $network );
504 678 $table = code_snippets()->db->get_table_name( $network );
505 679
506 - $result = $wpdb->update(
507 - $table,
508 - array( 'active' => '0' ),
509 - array( 'id' => $id ),
510 - array( '%d' )
511 - );
680 + $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
512 681
513 682 if ( $result ) {
683 + clean_snippets_cache( $table );
514 684 do_action( 'code_snippets/restore_snippet', $id, $network );
515 - clean_snippets_cache( $table );
516 685 }
517 686
518 687 return (bool) $result;
519 688 }
@@ -524,8 +693,9 @@
524 693 * @param Snippet $snippet Snippet object.
525 694 */
526 695 function test_snippet_code( Snippet $snippet ) {
527 696 $snippet->code_error = null;
697 + $snippet->code_error_trace = null;
528 698
529 699 if ( 'php' !== $snippet->type ) {
530 700 return;
531 701 }
@@ -534,18 +704,20 @@
534 704 $result = $validator->validate();
535 705
536 706 if ( $result ) {
537 707 $snippet->code_error = [ $result['message'], $result['line'] ];
708 + $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
538 709 }
539 710
540 711 if ( ! $snippet->code_error && 'single-use' !== $snippet->scope ) {
541 712 $result = execute_snippet( $snippet->code, $snippet->id, true );
542 713
543 - if ( $result instanceof ParseError ) {
714 + if ( $result instanceof Throwable ) {
544 715 $snippet->code_error = [
545 716 ucfirst( rtrim( $result->getMessage(), '.' ) ) . '.',
546 717 $result->getLine(),
547 718 ];
719 + $snippet->code_error_trace = $result->getTraceAsString();
548 720 }
549 721 }
550 722 }
551 723
@@ -558,9 +730,9 @@
558 730 * @return Snippet|null Updated snippet.
559 731 *
560 732 * @since 2.0.0
561 733 */
562 -function save_snippet( $snippet ) {
734 +function save_snippet( $snippet ): ?Snippet {
563 735 global $wpdb;
564 736 $table = code_snippets()->db->get_table_name( $snippet->network );
565 737
566 738 if ( ! $snippet instanceof Snippet ) {
@@ -566,16 +738,27 @@
566 738 if ( ! $snippet instanceof Snippet ) {
567 739 $snippet = new Snippet( $snippet );
568 740 }
569 741
742 + // Prevent modification of locked snippets (allow unlocking itself).
743 + if ( 0 !== $snippet->id ) {
744 + $old_snippet = get_snippet( $snippet->id, $snippet->network );
745 +
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 + }
753 +
570 754 // Update the last modification date if necessary.
571 755 $snippet->update_modified();
572 756
757 + // Strip any wrapper markup that came along with the pasted code.
758 + $snippet->code = normalize_snippet_code( $snippet->code, $snippet->type );
759 +
573 760 if ( 'php' === $snippet->type ) {
574 - // Remove tags from beginning and end of snippet.
575 - $snippet->code = preg_replace( '|^\s*<\?(php)?|', '', $snippet->code );
576 - $snippet->code = preg_replace( '|\?>\s*$|', '', $snippet->code );
577 -
578 761 // Deactivate snippet if code contains errors.
579 762 if ( $snippet->active && 'single-use' !== $snippet->scope ) {
580 763 test_snippet_code( $snippet );
581 764
@@ -592,9 +775,14 @@
592 775
593 776 // Shared network snippets are always considered inactive.
594 777 $snippet->active = $snippet->active && ! $snippet->shared_network;
595 778
596 - // Build the list of data to insert.
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).
597 785 $data = [
598 786 'name' => $snippet->name,
599 787 'description' => $snippet->desc,
600 788 'code' => $snippet->code,
@@ -604,13 +792,15 @@
604 792 'priority' => $snippet->priority,
605 793 'active' => intval( $snippet->active ),
606 794 'modified' => $snippet->modified,
607 795 'revision' => $snippet->revision,
608 - 'cloud_id' => $snippet->cloud_id ? $snippet->cloud_id : null,
796 + 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null,
797 + 'updated_by' => $author_id,
609 798 ];
610 799
611 800 // Create a new snippet if the ID is not set.
612 801 if ( 0 === $snippet->id ) {
802 + $data['created_by'] = $author_id;
613 803 $result = $wpdb->insert( $table, $data, '%s' );
614 804 if ( false === $result ) {
615 805 return null;
616 806 }
@@ -615,23 +805,84 @@
615 805 return null;
616 806 }
617 807
618 808 $snippet->id = $wpdb->insert_id;
619 - do_action( 'code_snippets/create_snippet', $snippet, $table );
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 + }
620 817 } else {
818 + // Otherwise, update the snippet data.
819 + $existing = get_snippet( $snippet->id, $snippet->network );
621 820
622 - // Otherwise, update the snippet data.
623 - $result = $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );
624 - if ( false === $result ) {
625 - return null;
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 + }
626 847 }
848 + }
627 849
628 - do_action( 'code_snippets/update_snippet', $snippet, $table );
850 + update_shared_network_snippets( [ $updated ] );
851 + clean_snippets_cache( $table );
852 + return $updated;
853 +}
854 +
855 +/**
856 + * Resolve a user ID to a compact author object for display.
857 + *
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.
861 + *
862 + * @param int $user_id User ID to resolve.
863 + *
864 + * @return array{id: int, display_name: string, avatar_url: string}|null
865 + */
866 +function get_snippet_author( int $user_id ): ?array {
867 + static $cache = [];
868 +
869 + if ( $user_id <= 0 ) {
870 + return null;
629 871 }
630 872
631 - update_shared_network_snippets( [ $snippet ] );
632 - clean_snippets_cache( $table );
633 - return $snippet;
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;
882 + }
883 +
884 + return $cache[ $user_id ];
634 885 }
635 886
636 887 /**
637 888 * Execute a snippet.
@@ -638,15 +889,18 @@
638 889 * Execute operation.
639 890 *
640 891 * Code must NOT be escaped, as it will be executed directly.
641 892 *
642 - * @param string $code Snippet code to execute.
643 - * @param integer $id Snippet ID.
644 - * @param boolean $force Force snippet execution, even if save mode is active.
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.
645 896 *
646 - * @return ParseError|mixed Code error if encountered during execution, or result of snippet execution otherwise.
897 + * @return Throwable|mixed Code error if encountered during execution, or result of snippet execution otherwise.
647 898 *
648 - * @since 2.0.0
899 + * @since 2.0.0
900 + * @noinspection PhpUndefinedConstantInspection
901 + *
902 + * phpcs:disable Squiz.PHP.Eval.Discouraged
649 903 */
650 904 function execute_snippet( string $code, int $id = 0, bool $force = false ) {
651 905 /**
652 906 * Do not continue if safe mode is active.
@@ -660,10 +914,10 @@
660 914 ob_start();
661 915
662 916 try {
663 917 $result = eval( $code );
664 - } catch ( ParseError $parse_error ) {
665 - $result = $parse_error;
918 + } catch ( Throwable $throwable ) {
919 + $result = $throwable;
666 920 }
667 921
668 922 ob_end_clean();
669 923
@@ -675,10 +929,10 @@
675 929 * Retrieve a single snippets from the database using its cloud ID.
676 930 *
677 931 * Read operation.
678 932 *
679 - * @param string $cloud_id The Cloud ID of the snippet to retrieve.
680 - * @param boolean|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
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).
681 935 *
682 936 * @return Snippet|null A single snippet object or null if no snippet was found.
683 937 *
684 938 * @since 3.5.0
@@ -685,9 +939,9 @@
685 939 */
686 940 function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
687 941 global $wpdb;
688 942
689 - $multisite = DB::validate_network_param( $multisite );
943 + $multisite = validate_network_param( $multisite );
690 944 $table_name = code_snippets()->db->get_table_name( $multisite );
691 945
692 946 $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
693 947
@@ -703,12 +957,74 @@
703 957 // Otherwise, search for the snippet from the database.
704 958 $snippet_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE cloud_id = %s", $cloud_id ) ); // cache pass, db call ok.
705 959 $snippet = $snippet_data ? new Snippet( $snippet_data ) : null;
706 960
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 + }
966 +
707 967 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
708 968 }
709 969
710 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 );
1000 + }
1001 +
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;
1009 +
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;
1014 +
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 + }
1020 +
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 +}
1025 +
1026 +/**
711 1027 * Update a snippet entry given a list of fields.
712 1028 * Write operation.
713 1029 *
714 1030 * @param int $snippet_id ID of the snippet to update.
@@ -717,8 +1033,9 @@
717 1033 */
718 1034 function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) {
719 1035 global $wpdb;
720 1036
1037 + $network = validate_network_param( $network );
721 1038 $table = code_snippets()->db->get_table_name( $network );
722 1039
723 1040 // Build a new snippet object for the validation.
724 1041 $snippet = new Snippet();
@@ -725,10 +1042,18 @@
725 1042 $snippet->id = $snippet_id;
726 1043
727 1044 // Validate fields through the snippet class and copy them into a clean array.
728 1045 $clean_fields = array();
1046 + $locked_value = null;
729 1047
730 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;
1055 + }
731 1056
732 1057 if ( $snippet->set_field( $field, $value ) ) {
733 1058 $clean_fields[ $field ] = $snippet->$field;
734 1059 }
@@ -733,20 +1058,42 @@
733 1058 $clean_fields[ $field ] = $snippet->$field;
734 1059 }
735 1060 }
736 1061
737 - // Update the snippet in the database.
738 - $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) );
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 + }
739 1066
740 - do_action( 'code_snippets/update_snippet', $snippet->id, $table );
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 + }
1071 +
741 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 + }
742 1077 }
743 1078
744 -function execute_snippet_from_flat_file( $code, $file, int $id = 0, bool $force = false ) {
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 ) {
745 1090 if ( ! is_file( $file ) ) {
746 - return execute_snippet( $code, $id, $force );
1091 + execute_snippet( $code, $id, $force );
1092 + return true;
747 1093 }
748 1094
1095 + /* @noinspection PhpUndefinedConstantInspection */
749 1096 if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) {
750 1097 return false;
751 1098 }
752 1099
@@ -754,12 +1101,8 @@
754 1101
755 1102 try {
756 1103 require_once $file;
757 1104 $result = null;
758 - } catch ( ParseError $parse_error ) {
759 - $result = $parse_error;
760 - } catch ( Error $error ) {
761 - $result = $error;
762 1105 } catch ( Throwable $throwable ) {
763 1106 $result = $throwable;
764 1107 }
765 1108