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 +841 -310 3.0.0 → trunk View file →
@@ -6,123 +6,235 @@
6 6 */
7 7
8 8 namespace Code_Snippets;
9 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 +
10 18 /**
11 - * Retrieve a list of snippets from the database.
19 + * Get the locked status for a snippet from wp_options.
12 20 *
13 - * @param array $ids The IDs of the snippets to fetch.
14 - * @param bool|null $multisite Retrieve multisite-wide snippets (true) or site-wide snippets (false).
21 + * @param int $snippet_id Snippet ID.
22 + * @param bool|null $network Whether the snippet is network-wide (true) or site-wide (false).
15 23 *
16 - * @param array $args {
17 - * Optional. Arguments to specify which sorts of snippets to retrieve.
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.
18 35 *
19 - * @type bool $active_only Whether to only fetch active snippets. Default false (will fetch both active and inactive snippets).
20 - * @type int $limit Limit the number of retrieved snippets. Default 0, which will not impose a limit on the results.
21 - * @type string $orderby Sort the retrieved snippets by a particular field. Example fields include 'id', 'priority', and 'name'.
22 - * @type string $order Designates ascending or descending order of snippets. Default 'DESC'. Accepts 'ASC', 'DESC'.
23 - * }
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).
24 39 *
25 - * @return array An array of Snippet objects.
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.
26 57 *
27 - * @uses $wpdb to query the database for snippets
28 - * @uses code_snippets()->db->get_table_name() to dynamically retrieve the snippet table name
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.
29 60 *
30 - * @since 2.0
61 + * @return void
31 62 */
32 -function get_snippets( array $ids = array(), $multisite = null, array $args = array() ) {
33 - global $wpdb;
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' ],
34 69
35 - /* If only one ID has been passed in, defer to the get_snippet() function */
36 - $ids_count = count( $ids );
37 - if ( 1 === $ids_count ) {
38 - return array( get_snippet( $ids[0] ) );
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 );
39 77 }
78 +}
40 79
41 - $searchable_columns = array( 'name', 'description', 'code', 'tags' );
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 +}
42 92
43 - $args = wp_parse_args(
44 - $args,
45 - array(
46 - 'active_only' => false,
47 - 'limit' => 0,
48 - 'orderby' => '',
49 - 'order' => 'desc',
50 - 'search' => '',
51 - 'searchby' => $searchable_columns,
52 - )
53 - );
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 );
54 118
55 - $db = code_snippets()->db;
56 - $multisite = $db->validate_network_param( $multisite );
57 - $table = $db->get_table_name( $multisite );
119 + if ( null !== $flushed ) {
120 + return (bool) $flushed;
121 + }
58 122
59 - if ( 0 === $ids_count ) {
60 - $snippets = wp_cache_get( $multisite ? 'all_ms_snippets' : 'all_snippets', 'code_snippets' );
61 - if ( $snippets ) {
62 - return $snippets;
63 - }
123 + if ( ! function_exists( 'wp_cache_flush_group' ) ||
124 + ! function_exists( 'wp_cache_supports' ) ||
125 + ! wp_cache_supports( 'flush_group' ) ) {
126 + return false;
64 127 }
65 128
66 - $sql = "SELECT * FROM $table WHERE 1=1";
67 - $sql_params = array();
129 + return wp_cache_flush_group( $group );
130 +}
68 131
69 - /* Build a query for specific search terms */
70 - if ( ! empty( $args['search'] ) && ! empty( $args['searchby'] ) ) {
71 - $search = array();
72 - foreach ( $args['searchby'] as $column ) {
73 - if ( in_array( $column, $searchable_columns, true ) ) {
74 - $search[] = "$column LIKE %s";
75 - $sql_params[] = sprintf( '%%%s%%', $wpdb->esc_like( $args['search'] ) );
76 - }
77 - }
78 - $sql .= sprintf( ' AND ( %s )', implode( ' OR ', $search ) );
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 );
79 142 }
80 143
81 - /* Build a query containing the specified IDs if there are any */
82 - if ( $ids_count > 1 ) {
83 - $sql .= sprintf( ' AND id IN (%s)', implode( ',', array_fill( 0, $ids_count, '%d' ) ) );
84 - $sql_params = array_merge( $sql_params, array_values( $ids ) );
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();
85 155 }
156 +}
86 157
87 - /* Restrict the active status of retrieved snippets if requested */
88 - if ( $args['active_only'] ) {
89 - $sql .= ' AND active=1';
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 );
90 170 }
91 171
92 - /* Apply custom ordering if requested */
93 - if ( $args['orderby'] ) {
94 - $order_dir = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC';
95 - $sql .= " ORDER BY %s $order_dir";
96 - $sql_params[] = $args['orderby'];
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 ) ];
97 193 }
98 194
99 - /* Limit the number of retrieved snippets if requested */
100 - if ( intval( $args['limit'] ) > 0 ) {
101 - $sql .= ' LIMIT %d';
102 - $sql_params[] = intval( $args['limit'] );
103 - }
195 + $network = validate_network_param( $network );
196 + $table_name = code_snippets()->db->get_table_name( $network );
104 197
105 - /* Retrieve the results from the database */
106 - if ( ! empty( $sql_params ) ) {
107 - $sql = $wpdb->prepare( $sql, $sql_params );
108 - }
109 - $snippets = $wpdb->get_results( $sql, ARRAY_A );
198 + $snippets = wp_cache_get( "all_snippets_$table_name", CACHE_GROUP );
110 199
111 - if ( $snippets ) {
112 - /* Convert snippets to snippet objects */
113 - foreach ( $snippets as $index => $snippet ) {
114 - $snippet['network'] = $multisite;
115 - $snippets[ $index ] = new Snippet( $snippet );
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 );
116 223 }
117 - } else {
118 - $snippets = array();
119 224 }
120 225
121 - $snippets = apply_filters( 'code_snippets/get_snippets', $snippets, $multisite );
122 -
123 - if ( 0 === $ids_count ) {
124 - wp_cache_set( $multisite ? 'all_ms_snippets' : 'all_snippets', $snippets, 'code_snippets' );
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 + );
125 237 }
126 238
127 239 return $snippets;
128 240 }
@@ -128,33 +240,35 @@
128 240 }
129 241
130 242 /**
131 243 * Gets all used tags from the database.
244 + * Read operation.
132 245 *
133 246 * @since 2.0
134 247 */
135 248 function get_all_snippet_tags() {
136 249 global $wpdb;
250 + $table_name = code_snippets()->db->get_table_name();
251 + $cache_key = "all_snippet_tags_$table_name";
137 252
138 - $tags = wp_cache_get( 'all_snippet_tags', 'code_snippets' );
253 + $tags = wp_cache_get( $cache_key, CACHE_GROUP );
139 254 if ( $tags ) {
140 255 return $tags;
141 256 }
142 257
143 - /* Grab all tags from the database */
258 + // Grab all tags from the database.
144 259 $tags = array();
145 - $table = code_snippets()->db->get_table_name();
146 - $all_tags = $wpdb->get_col( sprintf( 'SELECT tags FROM %s', $table ) );
260 + $all_tags = $wpdb->get_col( "SELECT tags FROM $table_name" );
147 261
148 - /* Merge all tags into a single array */
262 + // Merge all tags into a single array.
149 263 foreach ( $all_tags as $snippet_tags ) {
150 264 $snippet_tags = code_snippets_build_tags_array( $snippet_tags );
151 265 $tags = array_merge( $snippet_tags, $tags );
152 266 }
153 267
154 - /* Remove duplicate tags */
268 + // Remove duplicate tags.
155 269 $tags = array_values( array_unique( $tags, SORT_REGULAR ) );
156 - wp_cache_set( 'all_snippet_tags', $tags, 'code_snippets' );
270 + wp_cache_set( $cache_key, $tags, CACHE_GROUP );
157 271 return $tags;
158 272 }
159 273
160 274 /**
@@ -159,22 +273,22 @@
159 273
160 274 /**
161 275 * Make sure that the tags are a valid array.
162 276 *
163 - * @param mixed $tags The tags to convert into an array.
277 + * @param array|string $tags The tags to convert into an array.
164 278 *
165 - * @return array The converted tags.
279 + * @return array<string> The converted tags.
166 280 *
167 281 * @since 2.0.0
168 282 */
169 -function code_snippets_build_tags_array( $tags ) {
283 +function code_snippets_build_tags_array( $tags ): array {
170 284
171 - /* If there are no tags set, return an empty array */
285 + /* If there are no tags set, return an empty array. */
172 286 if ( empty( $tags ) ) {
173 287 return array();
174 288 }
175 289
176 - /* If the tags are set as a string, convert them into an array */
290 + /* If the tags are set as a string, convert them into an array. */
177 291 if ( is_string( $tags ) ) {
178 292 $tags = wp_strip_all_tags( $tags );
179 293 $tags = str_replace( ', ', ',', $tags );
180 294 $tags = explode( ',', $tags );
@@ -179,10 +293,9 @@
179 293 $tags = str_replace( ', ', ',', $tags );
180 294 $tags = explode( ',', $tags );
181 295 }
182 296
183 - /* If we still don't have an array, just convert whatever we do have into one */
184 -
297 + /* If we still don't have an array, just convert whatever we do have into one. */
185 298 return (array) $tags;
186 299 }
187 300
188 301 /**
@@ -187,67 +300,152 @@
187 300
188 301 /**
189 302 * Retrieve a single snippets from the database.
190 303 * Will return empty snippet object if no snippet ID is specified.
304 + * Read operation.
191 305 *
192 - * @param int $id The ID of the snippet to retrieve. 0 to build a new snippet.
193 - * @param boolean|null $multisite Retrieve a multisite-wide snippet (true) or site-wide snippet (false).
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).
194 308 *
195 - * @return Snippet A single snippet object.
309 + * @return ?Snippet A single snippet object.
310 + *
196 311 * @since 2.0.0
197 312 */
198 -function get_snippet( $id = 0, $multisite = null ) {
313 +function get_snippet( int $id = 0, ?bool $network = null ): ?Snippet {
199 314 global $wpdb;
200 315
201 316 $id = absint( $id );
202 - $multisite = code_snippets()->db->validate_network_param( $multisite );
203 - $table = code_snippets()->db->get_table_name( $multisite );
317 + $network = validate_network_param( $network );
318 + $table_name = code_snippets()->db->get_table_name( $network );
204 319
205 - $cache_key = ( $multisite ? 'ms_' : '' ) . 'snippet_' . $id;
206 - $snippet = wp_cache_get( $cache_key, 'code_snippets' );
320 + if ( 0 === $id ) {
321 + // If an invalid ID is provided, then return an empty snippet object.
322 + $snippet = new Snippet();
207 323
208 - if ( $snippet ) {
209 - return $snippet;
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 );
210 340 }
211 341
212 - if ( 0 !== $id ) {
342 + $snippet->network = $network;
213 343
214 - /* Retrieve the snippet from the database */
215 - $snippet = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ) );
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 + }
216 348
217 - /* Unescape the snippet data, ready for use */
218 - $snippet = new Snippet( $snippet );
349 + return apply_filters( 'code_snippets/get_snippet', $snippet, $id, $network );
350 +}
219 351
220 - } else {
221 352
222 - /* Get an empty snippet object */
223 - $snippet = new Snippet();
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;
224 369 }
225 370
226 - $snippet->network = $multisite;
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 + }
227 380
228 - $snippet = apply_filters( 'code_snippets/get_snippet', $snippet, $id, $multisite );
229 - wp_cache_set( $cache_key, $snippet, 'code_snippets' );
230 - return $snippet;
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;
231 414 }
232 415
233 416 /**
234 - * Activates a snippet
417 + * Activates a snippet.
418 + * Write operation.
235 419 *
236 - * @param int $id ID of the snippet to activate.
237 - * @param bool|null $multisite Whether the snippets are multisite-wide (true) or site-wide (false).
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).
238 422 *
239 - * @return boolean
423 + * @return Snippet|string Snippet object on success, error message on failure.
240 424 * @since 2.0.0
241 425 */
242 -function activate_snippet( $id, $multisite = null ) {
426 +function activate_snippet( int $id, ?bool $network = null ) {
243 427 global $wpdb;
244 - $db = code_snippets()->db;
245 - $table = $db->get_table_name( $multisite );
428 + $network = validate_network_param( $network );
429 + $table_name = code_snippets()->db->get_table_name( $network );
246 430
247 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
248 - $wpdb->update(
249 - $table,
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,
250 448 array( 'active' => '1' ),
251 449 array( 'id' => $id ),
252 450 array( '%d' ),
253 451 array( '%d' )
@@ -252,109 +450,108 @@
252 450 array( '%d' ),
253 451 array( '%d' )
254 452 );
255 453
256 - /* Retrieve the snippet code from the database for validation before activating */
257 - $row = $wpdb->get_row( $wpdb->prepare( "SELECT code FROM $table WHERE id = %d;", $id ) );
258 - if ( ! $row ) {
259 - return false;
454 + if ( ! $result ) {
455 + return __( 'Could not activate snippet.', 'code-snippets' );
260 456 }
261 457
262 - $validator = new Validator( $row->code );
263 - if ( $validator->validate() ) {
264 - return false;
265 - }
266 -
267 - $wpdb->update( $table, array( 'active' => '1' ), array( 'id' => $id ), array( '%d' ), array( '%d' ) );
268 -
269 - /* Remove snippet from shared network snippet list if it was Network Activated */
270 - if ( $table === $db->ms_table ) {
271 - $shared_network_snippets = get_site_option( 'shared_network_snippets' );
272 - if ( $shared_network_snippets ) {
273 - $shared_network_snippets = array_diff( $shared_network_snippets, array( $id ) );
274 - update_site_option( 'shared_network_snippets', $shared_network_snippets );
275 - }
276 - }
277 -
278 - do_action( 'code_snippets/activate_snippet', $id, $multisite );
279 - return true;
458 + update_shared_network_snippets( [ $snippet ] );
459 + do_action( 'code_snippets/activate_snippet', $snippet, $network );
460 + clean_snippets_cache( $table_name );
461 + return $snippet;
280 462 }
281 463
282 464 /**
283 465 * Activates multiple snippets.
466 + * Write operation.
284 467 *
285 - * @param array $ids The IDs of the snippets to activate.
286 - * @param bool|null $multisite Whether the snippets are multisite-wide (true) or site-wide (false).
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).
287 470 *
288 - * @return array The IDs of the snippets which were successfully activated.
471 + * @return Snippet[]|null Snippets which were successfully activated, or null on failure.
289 472 *
290 473 * @since 2.0.0
291 474 */
292 -function activate_snippets( array $ids, $multisite = null ) {
475 +function activate_snippets( array $ids, ?bool $network = null ): ?array {
293 476 global $wpdb;
294 - $db = code_snippets()->db;
295 - $table = $db->get_table_name( $multisite );
477 + $network = validate_network_param( $network );
478 + $table_name = code_snippets()->db->get_table_name( $network );
296 479
297 - /* Build SQL query containing all the provided snippet IDs */
298 - $ids_format = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
299 - $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, code FROM $table WHERE id IN ($ids_format)", $ids ) );
480 + $snippets = get_snippets( $ids, $network );
300 481
301 - if ( ! $rows ) {
302 - return array();
482 + if ( ! $snippets ) {
483 + return null;
303 484 }
304 485
305 - /* Loop through each snippet code and validate individually */
306 - $valid_ids = array();
486 + // Loop through each snippet code and validate individually.
487 + $valid_ids = [];
488 + $valid_snippets = [];
307 489
308 - foreach ( $rows as $row ) {
309 - $validator = new Validator( $row->code );
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 );
310 506 $code_error = $validator->validate();
311 507
312 508 if ( ! $code_error ) {
313 - $valid_ids[] = $row->id;
509 + $claimed_identifiers = $validator->get_claimed_identifiers();
510 + $valid_ids[] = $snippet->id;
511 + $valid_snippets[] = $snippet;
314 512 }
315 513 }
316 514
317 - /* If there are no valid snippets, then we're done */
515 + // If there are no valid snippets, then we're done.
318 516 if ( ! $valid_ids ) {
319 - return $valid_ids;
517 + return null;
320 518 }
321 519
322 - /* Build SQL query containing all the valid snippet IDs and activate the valid snippets */
520 + // Build a SQL query containing all IDs, as wpdb::update does not support OR conditionals.
323 521 $ids_format = implode( ',', array_fill( 0, count( $valid_ids ), '%d' ) );
324 - $wpdb->query( $wpdb->prepare( "UPDATE $table SET active = 1 WHERE id IN ($ids_format)", $valid_ids ) );
325 522
326 - /* Remove snippet from shared network snippet list if it was Network Activated */
327 - if ( $table === $db->ms_table ) {
328 - $shared_network_snippets = get_site_option( 'shared_network_snippets' );
329 - if ( $shared_network_snippets ) {
330 - $shared_network_snippets = array_diff( $shared_network_snippets, $valid_ids );
331 - update_site_option( 'shared_network_snippets', $shared_network_snippets );
332 - }
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;
333 528 }
334 529
335 - do_action( 'code_snippets/activate_snippets', $valid_ids, $multisite );
530 + update_shared_network_snippets( $valid_snippets );
531 + do_action( 'code_snippets/activate_snippets', $valid_snippets, $table_name );
532 + clean_snippets_cache( $table_name );
336 533 return $valid_ids;
337 534 }
338 535
339 536 /**
340 - * Deactivate a snippet
537 + * Deactivate a snippet.
538 + * Write operation.
341 539 *
342 - * @param int $id ID of the snippet to deactivate.
343 - * @param bool|null $multisite Whether the snippets are multisite-wide (true) or site-wide (false).
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).
344 542 *
345 - * @uses $wpdb to set the snippets' active status
543 + * @return Snippet|null Snippet that was deactivated on success, or null on failure.
544 + *
346 545 * @since 2.0.0
347 546 */
348 -function deactivate_snippet( $id, $multisite = null ) {
547 +function deactivate_snippet( int $id, ?bool $network = null ): ?Snippet {
349 548 global $wpdb;
350 - $db = code_snippets()->db;
351 - $table = $db->get_table_name( $multisite );
549 + $network = validate_network_param( $network );
550 + $table = code_snippets()->db->get_table_name( $network );
352 551
353 - /* Set the snippet to active */
354 -
355 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
356 - $wpdb->update(
552 + // Set the snippet to inactive.
553 + $result = $wpdb->update(
357 554 $table,
358 555 array( 'active' => '0' ),
359 556 array( 'id' => $id ),
360 557 array( '%d' ),
@@ -360,207 +557,541 @@
360 557 array( '%d' ),
361 558 array( '%d' )
362 559 );
363 560
364 - /* Update the recently active list */
561 + if ( ! $result ) {
562 + return null;
563 + }
365 564
366 - $recently_active = array( $id => time() );
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 );
367 570
368 - if ( $table === $db->table ) {
571 + update_shared_network_snippets( [ $snippet ] );
572 + do_action( 'code_snippets/deactivate_snippet', $id, $network );
573 + clean_snippets_cache( $table );
369 574
370 - update_option(
371 - 'recently_activated_snippets',
372 - $recently_active + (array) get_option( 'recently_activated_snippets', array() )
373 - );
374 -
375 - } elseif ( $table === $db->ms_table ) {
376 -
377 - update_site_option(
378 - 'recently_activated_snippets',
379 - $recently_active + (array) get_site_option( 'recently_activated_snippets', array() )
380 - );
381 - }
382 -
383 - do_action( 'code_snippets/deactivate_snippet', $id, $multisite );
575 + return $snippet;
384 576 }
385 577
386 578 /**
387 - * Deletes a snippet from the database
579 + * Deletes a snippet from the database.
580 + * Write operation.
388 581 *
389 - * @param int $id ID of the snippet to delete.
390 - * @param bool|null $multisite Delete from network-wide (true) or site-wide (false) table.
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.
391 584 *
585 + * @return bool Whether the snippet was deleted successfully.
586 + *
392 587 * @since 2.0.0
393 588 */
394 -function delete_snippet( $id, $multisite = null ) {
589 +function delete_snippet( int $id, ?bool $network = null ): bool {
395 590 global $wpdb;
591 + $network = validate_network_param( $network );
592 + $table = code_snippets()->db->get_table_name( $network );
396 593
397 - // phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
398 - $wpdb->delete(
399 - code_snippets()->db->get_table_name( $multisite ),
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,
400 603 array( 'id' => $id ),
401 604 array( '%d' )
402 605 );
403 606
404 - do_action( 'code_snippets/delete_snippet', $id, $multisite );
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;
405 620 }
406 621
407 622 /**
408 - * Saves a snippet to the database.
623 + * Trashes a snippet from the database.
624 + * Write operation.
409 625 *
410 - * @param Snippet $snippet The snippet to add/update to the database.
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.
411 628 *
412 - * @return int ID of the snippet
629 + * @return bool Whether the snippet was trashed successfully.
413 630 *
414 - * @since 2.0.0
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.
415 656 *
416 - * phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
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
417 663 */
418 -function save_snippet( Snippet $snippet ) {
664 +function restore_snippet( int $id, ?bool $network = null ): bool {
419 665 global $wpdb;
666 + $network = validate_network_param( $network );
667 + $table = code_snippets()->db->get_table_name( $network );
420 668
421 - $table = code_snippets()->db->get_table_name( $snippet->network );
669 + $result = $wpdb->update( $table, [ 'active' => '0' ], [ 'id' => $id ], [ '%d' ] );
422 670
423 - /* Update the last modification date and the creation date if necessary */
424 - $snippet->update_modified();
671 + if ( $result ) {
672 + do_action( 'code_snippets/restore_snippet', $id, $network );
673 + clean_snippets_cache( $table );
674 + }
425 675
426 - /* Build array of data to insert */
427 - $data = array(
428 - 'name' => $snippet->name,
429 - 'description' => $snippet->desc,
430 - 'code' => $snippet->code,
431 - 'tags' => $snippet->tags_list,
432 - 'scope' => $snippet->scope,
433 - 'priority' => $snippet->priority,
434 - 'active' => intval( $snippet->active ),
435 - 'modified' => $snippet->modified,
436 - );
676 + return (bool) $result;
677 +}
437 678
438 - /* Create a new snippet if the ID is not set */
439 - if ( 0 === $snippet->id ) {
440 - $wpdb->insert( $table, $data, '%s' );
441 - $snippet->id = $wpdb->insert_id;
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;
442 687
443 - do_action( 'code_snippets/create_snippet', $snippet->id, $table );
444 - } else {
688 + if ( 'php' !== $snippet->type ) {
689 + return;
690 + }
445 691
446 - /* Otherwise, update the snippet data */
447 - $wpdb->update( $table, $data, array( 'id' => $snippet->id ), null, array( '%d' ) );
692 + $validator = new Validator( $snippet->code );
693 + $result = $validator->validate();
448 694
449 - do_action( 'code_snippets/update_snippet', $snippet->id, $table );
695 + if ( $result ) {
696 + $snippet->code_error = [ $result['message'], $result['line'] ];
697 + $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
450 698 }
451 699
452 - return $snippet->id;
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 + }
453 711 }
454 712
455 713 /**
456 - * Update a snippet entry given a list of fields
714 + * Saves a snippet to the database.
715 + * Write operation.
457 716 *
458 - * @param int $snippet_id ID of the snippet to update.
459 - * @param array $fields An array of fields mapped to their values.
460 - * @param bool|null $network Delete from network-wide (true) or site-wide (false) table.
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
461 722 */
462 -function update_snippet_fields( $snippet_id, $fields, $network = null ) {
723 +function save_snippet( $snippet ): ?Snippet {
463 724 global $wpdb;
725 + $table = code_snippets()->db->get_table_name( $snippet->network );
464 726
465 - $table = code_snippets()->db->get_table_name( $network );
727 + if ( ! $snippet instanceof Snippet ) {
728 + $snippet = new Snippet( $snippet );
729 + }
466 730
467 - /* Build a new snippet object for the validation */
468 - $snippet = new Snippet();
469 - $snippet->id = $snippet_id;
731 + // Prevent modification of locked snippets (allow unlocking itself).
732 + if ( 0 !== $snippet->id ) {
733 + $old_snippet = get_snippet( $snippet->id, $snippet->network );
470 734
471 - /* Validate fields through the snippet class and copy them into a clean array */
472 - $clean_fields = array();
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 + }
473 742
474 - foreach ( $fields as $field => $value ) {
743 + // Update the last modification date if necessary.
744 + $snippet->update_modified();
475 745
476 - if ( $snippet->set_field( $field, $value ) ) {
477 - $clean_fields[ $field ] = $snippet->$field;
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 + }
478 757 }
479 758 }
480 759
481 - /* Update the snippet in the database */
482 - $wpdb->update( $table, $clean_fields, array( 'id' => $snippet->id ), null, array( '%d' ) );
483 - do_action( 'code_snippets/update_snippet', $snippet->id, $table );
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;
484 836 }
485 837
486 838 /**
487 - * Execute a snippet
839 + * Resolve a user ID to a compact author object for display.
488 840 *
489 - * Code must NOT be escaped, as it will be executed directly.
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.
490 844 *
491 - * @param string $code Snippet code to execute.
492 - * @param int $id Snippet ID.
493 - * @param bool $catch_output Whether to attempt to suppress the output of execution using buffers.
845 + * @param int $user_id User ID to resolve.
494 846 *
495 - * @return mixed Result of the code execution
496 - * @since 2.0.0
847 + * @return array{id: int, display_name: string, avatar_url: string}|null
497 848 */
498 -function execute_snippet( $code, $id = 0, $catch_output = true ) {
849 +function get_snippet_author( int $user_id ): ?array {
850 + static $cache = [];
499 851
500 - if ( empty( $code ) || defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE ) {
501 - return false;
852 + if ( $user_id <= 0 ) {
853 + return null;
502 854 }
503 855
504 - if ( $catch_output ) {
505 - ob_start();
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;
506 865 }
507 866
508 - $result = eval( $code );
867 + return $cache[ $user_id ];
868 +}
509 869
510 - if ( $catch_output ) {
511 - ob_end_clean();
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;
512 895 }
513 896
514 - do_action( 'code_snippets/after_execute_snippet', $id, $code, $result );
897 + ob_start();
515 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 );
516 908 return $result;
517 909 }
518 910
519 911 /**
520 - * Run the active snippets
912 + * Retrieve a single snippets from the database using its cloud ID.
521 913 *
522 - * @return bool true on success, false on failure
914 + * Read operation.
523 915 *
524 - * @since 2.0.0
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).
525 918 *
526 - * phpcs:disable WordPress.DB.DirectDatabaseQuery.NoCaching
919 + * @return Snippet|null A single snippet object or null if no snippet was found.
920 + *
921 + * @since 3.5.0
527 922 */
528 -function execute_active_snippets() {
923 +function get_snippet_by_cloud_id( string $cloud_id, ?bool $multisite = null ): ?Snippet {
529 924 global $wpdb;
530 925
531 - /* Bail early if safe mode is active */
532 - if ( defined( 'CODE_SNIPPETS_SAFE_MODE' ) && CODE_SNIPPETS_SAFE_MODE || ! apply_filters( 'code_snippets/execute_snippets', true ) ) {
533 - return false;
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 + }
534 938 }
535 939
536 - $db = code_snippets()->db;
537 - $scopes = array( 'global', 'single-use', is_admin() ? 'admin' : 'front-end' );
538 - /** Manually specify select list. @noinspection PhpRedundantOptionalArgumentInspection */
539 - $data = $db->fetch_active_snippets( $scopes, 'id, code, scope' );
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;
540 943
541 - foreach ( $data as $table_name => $active_snippets ) {
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 + }
542 949
543 - /* Loop through the returned snippets and execute the PHP code */
544 - foreach ( $active_snippets as $snippet ) {
545 - $snippet_id = intval( $snippet['id'] );
546 - $code = $snippet['code'];
950 + return apply_filters( 'code_snippets/get_snippet_by_cloud_id', $snippet, $cloud_id, $multisite );
951 +}
547 952
548 - // if the snippet is a single-use snippet, deactivate it before execution to ensure that the process always happens
549 - if ( 'single-use' === $snippet['scope'] ) {
550 - if ( $table_name === $db->ms_table && isset( $active_shared_ids ) && in_array( $snippet_id, $active_shared_ids, true ) ) {
551 - unset( $active_shared_ids[ array_search( $snippet_id, $active_shared_ids, true ) ] );
552 - $active_shared_ids = array_values( $active_shared_ids );
553 - update_option( 'active_shared_network_snippets', $active_shared_ids );
554 - } else {
555 - $wpdb->update( $table_name, array( 'active' => '0' ), array( 'id' => $snippet_id ), array( '%d' ), array( '%d' ) );
556 - }
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;
557 1036 }
1037 + continue;
1038 + }
558 1039
559 - if ( apply_filters( 'code_snippets/allow_execute_snippet', true, $snippet_id, $table_name ) ) {
560 - execute_snippet( $code, $snippet_id );
561 - }
1040 + if ( $snippet->set_field( $field, $value ) ) {
1041 + $clean_fields[ $field ] = $snippet->$field;
562 1042 }
563 1043 }
564 1044
565 - return true;
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;
566 1097 }