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

snippet-ops.php in Code Snippets trunk, at php/snippet-ops.php

1,098 lines 32.6 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 update_shared_network_snippets( [ $snippet ] );
459 do_action( 'code_snippets/activate_snippet', $snippet, $network );
460 clean_snippets_cache( $table_name );
461 return $snippet;
462 }
463
464 /**
465 * Activates multiple snippets.
466 * Write operation.
467 *
468 * @param array<int> $ids The IDs of the snippets to activate.
469 * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
470 *
471 * @return Snippet[]|null Snippets which were successfully activated, or null on failure.
472 *
473 * @since 2.0.0
474 */
475 function activate_snippets( array $ids, ?bool $network = null ): ?array {
476 global $wpdb;
477 $network = validate_network_param( $network );
478 $table_name = code_snippets()->db->get_table_name( $network );
479
480 $snippets = get_snippets( $ids, $network );
481
482 if ( ! $snippets ) {
483 return null;
484 }
485
486 // Loop through each snippet code and validate individually.
487 $valid_ids = [];
488 $valid_snippets = [];
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
495 foreach ( $snippets as $snippet ) {
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 );
506 $code_error = $validator->validate();
507
508 if ( ! $code_error ) {
509 $claimed_identifiers = $validator->get_claimed_identifiers();
510 $valid_ids[] = $snippet->id;
511 $valid_snippets[] = $snippet;
512 }
513 }
514
515 // If there are no valid snippets, then we're done.
516 if ( ! $valid_ids ) {
517 return null;
518 }
519
520 // Build a SQL query containing all IDs, as wpdb::update does not support OR conditionals.
521 $ids_format = implode( ',', array_fill( 0, count( $valid_ids ), '%d' ) );
522
523 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
524 $rows_updated = $wpdb->query( $wpdb->prepare( "UPDATE $table_name SET active = 1 WHERE id IN ($ids_format)", $valid_ids ) );
525
526 if ( ! $rows_updated ) {
527 return null;
528 }
529
530 update_shared_network_snippets( $valid_snippets );
531 do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name );
532 clean_snippets_cache( $table_name );
533 return $valid_ids;
534 }
535
536 /**
537 * Deactivate a snippet.
538 * Write operation.
539 *
540 * @param int $id ID of the snippet to deactivate.
541 * @param bool|null $network Whether the snippets are multisite-wide (true) or site-wide (false).
542 *
543 * @return Snippet|null Snippet that was deactivated on success, or null on failure.
544 *
545 * @since 2.0.0
546 */
547 function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
548 global $wpdb;
549 $network = validate_network_param( $network );
550 $table = code_snippets()->db->get_table_name( $network );
551
552 // Set the snippet to inactive.
553 $result = $wpdb->update(
554 $table,
555 array( 'active' => '0' ),
556 array( 'id' => $id ),
557 array( '%d' ),
558 array( '%d' )
559 );
560
561 if ( ! $result ) {
562 return null;
563 }
564
565 // Update the recently active list.
566 $snippet = get_snippet( $id );
567 $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
568 $recently_active[ $id ] = time();
569 update_self_option( $network, 'recently_active_snippets', $recently_active );
570
571 update_shared_network_snippets( [ $snippet ] );
572 do_action( 'code_snippets/deactivate_snippet', $id, $network );
573 clean_snippets_cache( $table );
574
575 return $snippet;
576 }
577
578 /**
579 * Deletes a snippet from the database.
580 * Write operation.
581 *
582 * @param int $id ID of the snippet to delete.
583 * @param bool|null $network Delete from network-wide (true) or site-wide (false) table.
584 *
585 * @return bool Whether the snippet was deleted successfully.
586 *
587 * @since 2.0.0
588 */
589 function delete_snippet( int $id, ?bool $network = null ): bool {
590 global $wpdb;
591 $network = validate_network_param( $network );
592 $table = code_snippets()->db->get_table_name( $network );
593
594 $snippet = get_snippet( $id, $network );
595
596 // Prevent deletion of locked snippets.
597 if ( $snippet->locked ) {
598 return false;
599 }
600
601 $result = $wpdb->delete(
602 $table,
603 array( 'id' => $id ),
604 array( '%d' )
605 );
606
607 if ( $result ) {
608 do_action( 'code_snippets/delete_snippet', $snippet, $network );
609 clean_snippets_cache( $table );
610
611 $recently_active = get_self_option( $network, 'recently_active_snippets', [] );
612
613 if ( isset( $recently_active[ $id ] ) ) {
614 unset( $recently_active[ $id ] );
615 update_self_option( $network, 'recently_active_snippets', $recently_active );
616 }
617 }
618
619 return (bool) $result;
620 }
621
622 /**
623 * Trashes a snippet from the database.
624 * Write operation.
625 *
626 * @param int $id ID of the snippet to trash.
627 * @param bool|null $network Trash from network-wide (true) or site-wide (false) table.
628 *
629 * @return bool Whether the snippet was trashed successfully.
630 *
631 * @since 3.8.0
632 */
633 function trash_snippet( int $id, ?bool $network = null ): bool {
634 global $wpdb;
635 $network = validate_network_param( $network );
636 $table = code_snippets()->db->get_table_name( $network );
637
638 $snippet = get_snippet( $id, $network );
639
640 // Prevent trashing of locked snippets.
641 if ( $snippet->locked ) {
642 return false;
643 }
644
645 $wpdb->update( $table, [ 'active' => '-1' ], [ 'id' => $id ], [ '%d' ] );
646
647 do_action( 'code_snippets/trash_snippet', $snippet, $network );
648 clean_snippets_cache( $table );
649
650 return true;
651 }
652
653 /**
654 * Restore a trashed snippet by setting its active status back to 0 (inactive).
655 * Write operation.
656 *
657 * @param int $id Snippet ID to restore.
658 * @param bool|null $network Whether the snippet is multisite-wide (true) or site-wide (false).
659 *
660 * @return bool Whether the restore was successful.
661 *
662 * @since 3.8.0
663 */
664 function restore_snippet( int $id, ?bool $network = null ): bool {
665 global $wpdb;
666 $network = validate_network_param( $network );
667 $table = code_snippets()->db->get_table_name( $network );
668
669 $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
670
671 if ( $result ) {
672 do_action( 'code_snippets/restore_snippet', $id, $network );
673 clean_snippets_cache( $table );
674 }
675
676 return (bool) $result;
677 }
678
679 /**
680 * Test snippet code for errors, augmenting the snippet object.
681 *
682 * @param Snippet $snippet Snippet object.
683 */
684 function test_snippet_code( Snippet $snippet ) {
685 $snippet->code_error = null;
686 $snippet->code_error_trace = null;
687
688 if ( 'php' !== $snippet->type ) {
689 return;
690 }
691
692 $validator = new Validator( $snippet->code );
693 $result = $validator->validate();
694
695 if ( $result ) {
696 $snippet->code_error = [ $result['message'], $result['line'] ];
697 $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
698 }
699
700 if ( ! $snippet->code_error && 'single-use' !== $snippet->scope ) {
701 $result = execute_snippet( $snippet->code, $snippet->id, true );
702
703 if ( $result instanceof Throwable ) {
704 $snippet->code_error = [
705 ucfirst( rtrim( $result->getMessage(), '.' ) ) . '.',
706 $result->getLine(),
707 ];
708 $snippet->code_error_trace = $result->getTraceAsString();
709 }
710 }
711 }
712
713 /**
714 * Saves a snippet to the database.
715 * Write operation.
716 *
717 * @param Snippet|array<string, mixed> $snippet The snippet to add/update to the database.
718 *
719 * @return Snippet|null Updated snippet.
720 *
721 * @since 2.0.0
722 */
723 function save_snippet( $snippet ): ?Snippet {
724 global $wpdb;
725 $table = code_snippets()->db->get_table_name( $snippet->network );
726
727 if ( ! $snippet instanceof Snippet ) {
728 $snippet = new Snippet( $snippet );
729 }
730
731 // Prevent modification of locked snippets (allow unlocking itself).
732 if ( 0 !== $snippet->id ) {
733 $old_snippet = get_snippet( $snippet->id, $snippet->network );
734
735 if ( $old_snippet->locked && $snippet->locked ) {
736 // If it was locked and the new request still wants it locked,
737 // prevent changes to sensitive fields (code and name).
738 $snippet->code = $old_snippet->code;
739 $snippet->name = $old_snippet->name;
740 }
741 }
742
743 // Update the last modification date if necessary.
744 $snippet->update_modified();
745
746 // Strip any wrapper markup that came along with the pasted code.
747 $snippet->code = normalize_snippet_code( $snippet->code, $snippet->type );
748
749 if ( 'php' === $snippet->type ) {
750 // Deactivate snippet if code contains errors.
751 if ( $snippet->active && 'single-use' !== $snippet->scope ) {
752 test_snippet_code( $snippet );
753
754 if ( $snippet->code_error ) {
755 $snippet->active = 0;
756 }
757 }
758 }
759
760 // Increment the revision number unless revision = 1 or revision is not set.
761 if ( $snippet->revision && $snippet->revision > 1 ) {
762 $snippet->increment_revision();
763 }
764
765 // Shared network snippets are always considered inactive.
766 $snippet->active = $snippet->active && ! $snippet->shared_network;
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
773 // Build the list of data to insert (excluding locked, which is stored in wp_options).
774 $data = [
775 'name' => $snippet->name,
776 'description' => $snippet->desc,
777 'code' => $snippet->code,
778 'tags' => $snippet->tags_list,
779 'scope' => $snippet->scope,
780 'condition_id' => intval( $snippet->condition_id ),
781 'priority' => $snippet->priority,
782 'active' => intval( $snippet->active ),
783 'modified' => $snippet->modified,
784 'revision' => $snippet->revision,
785 'cloud_id' => $snippet->cloud_id_owner ? $snippet->cloud_id_owner : null,
786 'updated_by' => $author_id,
787 ];
788
789 // Create a new snippet if the ID is not set.
790 if ( 0 === $snippet->id ) {
791 $data['created_by'] = $author_id;
792 $result = $wpdb->insert( $table, $data, '%s' );
793 if ( false === $result ) {
794 return null;
795 }
796
797 $snippet->id = $wpdb->insert_id;
798 $updated = get_snippet( $snippet->id, $snippet->network );
799 $updated->code_error = $snippet->code_error;
800 $updated->code_error_trace = $snippet->code_error_trace;
801 do_action( 'code_snippets/create_snippet', $updated, $table );
802
803 if ( $updated->id > 0 ) {
804 set_snippet_locked( $updated->id, $updated->locked, $updated->network );
805 }
806 } else {
807 // Otherwise, update the snippet data.
808 $existing = get_snippet( $snippet->id, $snippet->network );
809
810 set_snippet_locked( $snippet->id, $snippet->locked, $snippet->network );
811 $wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );
812
813 $updated = get_snippet( $snippet->id, $snippet->network );
814 $updated->code_error = $snippet->code_error;
815 $updated->code_error_trace = $snippet->code_error_trace;
816
817 do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet );
818
819 if ( ! $updated->active && $existing->active ) {
820 $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
821 $recently_active[ $updated->id ] = time();
822 update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
823 } elseif ( ! $updated->active ) {
824 $recently_active = get_self_option( $updated->network, 'recently_active_snippets', [] );
825
826 if ( isset( $recently_active[ $updated->id ] ) ) {
827 unset( $recently_active[ $updated->id ] );
828 update_self_option( $updated->network, 'recently_active_snippets', $recently_active );
829 }
830 }
831 }
832
833 update_shared_network_snippets( [ $updated ] );
834 clean_snippets_cache( $table );
835 return $updated;
836 }
837
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 /**
871 * Execute a snippet.
872 * Execute operation.
873 *
874 * Code must NOT be escaped, as it will be executed directly.
875 *
876 * @param string $code Snippet code to execute.
877 * @param int $id Snippet ID.
878 * @param bool $force Force snippet execution, even if save mode is active.
879 *
880 * @return Throwable|mixed Code error if encountered during execution, or result of snippet execution otherwise.
881 *
882 * @since 2.0.0
883 * @noinspection PhpUndefinedConstantInspection
884 *
885 * phpcs:disable Squiz.PHP.Eval.Discouraged
886 */
887 function execute_snippet( string $code, int $id = 0, bool $force = false ) {
888 /**
889 * Do not continue if safe mode is active.
890 *
891 * @noinspection PhpUndefinedConstantInspection
892 */
893 if ( empty( $code ) || ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) ) {
894 return false;
895 }
896
897 ob_start();
898
899 try {
900 $result = eval( $code );
901 } catch ( Throwable $throwable ) {
902 $result = $throwable;
903 }
904
905 ob_end_clean();
906
907 do_action( 'code_snippets/after_execute_snippet', $code, $id, $result );
908 return $result;
909 }
910
911 /**
912 * Retrieve a single snippets from the database using its cloud ID.
913 *
914 * Read operation.
915 *
916 * @param string $cloud_id The Cloud ID of the snippet to retrieve.
917 * @param bool|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
918 *
919 * @return Snippet|null A single snippet object or null if no snippet was found.
920 *
921 * @since 3.5.0
922 */
923 function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
924 global $wpdb;
925
926 $multisite = validate_network_param( $multisite );
927 $table_name = code_snippets()->db->get_table_name( $multisite );
928
929 $cached_snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
930
931 // Attempt to fetch snippet from the cached list, if it exists.
932 if ( is_array( $cached_snippets ) ) {
933 foreach ( $cached_snippets as $snippet ) {
934 if ( $snippet->cloud_id === $cloud_id ) {
935 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
936 }
937 }
938 }
939
940 // Otherwise, search for the snippet from the database.
941 $snippet_data = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table_name WHERE cloud_id = %s", $cloud_id ) ); // cache pass, db call ok.
942 $snippet = $snippet_data ? new Snippet( $snippet_data ) : null;
943
944 // Load locked from wp_options if snippet exists.
945 if ( $snippet && $snippet->id > 0 ) {
946 $snippet->network = $multisite;
947 $snippet->locked = is_snippet_locked( $snippet->id, $multisite );
948 }
949
950 return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
951 }
952
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 /**
1010 * Update a snippet entry given a list of fields.
1011 * Write operation.
1012 *
1013 * @param int $snippet_id ID of the snippet to update.
1014 * @param array<string, mixed> $fields An array of fields mapped to their values.
1015 * @param bool|null $network Update in network-wide (true) or site-wide (false) table.
1016 */
1017 function update_snippet_fields( int $snippet_id, array $fields, ?bool $network = null ) {
1018 global $wpdb;
1019
1020 $network = validate_network_param( $network );
1021 $table = code_snippets()->db->get_table_name( $network );
1022
1023 // Build a new snippet object for the validation.
1024 $snippet = new Snippet();
1025 $snippet->id = $snippet_id;
1026
1027 // Validate fields through the snippet class and copy them into a clean array.
1028 $clean_fields = array();
1029 $locked_value = null;
1030
1031 foreach ( $fields as $field => $value ) {
1032 // Handle locked separately (stored in wp_options).
1033 if ( 'locked' === $field ) {
1034 if ( $snippet->set_field( $field, $value ) ) {
1035 $locked_value = $snippet->$field;
1036 }
1037 continue;
1038 }
1039
1040 if ( $snippet->set_field( $field, $value ) ) {
1041 $clean_fields[ $field ] = $snippet->$field;
1042 }
1043 }
1044
1045 // Update the snippet in the database (excluding locked).
1046 if ( ! empty( $clean_fields ) ) {
1047 $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) );
1048 }
1049
1050 // Save locked to wp_options if it was provided.
1051 if ( null !== $locked_value ) {
1052 set_snippet_locked( $snippet->id, $locked_value, $network );
1053 }
1054
1055 clean_snippets_cache( $table );
1056 $updated = get_snippet( $snippet->id, $network );
1057 if ( $updated->id ) {
1058 do_action( 'code_snippets/update_snippet', $updated, $table );
1059 }
1060 }
1061
1062 /**
1063 * Evaluate a snippet by loading it from the filesystem.
1064 *
1065 * @param string $code Snippet code.
1066 * @param string $file Snippet filename.
1067 * @param int $id Snippet ID.
1068 * @param bool $force Force snippet execution, even if save mode is active.
1069 *
1070 * @return bool|Exception|Throwable|null Code error if encountered during execution, or result of snippet execution otherwise.
1071 */
1072 function execute_snippet_from_flat_file( string $code, string $file, int $id = 0, bool $force = false ) {
1073 if ( ! is_file( $file ) ) {
1074 execute_snippet( $code, $id, $force );
1075 return true;
1076 }
1077
1078 /* @noinspection PhpUndefinedConstantInspection */
1079 if ( ! $force && defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) {
1080 return false;
1081 }
1082
1083 ob_start();
1084
1085 try {
1086 require_once $file;
1087 $result = null;
1088 } catch ( Throwable $throwable ) {
1089 $result = $throwable;
1090 }
1091
1092 ob_end_clean();
1093
1094 do_action( 'code_snippets/after_execute_snippet_from_flat_file', $file, $id );
1095
1096 return $result ?? null;
1097 }
1098