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 +237 -30 3.10.0-beta.1 → 4.0.0-beta.3 View file →
@@ -6,15 +6,14 @@
6 6 */
7 7
8 8 namespace Code_Snippets;
9 9
10 -use Code_Snippets\Core\DB;
11 -use Code_Snippets\Flat_Files\Snippet_Files;
12 10 use Exception;
13 11 use Code_Snippets\Model\Snippet;
14 12 use Code_Snippets\Utils\Validator;
15 13 use Throwable;
16 14 use function Code_Snippets\Utils\get_self_option;
15 +use function Code_Snippets\Utils\validate_network_param;
17 16 use function Code_Snippets\Utils\update_self_option;
18 17
19 18 /**
20 19 * Get the locked status for a snippet from wp_options.
@@ -24,9 +23,9 @@
24 23 *
25 24 * @return bool Whether the snippet is locked.
26 25 */
27 26 function is_snippet_locked( int $snippet_id, ?bool $network = null ): bool {
28 - $network = DB::validate_network_param( $network );
27 + $network = validate_network_param( $network );
29 28 $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
30 29
31 30 return isset( $locked_snippets[ $snippet_id ] ) && $locked_snippets[ $snippet_id ];
32 31 }
@@ -40,9 +39,9 @@
40 39 *
41 40 * @return void
42 41 */
43 42 function set_snippet_locked( int $snippet_id, bool $locked, ?bool $network = null ): void {
44 - $network = DB::validate_network_param( $network );
43 + $network = validate_network_param( $network );
45 44 $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
46 45
47 46 if ( $locked ) {
48 47 $locked_snippets[ $snippet_id ] = true;
@@ -64,9 +63,12 @@
64 63 function clean_active_snippets_cache( string $table_name, $scopes = false ) {
65 64 $scope_groups = $scopes
66 65 ? [ $scopes ]
67 66 : [
67 + // Content snippets.
68 68 [ 'head-content', 'body-content', 'footer-content' ],
69 +
70 + // Function snippets.
69 71 [ 'global', 'single-use', 'front-end' ],
70 72 [ 'global', 'single-use', 'admin' ],
71 73 ];
72 74
@@ -88,8 +90,90 @@
88 90 clean_active_snippets_cache( $table_name );
89 91 }
90 92
91 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 +/**
92 176 * Retrieve a list of snippets from the database.
93 177 * Read operation.
94 178 *
95 179 * @param array<string> $ids The IDs of the snippets to fetch.
@@ -107,9 +191,9 @@
107 191 if ( 1 === $ids_count ) {
108 192 return [ get_snippet( $ids[0], $network ) ];
109 193 }
110 194
111 - $network = DB::validate_network_param( $network );
195 + $network = validate_network_param( $network );
112 196 $table_name = code_snippets()->db->get_table_name( $network );
113 197
114 198 $snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
115 199
@@ -229,9 +313,9 @@
229 313 function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
230 314 global $wpdb;
231 315
232 316 $id = absint( $id );
233 - $network = DB::validate_network_param( $network );
317 + $network = validate_network_param( $network );
234 318 $table_name = code_snippets()->db->get_table_name( $network );
235 319
236 320 if ( 0 === $id ) {
237 321 // If an invalid ID is provided, then return an empty snippet object.
@@ -340,9 +424,9 @@
340 424 * @since 2.0.0
341 425 */
342 426 function activate_snippet( int $id, ?bool $network = null ) {
343 427 global $wpdb;
344 - $network = DB::validate_network_param( $network );
428 + $network = validate_network_param( $network );
345 429 $table_name = code_snippets()->db->get_table_name( $network );
346 430
347 431 // Retrieve the snippet code from the database for validation before activating.
348 432 $snippet = get_snippet( $id, $network );
@@ -370,8 +454,13 @@
370 454 if ( ! $result ) {
371 455 return __( 'Could not activate snippet.', 'code-snippets' );
372 456 }
373 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 +
374 463 update_shared_network_snippets( [ $snippet ] );
375 464 do_action( 'code_snippets/activate_snippet', $snippet, $network );
376 465 clean_snippets_cache( $table_name );
377 466 return $snippet;
@@ -389,9 +478,9 @@
389 478 * @since 2.0.0
390 479 */
391 480 function activate_snippets( array $ids, ?bool $network = null ): ?array {
392 481 global $wpdb;
393 - $network = DB::validate_network_param( $network );
482 + $network = validate_network_param( $network );
394 483 $table_name = code_snippets()->db->get_table_name( $network );
395 484
396 485 $snippets = get_snippets( $ids, $network );
397 486
@@ -402,13 +491,28 @@
402 491 // Loop through each snippet code and validate individually.
403 492 $valid_ids = [];
404 493 $valid_snippets = [];
405 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 +
406 500 foreach ( $snippets as $snippet ) {
407 - $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 );
408 511 $code_error = $validator->validate();
409 512
410 513 if ( ! $code_error ) {
514 + $claimed_identifiers = $validator->get_claimed_identifiers();
411 515 $valid_ids[] = $snippet->id;
412 516 $valid_snippets[] = $snippet;
413 517 }
414 518 }
@@ -427,8 +531,10 @@
427 531 if ( ! $rows_updated ) {
428 532 return null;
429 533 }
430 534
535 + clean_snippets_cache( $table_name );
536 +
431 537 update_shared_network_snippets( $valid_snippets );
432 538 do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name );
433 539 clean_snippets_cache( $table_name );
434 540 return $valid_ids;
@@ -446,9 +552,9 @@
446 552 * @since 2.0.0
447 553 */
448 554 function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
449 555 global $wpdb;
450 - $network = DB::validate_network_param( $network );
556 + $network = validate_network_param( $network );
451 557 $table = code_snippets()->db->get_table_name( $network );
452 558
453 559 // Set the snippet to inactive.
454 560 $result = $wpdb->update(
@@ -462,11 +568,16 @@
462 568 if ( ! $result ) {
463 569 return null;
464 570 }
465 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 +
466 577 // Update the recently active list.
467 - $snippet = get_snippet( $id );
468 - $recently_active = [ $id => time() ] + get_self_option( $network, 'recently_active_snippets', [] );
578 + $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
579 + $recently_active[ $id ] = time();
469 580 update_self_option( $network, 'recently_active_snippets', $recently_active );
470 581
471 582 update_shared_network_snippets( [ $snippet ] );
472 583 do_action( 'code_snippets/deactivate_snippet', $id, $network );
@@ -487,9 +598,9 @@
487 598 * @since 2.0.0
488 599 */
489 600 function delete_snippet( int $id, ?bool $network = null ): bool {
490 601 global $wpdb;
491 - $network = DB::validate_network_param( $network );
602 + $network = validate_network_param( $network );
492 603 $table = code_snippets()->db->get_table_name( $network );
493 604
494 605 $snippet = get_snippet( $id, $network );
495 606
@@ -504,10 +615,10 @@
504 615 array( '%d' )
505 616 );
506 617
507 618 if ( $result ) {
619 + clean_snippets_cache( $table );
508 620 do_action( 'code_snippets/delete_snippet', $snippet, $network );
509 - clean_snippets_cache( $table );
510 621
511 622 $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
512 623
513 624 if ( isset( $recently_active[ $id ] ) ) {
@@ -531,9 +642,9 @@
531 642 * @since 3.8.0
532 643 */
533 644 function trash_snippet( int $id, ?bool $network = null ): bool {
534 645 global $wpdb;
535 - $network = DB::validate_network_param( $network );
646 + $network = validate_network_param( $network );
536 647 $table = code_snippets()->db->get_table_name( $network );
537 648
538 649 $snippet = get_snippet( $id, $network );
539 650
@@ -543,10 +654,10 @@
543 654 }
544 655
545 656 $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] );
546 657
658 + clean_snippets_cache( $table );
547 659 do_action( 'code_snippets/trash_snippet', $snippet, $network );
548 - clean_snippets_cache( $table );
549 660
550 661 return true;
551 662 }
552 663
@@ -562,16 +673,16 @@
562 673 * @since 3.8.0
563 674 */
564 675 function restore_snippet( int $id, ?bool $network = null ): bool {
565 676 global $wpdb;
566 - $network = DB::validate_network_param( $network );
677 + $network = validate_network_param( $network );
567 678 $table = code_snippets()->db->get_table_name( $network );
568 679
569 680 $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
570 681
571 682 if ( $result ) {
683 + clean_snippets_cache( $table );
572 684 do_action( 'code_snippets/restore_snippet', $id, $network );
573 - clean_snippets_cache( $table );
574 685 }
575 686
576 687 return (bool) $result;
577 688 }
@@ -642,13 +753,12 @@
642 753
643 754 // Update the last modification date if necessary.
644 755 $snippet->update_modified();
645 756
757 + // Strip any wrapper markup that came along with the pasted code.
758 + $snippet->code = normalize_snippet_code( $snippet->code, $snippet->type );
759 +
646 760 if ( 'php' === $snippet->type ) {
647 - // Remove tags from beginning and end of snippet.
648 - $snippet->code = preg_replace( '|^\s*<\?(php)?|', '', $snippet->code );
649 - $snippet->code = preg_replace( '|\?>\s*$|', '', $snippet->code );
650 -
651 761 // Deactivate snippet if code contains errors.
652 762 if ( $snippet->active && 'single-use' !== $snippet->scope ) {
653 763 test_snippet_code( $snippet );
654 764
@@ -662,16 +772,16 @@
662 772 if ( $snippet->revision && $snippet->revision > 1 ) {
663 773 $snippet->increment_revision();
664 774 }
665 775
666 - // Increment the revision number unless revision = 1 or revision is not set.
667 - if ( $snippet->revision && $snippet->revision > 1 ) {
668 - $snippet->increment_revision();
669 - }
670 -
671 776 // Shared network snippets are always considered inactive.
672 777 $snippet->active = $snippet->active && ! $snippet->shared_network;
673 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 +
674 784 // Build the list of data to insert (excluding locked, which is stored in wp_options).
675 785 $data = [
676 786 'name' => $snippet->name,
677 787 'description' => $snippet->desc,
@@ -683,12 +793,14 @@
683 793 'active' => intval( $snippet->active ),
684 794 'modified' => $snippet->modified,
685 795 'revision' => $snippet->revision,
686 796 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null,
797 + 'updated_by' => $author_id,
687 798 ];
688 799
689 800 // Create a new snippet if the ID is not set.
690 801 if ( 0 === $snippet->id ) {
802 + $data['created_by'] = $author_id;
691 803 $result = $wpdb->insert( $table, $data, '%s' );
692 804 if ( false === $result ) {
693 805 return null;
694 806 }
@@ -708,8 +820,14 @@
708 820
709 821 set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network );
710 822 $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );
711 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 +
712 830 $updated = get_snippet( $snippet->id, $snippet->network );
713 831 $updated->code_error = $snippet->code_error;
714 832 $updated->code_error_trace = $snippet->code_error_trace;
715 833
@@ -715,9 +833,10 @@
715 833
716 834 do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet );
717 835
718 836 if ( ! $updated->active && $existing->active ) {
719 - $recently_active = [ $updated->id => time() ] + get_self_option( $updated->network, 'recently_active_snippets', [] );
837 + $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
838 + $recently_active[ $updated->id ] = time();
720 839 update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
721 840 } elseif ( ! $updated->active ) {
722 841 $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
723 842
@@ -733,8 +852,40 @@
733 852 return $updated;
734 853 }
735 854
736 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;
871 + }
872 +
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 ];
885 +}
886 +
887 +/**
737 888 * Execute a snippet.
738 889 * Execute operation.
739 890 *
740 891 * Code must NOT be escaped, as it will be executed directly.
@@ -788,9 +939,9 @@
788 939 */
789 940 function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
790 941 global $wpdb;
791 942
792 - $multisite = DB::validate_network_param( $multisite );
943 + $multisite = validate_network_param( $multisite );
793 944 $table_name = code_snippets()->db->get_table_name( $multisite );
794 945
795 946 $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
796 947
@@ -816,8 +967,64 @@
816 967 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
817 968 }
818 969
819 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 +/**
820 1027 * Update a snippet entry given a list of fields.
821 1028 * Write operation.
822 1029 *
823 1030 * @param int $snippet_id ID of the snippet to update.
@@ -826,9 +1033,9 @@
826 1033 */
827 1034 function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) {
828 1035 global $wpdb;
829 1036
830 - $network = DB::validate_network_param( $network );
1037 + $network = validate_network_param( $network );
831 1038 $table = code_snippets()->db->get_table_name( $network );
832 1039
833 1040 // Build a new snippet object for the validation.
834 1041 $snippet = new Snippet();