PluginProbe
Code Snippets / trunk
Code Snippets vtrunk
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 3.0.0 All 65 releases
← All changes | php/snippet-ops.php +224 -30 4.0.0-beta.1 → trunk View file →
@@ -6,14 +6,14 @@
6 6 */
7 7
8 8 namespace Code_Snippets;
9 9
10 -use Code_Snippets\Core\DB;
11 10 use Exception;
12 11 use Code_Snippets\Model\Snippet;
13 12 use Code_Snippets\Utils\Validator;
14 13 use Throwable;
15 14 use function Code_Snippets\Utils\get_self_option;
15 +use function Code_Snippets\Utils\validate_network_param;
16 16 use function Code_Snippets\Utils\update_self_option;
17 17
18 18 /**
19 19 * Get the locked status for a snippet from wp_options.
@@ -23,9 +23,9 @@
23 23 *
24 24 * @return bool Whether the snippet is locked.
25 25 */
26 26 function is_snippet_locked( int $snippet_id, ?bool $network = null ): bool {
27 - $network = DB::validate_network_param( $network );
27 + $network = validate_network_param( $network );
28 28 $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
29 29
30 30 return isset( $locked_snippets[ $snippet_id ] ) && $locked_snippets[ $snippet_id ];
31 31 }
@@ -39,9 +39,9 @@
39 39 *
40 40 * @return void
41 41 */
42 42 function set_snippet_locked( int $snippet_id, bool $locked, ?bool $network = null ): void {
43 - $network = DB::validate_network_param( $network );
43 + $network = validate_network_param( $network );
44 44 $locked_snippets = get_self_option( $network, 'code_snippets_locked', [] );
45 45
46 46 if ( $locked ) {
47 47 $locked_snippets[ $snippet_id ] = true;
@@ -63,9 +63,12 @@
63 63 function clean_active_snippets_cache( string $table_name, $scopes = false ) {
64 64 $scope_groups = $scopes
65 65 ? [ $scopes ]
66 66 : [
67 + // Content snippets.
67 68 [ 'head-content', 'body-content', 'footer-content' ],
69 +
70 + // Function snippets.
68 71 [ 'global', 'single-use', 'front-end' ],
69 72 [ 'global', 'single-use', 'admin' ],
70 73 ];
71 74
@@ -87,8 +90,90 @@
87 90 clean_active_snippets_cache( $table_name );
88 91 }
89 92
90 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 +/**
91 176 * Retrieve a list of snippets from the database.
92 177 * Read operation.
93 178 *
94 179 * @param array<string> $ids The IDs of the snippets to fetch.
@@ -97,18 +182,18 @@
97 182 * @return Snippet[] List of Snippet objects.
98 183 *
99 184 * @since 2.0
100 185 */
101 -function get_snippets( array $ids = array(), ?bool $network = null ): array {
186 +function get_snippets( array $ids = [], ?bool $network = null ): array {
102 187 global $wpdb;
103 188
104 189 // If only one ID has been passed in, defer to the get_snippet() function.
105 190 $ids_count = count( $ids );
106 191 if ( 1 === $ids_count ) {
107 - return array( get_snippet( $ids[0], $network ) );
192 + return [ get_snippet( $ids[0], $network ) ];
108 193 }
109 194
110 - $network = DB::validate_network_param( $network );
195 + $network = validate_network_param( $network );
111 196 $table_name = code_snippets()->db->get_table_name( $network );
112 197
113 198 $snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
114 199
@@ -220,17 +305,17 @@
220 305 *
221 306 * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet.
222 307 * @param bool|null $network Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
223 308 *
224 - * @return Snippet A single snippet object.
309 + * @return ?Snippet A single snippet object.
225 310 *
226 311 * @since 2.0.0
227 312 */
228 -function get_snippet( int $id = 0, ?bool $network = null ): Snippet {
313 +function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
229 314 global $wpdb;
230 315
231 316 $id = absint( $id );
232 - $network = DB::validate_network_param( $network );
317 + $network = validate_network_param( $network );
233 318 $table_name = code_snippets()->db->get_table_name( $network );
234 319
235 320 if ( 0 === $id ) {
236 321 // If an invalid ID is provided, then return an empty snippet object.
@@ -339,9 +424,9 @@
339 424 * @since 2.0.0
340 425 */
341 426 function activate_snippet( int $id, ?bool $network = null ) {
342 427 global $wpdb;
343 - $network = DB::validate_network_param( $network );
428 + $network = validate_network_param( $network );
344 429 $table_name = code_snippets()->db->get_table_name( $network );
345 430
346 431 // Retrieve the snippet code from the database for validation before activating.
347 432 $snippet = get_snippet( $id, $network );
@@ -388,9 +473,9 @@
388 473 * @since 2.0.0
389 474 */
390 475 function activate_snippets( array $ids, ?bool $network = null ): ?array {
391 476 global $wpdb;
392 - $network = DB::validate_network_param( $network );
477 + $network = validate_network_param( $network );
393 478 $table_name = code_snippets()->db->get_table_name( $network );
394 479
395 480 $snippets = get_snippets( $ids, $network );
396 481
@@ -401,13 +486,28 @@
401 486 // Loop through each snippet code and validate individually.
402 487 $valid_ids = [];
403 488 $valid_snippets = [];
404 489
490 + // Names claimed by snippets already accepted into this batch. A snippet is
491 + // otherwise validated only against what PHP has declared so far, which does
492 + // not include the other snippets about to be activated alongside it.
493 + $claimed_identifiers = [];
494 +
405 495 foreach ( $snippets as $snippet ) {
406 - $validator = new Validator( $snippet->code );
496 + // Only PHP is validated. The validator looks for redeclarations of
497 + // existing PHP functions and classes, which says nothing meaningful
498 + // about CSS or JavaScript.
499 + if ( 'php' !== $snippet->type ) {
500 + $valid_ids[] = $snippet->id;
501 + $valid_snippets[] = $snippet;
502 + continue;
503 + }
504 +
505 + $validator = new Validator( $snippet->code, $claimed_identifiers );
407 506 $code_error = $validator->validate();
408 507
409 508 if ( ! $code_error ) {
509 + $claimed_identifiers = $validator->get_claimed_identifiers();
410 510 $valid_ids[] = $snippet->id;
411 511 $valid_snippets[] = $snippet;
412 512 }
413 513 }
@@ -445,9 +545,9 @@
445 545 * @since 2.0.0
446 546 */
447 547 function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
448 548 global $wpdb;
449 - $network = DB::validate_network_param( $network );
549 + $network = validate_network_param( $network );
450 550 $table = code_snippets()->db->get_table_name( $network );
451 551
452 552 // Set the snippet to inactive.
453 553 $result = $wpdb->update(
@@ -463,9 +563,10 @@
463 563 }
464 564
465 565 // Update the recently active list.
466 566 $snippet = get_snippet( $id );
467 - $recently_active = [ $id => time() ] + get_self_option( $network, 'recently_active_snippets', [] );
567 + $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
568 + $recently_active[ $id ] = time();
468 569 update_self_option( $network, 'recently_active_snippets', $recently_active );
469 570
470 571 update_shared_network_snippets( [ $snippet ] );
471 572 do_action( 'code_snippets/deactivate_snippet', $id, $network );
@@ -486,9 +587,9 @@
486 587 * @since 2.0.0
487 588 */
488 589 function delete_snippet( int $id, ?bool $network = null ): bool {
489 590 global $wpdb;
490 - $network = DB::validate_network_param( $network );
591 + $network = validate_network_param( $network );
491 592 $table = code_snippets()->db->get_table_name( $network );
492 593
493 594 $snippet = get_snippet( $id, $network );
494 595
@@ -505,9 +606,8 @@
505 606
506 607 if ( $result ) {
507 608 do_action( 'code_snippets/delete_snippet', $snippet, $network );
508 609 clean_snippets_cache( $table );
509 - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id );
510 610
511 611 $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
512 612
513 613 if ( isset( $recently_active[ $id ] ) ) {
@@ -531,9 +631,9 @@
531 631 * @since 3.8.0
532 632 */
533 633 function trash_snippet( int $id, ?bool $network = null ): bool {
534 634 global $wpdb;
535 - $network = DB::validate_network_param( $network );
635 + $network = validate_network_param( $network );
536 636 $table = code_snippets()->db->get_table_name( $network );
537 637
538 638 $snippet = get_snippet( $id, $network );
539 639
@@ -545,9 +645,8 @@
545 645 $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] );
546 646
547 647 do_action( 'code_snippets/trash_snippet', $snippet, $network );
548 648 clean_snippets_cache( $table );
549 - code_snippets()->cloud_api->delete_snippet_from_transient_data( $id );
550 649
551 650 return true;
552 651 }
553 652
@@ -563,9 +662,9 @@
563 662 * @since 3.8.0
564 663 */
565 664 function restore_snippet( int $id, ?bool $network = null ): bool {
566 665 global $wpdb;
567 - $network = DB::validate_network_param( $network );
666 + $network = validate_network_param( $network );
568 667 $table = code_snippets()->db->get_table_name( $network );
569 668
570 669 $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
571 670
@@ -643,13 +742,12 @@
643 742
644 743 // Update the last modification date if necessary.
645 744 $snippet->update_modified();
646 745
746 + // Strip any wrapper markup that came along with the pasted code.
747 + $snippet->code = normalize_snippet_code( $snippet->code, $snippet->type );
748 +
647 749 if ( 'php' === $snippet->type ) {
648 - // Remove tags from beginning and end of snippet.
649 - $snippet->code = preg_replace( '|^\s*<\?(php)?|', '', $snippet->code );
650 - $snippet->code = preg_replace( '|\?>\s*$|', '', $snippet->code );
651 -
652 750 // Deactivate snippet if code contains errors.
653 751 if ( $snippet->active && 'single-use' !== $snippet->scope ) {
654 752 test_snippet_code( $snippet );
655 753
@@ -666,8 +764,13 @@
666 764
667 765 // Shared network snippets are always considered inactive.
668 766 $snippet->active = $snippet->active && ! $snippet->shared_network;
669 767
768 + // Snippet authorship: track who created and who last edited each snippet.
769 + // `created_by` is fixed at insert time; `updated_by` reflects every save.
770 + $current_user_id = get_current_user_id();
771 + $author_id = $current_user_id > 0 ? $current_user_id : null;
772 +
670 773 // Build the list of data to insert (excluding locked, which is stored in wp_options).
671 774 $data = [
672 775 'name' => $snippet->name,
673 776 'description' => $snippet->desc,
@@ -678,13 +781,15 @@
678 781 'priority' => $snippet->priority,
679 782 'active' => intval( $snippet->active ),
680 783 'modified' => $snippet->modified,
681 784 'revision' => $snippet->revision,
682 - 'cloud_id' => $snippet->cloud_id ? $snippet->cloud_id : null,
785 + 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null,
786 + 'updated_by' => $author_id,
683 787 ];
684 788
685 789 // Create a new snippet if the ID is not set.
686 790 if ( 0 === $snippet->id ) {
791 + $data['created_by'] = $author_id;
687 792 $result = $wpdb->insert( $table, $data, '%s' );
688 793 if ( false === $result ) {
689 794 return null;
690 795 }
@@ -689,9 +794,9 @@
689 794 return null;
690 795 }
691 796
692 797 $snippet->id = $wpdb->insert_id;
693 - $updated = get_snippet( $snippet->id );
798 + $updated = get_snippet( $snippet->id, $snippet->network );
694 799 $updated->code_error = $snippet->code_error;
695 800 $updated->code_error_trace = $snippet->code_error_trace;
696 801 do_action( 'code_snippets/create_snippet', $updated, $table );
697 802
@@ -711,9 +816,10 @@
711 816
712 817 do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet );
713 818
714 819 if ( ! $updated->active && $existing->active ) {
715 - $recently_active = [ $updated->id => time() ] + get_self_option( $updated->network, 'recently_active_snippets', [] );
820 + $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
821 + $recently_active[ $updated->id ] = time();
716 822 update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
717 823 } elseif ( ! $updated->active ) {
718 824 $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
719 825
@@ -729,8 +835,40 @@
729 835 return $updated;
730 836 }
731 837
732 838 /**
839 + * Resolve a user ID to a compact author object for display.
840 + *
841 + * Returns the user's ID, display name, and avatar URL, or null when the ID is
842 + * empty or the user no longer exists. Results are cached per request, so a list
843 + * of snippets sharing authors only triggers one lookup per distinct user.
844 + *
845 + * @param int $user_id User ID to resolve.
846 + *
847 + * @return array{id: int, display_name: string, avatar_url: string}|null
848 + */
849 +function get_snippet_author( int $user_id ): ?array {
850 + static $cache = [];
851 +
852 + if ( $user_id <= 0 ) {
853 + return null;
854 + }
855 +
856 + if ( ! array_key_exists( $user_id, $cache ) ) {
857 + $user = get_userdata( $user_id );
858 + $cache[ $user_id ] = $user ?
859 + [
860 + 'id' => $user_id,
861 + 'display_name' => $user->display_name,
862 + 'avatar_url' => (string) get_avatar_url( $user_id, [ 'size' => 32 ] ),
863 + ] :
864 + null;
865 + }
866 +
867 + return $cache[ $user_id ];
868 +}
869 +
870 +/**
733 871 * Execute a snippet.
734 872 * Execute operation.
735 873 *
736 874 * Code must NOT be escaped, as it will be executed directly.
@@ -784,9 +922,9 @@
784 922 */
785 923 function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
786 924 global $wpdb;
787 925
788 - $multisite = DB::validate_network_param( $multisite );
926 + $multisite = validate_network_param( $multisite );
789 927 $table_name = code_snippets()->db->get_table_name( $multisite );
790 928
791 929 $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
792 930
@@ -812,8 +950,64 @@
812 950 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
813 951 }
814 952
815 953 /**
954 + * Remove the wrapper markup that a snippet's code does not need.
955 + *
956 + * Snippet code is stored bare: PHP is evaluated already inside PHP, and CSS and
957 + * JavaScript are wrapped in their own tags when printed. People increasingly
958 + * paste code generated by an AI assistant, which almost always arrives wrapped
959 + * in the tags for its language and sometimes in a markdown code fence as well.
960 + *
961 + * Leaving that markup in place fails differently depending on the type, and all
962 + * three ways are unhelpful. PHP raises a syntax error, so the snippet saves and
963 + * is then quietly deactivated. CSS and JavaScript have no syntax check at all,
964 + * so they save as active and emit doubled tags on the front end with nothing
965 + * reported anywhere.
966 + *
967 + * Only a wrapper around the whole snippet is removed. Tags appearing partway
968 + * through the code are left alone, since those are the author's own.
969 + *
970 + * @param string $code Snippet code as provided.
971 + * @param string $type Snippet type: php, css, js or html.
972 + *
973 + * @return string Code with any surrounding wrapper markup removed.
974 + */
975 +function normalize_snippet_code( string $code, string $type ): string {
976 + // A markdown fence around the whole snippet, as copied from a chat window.
977 + // The closing fence only goes when an opening one was there: on its own it
978 + // is the author's content, as in an HTML snippet ending in backticks.
979 + $code = preg_replace( '/\A\s*```[a-z]*[ \t]*\R/i', '', $code, 1, $fenced );
980 +
981 + if ( $fenced ) {
982 + $code = preg_replace( '/\R\s*```\s*\z/', '', $code );
983 + }
984 +
985 + switch ( $type ) {
986 + case 'php':
987 + // `php` is matched as a whole word so that `<?phpinfo()` is not
988 + // mistaken for an opening tag followed by `info()`.
989 + $code = preg_replace( '/\A\s*<\?(?:php\b)?/i', '', $code );
990 + $code = preg_replace( '/\?>\s*\z/', '', $code );
991 + break;
992 +
993 + case 'css':
994 + $code = preg_replace( '/\A\s*<style\b[^>]*>/i', '', $code );
995 + $code = preg_replace( '/<\/style\s*>\s*\z/i', '', $code );
996 + break;
997 +
998 + case 'js':
999 + $code = preg_replace( '/\A\s*<script\b[^>]*>/i', '', $code );
1000 + $code = preg_replace( '/<\/script\s*>\s*\z/i', '', $code );
1001 + break;
1002 + }
1003 +
1004 + // Drop the single line break left behind by an opening tag on its own line,
1005 + // so the stored code does not gain a blank first line each time.
1006 + return preg_replace( '/\A\R/', '', $code );
1007 +}
1008 +
1009 +/**
816 1010 * Update a snippet entry given a list of fields.
817 1011 * Write operation.
818 1012 *
819 1013 * @param int $snippet_id ID of the snippet to update.
@@ -822,8 +1016,9 @@
822 1016 */
823 1017 function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) {
824 1018 global $wpdb;
825 1019
1020 + $network = validate_network_param( $network );
826 1021 $table = code_snippets()->db->get_table_name( $network );
827 1022
828 1023 // Build a new snippet object for the validation.
829 1024 $snippet = new Snippet();
@@ -856,14 +1051,13 @@
856 1051 if ( null !== $locked_value ) {
857 1052 set_snippet_locked( $snippet->id, $locked_value, $network );
858 1053 }
859 1054
1055 + clean_snippets_cache( $table );
860 1056 $updated = get_snippet( $snippet->id, $network );
861 1057 if ( $updated->id ) {
862 1058 do_action( 'code_snippets/update_snippet', $updated, $table );
863 1059 }
864 -
865 - clean_snippets_cache( $table );
866 1060 }
867 1061
868 1062 /**
869 1063 * Evaluate a snippet by loading it from the filesystem.
@@ -872,9 +1066,9 @@
872 1066 * @param string $file Snippet filename.
873 1067 * @param int $id Snippet ID.
874 1068 * @param bool $force Force snippet execution, even if save mode is active.
875 1069 *
876 - * @return bool|Throwable|null
1070 + * @return bool|Exception|Throwable|null Code error if encountered during execution, or result of snippet execution otherwise.
877 1071 */
878 1072 function execute_snippet_from_flat_file( string $code, string $file, int $id = 0, bool $force = false ) {
879 1073 if ( ! is_file( $file ) ) {
880 1074 execute_snippet( $code, $id, $force );