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
code-snippets / php / snippet-ops.php

snippet-ops.php in Code Snippets 4.0.0-beta.3, at php/snippet-ops.php

1,115 lines 33.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Functions to perform snippet operations
4 *
5 * @package Code_Snippets
6 */
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
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 /**
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 );
77 }
78 }
79
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 }
92
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 /**
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 *
184 * @since 2.0
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 */
248 function get_all_snippet_tags() {
249 global $wpdb;
250 $table_name = code_snippets()->db->get_table_name();
251 $cache_key = "all_snippet_tags_$table_name";
252
253 $tags = wp_cache_get( $cache_key, CACHE_GROUP );
254 if ( $tags ) {
255 return $tags;
256 }
257
258 // Grab all tags from the database.
259 $tags = array();
260 $all_tags = $wpdb->get_col( "SELECT tags FROM $table_name" );
261
262 // Merge all tags into a single array.
263 foreach ( $all_tags as $snippet_tags ) {
264 $snippet_tags = code_snippets_build_tags_array( $snippet_tags );
265 $tags = array_merge( $snippet_tags, $tags );
266 }
267
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;
272 }
273
274 /**
275 * Make sure that the tags are a valid array.
276 *
277 * @param array|string $tags The tags to convert into an array.
278 *
279 * @return array<string> The converted tags.
280 *
281 * @since 2.0.0
282 */
283 function code_snippets_build_tags_array( $tags ): array {
284
285 /* If there are no tags set, return an empty array. */
286 if ( empty( $tags ) ) {
287 return array();
288 }
289
290 /* If the tags are set as a string, convert them into an array. */
291 if ( is_string( $tags ) ) {
292 $tags = wp_strip_all_tags( $tags );
293 $tags = str_replace( ', ', ',', $tags );
294 $tags = explode( ',', $tags );
295 }
296
297 /* If we still don't have an array, just convert whatever we do have into one. */
298 return (array) $tags;
299 }
300
301 /**
302 * Retrieve a single snippets from the database.
303 * Will return empty snippet object if no snippet ID is specified.
304 * Read operation.
305 *
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).
308 *
309 * @return ?Snippet A single snippet object.
310 *
311 * @since 2.0.0
312 */
313 function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
314 global $wpdb;
315
316 $id = absint( $id );
317 $network = validate_network_param( $network );
318 $table_name = code_snippets()->db->get_table_name( $network );
319
320 if ( 0 === $id ) {
321 // If an invalid ID is provided, then return an empty snippet object.
322 $snippet = new Snippet();
323
324 } else {
325 $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
326
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 }
335
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 }
341
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
349 return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network );
350 }
351
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;
414 }
415
416 /**
417 * Activates a snippet.
418 * Write operation.
419 *
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).
422 *
423 * @return Snippet|string Snippet object on success, error message on failure.
424 * @since 2.0.0
425 */
426 function activate_snippet( int $id, ?bool $network = null ) {
427 global $wpdb;
428 $network = validate_network_param( $network );
429 $table_name = code_snippets()->db->get_table_name( $network );
430
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,
448 array( 'active' => '1' ),
449 array( 'id' => $id ),
450 array( '%d' ),
451 array( '%d' )
452 );
453
454 if ( ! $result ) {
455 return __( 'Could not activate snippet.', 'code-snippets' );
456 }
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
463 update_shared_network_snippets( [ $snippet ] );
464 do_action( 'code_snippets/activate_snippet', $snippet, $network );
465 clean_snippets_cache( $table_name );
466 return $snippet;
467 }
468
469 /**
470 * Activates multiple snippets.
471 * Write operation.
472 *
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).
475 *
476 * @return Snippet[]|null Snippets which were successfully activated, or null on failure.
477 *
478 * @since 2.0.0
479 */
480 function activate_snippets( array $ids, ?bool $network = null ): ?array {
481 global $wpdb;
482 $network = validate_network_param( $network );
483 $table_name = code_snippets()->db->get_table_name( $network );
484
485 $snippets = get_snippets( $ids, $network );
486
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(
561 $table,
562 array( 'active' => '0' ),
563 array( 'id' => $id ),
564 array( '%d' ),
565 array( '%d' )
566 );
567
568 if ( ! $result ) {
569 return null;
570 }
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
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 );
581
582 update_shared_network_snippets( [ $snippet ] );
583 do_action( 'code_snippets/deactivate_snippet', $id, $network );
584 clean_snippets_cache( $table );
585
586 return $snippet;
587 }
588
589 /**
590 * Deletes a snippet from the database.
591 * Write operation.
592 *
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.
595 *
596 * @return bool Whether the snippet was deleted successfully.
597 *
598 * @since 2.0.0
599 */
600 function delete_snippet( int $id, ?bool $network = null ): bool {
601 global $wpdb;
602 $network = validate_network_param( $network );
603 $table = code_snippets()->db->get_table_name( $network );
604
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,
614 array( 'id' => $id ),
615 array( '%d' )
616 );
617
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;
631 }
632
633 /**
634 * Trashes a snippet from the database.
635 * Write operation.
636 *
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.
639 *
640 * @return bool Whether the snippet was trashed successfully.
641 *
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.
667 *
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
674 */
675 function restore_snippet( int $id, ?bool $network = null ): bool {
676 global $wpdb;
677 $network = validate_network_param( $network );
678 $table = code_snippets()->db->get_table_name( $network );
679
680 $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
681
682 if ( $result ) {
683 clean_snippets_cache( $table );
684 do_action( 'code_snippets/restore_snippet', $id, $network );
685 }
686
687 return (bool) $result;
688 }
689
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;
698
699 if ( 'php' !== $snippet->type ) {
700 return;
701 }
702
703 $validator = new Validator( $snippet->code );
704 $result = $validator->validate();
705
706 if ( $result ) {
707 $snippet->code_error = [ $result['message'], $result['line'] ];
708 $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
709 }
710
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 }
722 }
723
724 /**
725 * Saves a snippet to the database.
726 * Write operation.
727 *
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
733 */
734 function save_snippet( $snippet ): ?Snippet {
735 global $wpdb;
736 $table = code_snippets()->db->get_table_name( $snippet->network );
737
738 if ( ! $snippet instanceof Snippet ) {
739 $snippet = new Snippet( $snippet );
740 }
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
754 // Update the last modification date if necessary.
755 $snippet->update_modified();
756
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 }
768 }
769 }
770
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;
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;
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 /**
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;
912 }
913
914 ob_start();
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 );
925 return $result;
926 }
927
928 /**
929 * Retrieve a single snippets from the database using its cloud ID.
930 *
931 * Read operation.
932 *
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
939 */
940 function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
941 global $wpdb;
942
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 }
955 }
956
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;
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
967 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
968 }
969
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 /**
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;
1036
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;
1055 }
1056
1057 if ( $snippet->set_field( $field, $value ) ) {
1058 $clean_fields[ $field ] = $snippet->$field;
1059 }
1060 }
1061
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 }
1066
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
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 }
1078
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 }
1094
1095 /* @noinspection PhpUndefinedConstantInspection */
1096 if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) {
1097 return false;
1098 }
1099
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;
1114 }
1115