PluginProbe
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts / 2.7.9
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts v2.7.9
2.7.9 2.7.8 2.7.7 2.7.6 2.7.5 2.7.4 trunk 1.3 2.0.4 2.0.6 2.1.91 2.2.4 2.2.7 2.2.9 2.3.1 2.3.10 2.4.10 2.4.2 2.4.4 2.4.5 2.4.6 2.4.7 2.4.8 2.4.9 2.6.0 All 30 releases
insert-php / includes / class.abilities.php

class.abilities.php in Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts 2.7.9, at includes/class.abilities.php

1,489 lines 48.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registers the plugin abilities with the WordPress Abilities API.
4 *
5 * @package Woody_Code_Snippets
6 */
7
8 // Exit if accessed directly.
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * WINP_Abilities class.
15 */
16 class WINP_Abilities {
17
18 /**
19 * Ability category slug.
20 */
21 const CATEGORY = 'woody-snippets';
22
23 /**
24 * Maximum number of snippets returned per page.
25 */
26 const MAX_PER_PAGE = 100;
27
28 /**
29 * Maximum length of the conditions JSON string.
30 */
31 const MAX_CONDITIONS_LENGTH = 20000;
32
33 /**
34 * Maximum nesting depth of a condition value.
35 */
36 const MAX_VALUE_DEPTH = 4;
37
38 /**
39 * Constructor.
40 */
41 public function __construct() {
42 add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] );
43 add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] );
44 }
45
46 /**
47 * Register the ability category.
48 *
49 * @return void
50 */
51 public function register_category() {
52 if ( ! function_exists( 'wp_register_ability_category' ) ) {
53 return;
54 }
55
56 wp_register_ability_category(
57 self::CATEGORY,
58 [
59 'label' => __( 'Woody Code Snippets', 'insert-php' ),
60 'description' => __( 'Manage Woody code snippets: code, insertion location, display conditions and activation state.', 'insert-php' ),
61 ]
62 );
63 }
64
65 /**
66 * Register the abilities.
67 *
68 * @return void
69 */
70 public function register_abilities() {
71 if ( ! function_exists( 'wp_register_ability' ) ) {
72 return;
73 }
74
75 wp_register_ability(
76 'woody/list-snippets',
77 [
78 'label' => __( 'List snippets', 'insert-php' ),
79 'description' => __( 'Lists Woody snippets with their type, insertion scope, location and activation state. Snippet code is not included; use woody/get-snippet to read it.', 'insert-php' ),
80 'category' => self::CATEGORY,
81 'input_schema' => $this->get_list_input_schema(),
82 'output_schema' => [
83 'type' => 'object',
84 'properties' => [
85 'snippets' => [
86 'type' => 'array',
87 'items' => $this->get_snippet_schema( false ),
88 ],
89 'total' => [ 'type' => 'integer' ],
90 'page' => [ 'type' => 'integer' ],
91 'per_page' => [ 'type' => 'integer' ],
92 'total_pages' => [ 'type' => 'integer' ],
93 ],
94 ],
95 'execute_callback' => [ $this, 'list_snippets' ],
96 'permission_callback' => [ $this, 'can_manage_snippets' ],
97 'meta' => [
98 'annotations' => [
99 'readonly' => true,
100 'destructive' => false,
101 'idempotent' => true,
102 ],
103 'show_in_rest' => true,
104 ],
105 ]
106 );
107
108 wp_register_ability(
109 'woody/get-snippet',
110 [
111 'label' => __( 'Get snippet', 'insert-php' ),
112 'description' => __( 'Returns one Woody snippet with its code, type, insertion scope, location, display conditions and activation state.', 'insert-php' ),
113 'category' => self::CATEGORY,
114 'input_schema' => [
115 'type' => 'object',
116 'properties' => [
117 'snippet_id' => [
118 'type' => 'integer',
119 'description' => __( 'Snippet ID.', 'insert-php' ),
120 'minimum' => 1,
121 ],
122 ],
123 'required' => [ 'snippet_id' ],
124 'additionalProperties' => false,
125 ],
126 'output_schema' => $this->get_snippet_schema( true ),
127 'execute_callback' => [ $this, 'get_snippet' ],
128 'permission_callback' => [ $this, 'can_manage_snippets' ],
129 'meta' => [
130 'annotations' => [
131 'readonly' => true,
132 'destructive' => false,
133 'idempotent' => true,
134 ],
135 'show_in_rest' => true,
136 ],
137 ]
138 );
139
140 wp_register_ability(
141 'woody/upsert-snippet',
142 [
143 'label' => __( 'Create or update snippet', 'insert-php' ),
144 'description' => __( 'Creates a snippet (always inactive) or updates the code and execution settings of an existing one. The code is never run: new or changed PHP and universal code is only checked for syntax errors and, unless the snippet is active, for functions and classes whose names are already in use; nothing is saved when the check fails. With dry_run=true only the check runs and nothing is saved. Changing the code or scope of an active PHP or universal snippet saves it inactive, because its new code has not run yet; after other updates an active snippet stays active only when the "activate by default" setting is on. This ability never activates a snippet; use woody/set-snippet-state, which runs the code once to check for errors. CSS and JS snippet code cannot be written through this ability.', 'insert-php' ),
145 'category' => self::CATEGORY,
146 'input_schema' => $this->get_upsert_input_schema(),
147 'output_schema' => [
148 'type' => 'object',
149 'properties' => [
150 'dry_run' => [ 'type' => 'boolean' ],
151 'operation' => [
152 'type' => 'string',
153 'enum' => [ 'create', 'update' ],
154 ],
155 'valid' => [ 'type' => 'boolean' ],
156 'validated' => [
157 'type' => 'boolean',
158 'description' => __( 'Whether the code was checked for syntax errors; it is never run. False when the code and scope did not change, for snippet types that do not run PHP, and when the PHP tokenizer extension is missing.', 'insert-php' ),
159 ],
160 'deactivated' => [
161 'type' => 'boolean',
162 'description' => __( 'Whether saving left (or, with dry_run, would leave) a previously active snippet inactive.', 'insert-php' ),
163 ],
164 'message' => [ 'type' => 'string' ],
165 'revision_id' => [ 'type' => 'integer' ],
166 'snippet' => $this->get_snippet_schema( true ),
167 ],
168 ],
169 'execute_callback' => [ $this, 'upsert_snippet' ],
170 'permission_callback' => [ $this, 'can_manage_snippets' ],
171 'meta' => [
172 'annotations' => [
173 'readonly' => false,
174 'destructive' => true,
175 'idempotent' => false,
176 ],
177 'show_in_rest' => true,
178 'ai_connect' => false,
179 ],
180 ]
181 );
182
183 wp_register_ability(
184 'woody/set-snippet-state',
185 [
186 'label' => __( 'Activate or deactivate snippet', 'insert-php' ),
187 'description' => __( 'Activates or deactivates one snippet. Activating a PHP or universal snippet that runs everywhere or is inserted automatically first runs its code once with output discarded, and is refused when the code raises an error or a warning or returns false. Activating such an HTML snippet runs the check of the activation switch in the snippets list.', 'insert-php' ),
188 'category' => self::CATEGORY,
189 'input_schema' => [
190 'type' => 'object',
191 'properties' => [
192 'snippet_id' => [
193 'type' => 'integer',
194 'description' => __( 'Snippet ID.', 'insert-php' ),
195 'minimum' => 1,
196 ],
197 'state' => [
198 'type' => 'string',
199 'description' => __( 'Target state.', 'insert-php' ),
200 'enum' => [ 'active', 'inactive' ],
201 ],
202 ],
203 'required' => [ 'snippet_id', 'state' ],
204 'additionalProperties' => false,
205 ],
206 'output_schema' => [
207 'type' => 'object',
208 'properties' => [
209 'changed' => [ 'type' => 'boolean' ],
210 'snippet' => $this->get_snippet_schema( false ),
211 ],
212 ],
213 'execute_callback' => [ $this, 'set_snippet_state' ],
214 'permission_callback' => [ $this, 'can_manage_snippets' ],
215 'meta' => [
216 'annotations' => [
217 'readonly' => false,
218 'destructive' => true,
219 'idempotent' => true,
220 ],
221 'show_in_rest' => true,
222 'ai_connect' => false,
223 ],
224 ]
225 );
226 }
227
228 /**
229 * Permission check shared by all abilities.
230 *
231 * Mirrors the snippets admin screens: the plugin capability gate plus the
232 * capability of the snippets post type.
233 *
234 * @return bool
235 */
236 public function can_manage_snippets() {
237 if ( ! WINP_Plugin::app()->current_user_car() ) {
238 return false;
239 }
240
241 return current_user_can( $this->get_post_type_cap( 'edit_posts' ) );
242 }
243
244 /**
245 * Get a capability of the snippets post type.
246 *
247 * @param string $cap Post type capability key.
248 *
249 * @return string
250 */
251 private function get_post_type_cap( $cap ) {
252 $post_type = get_post_type_object( WINP_SNIPPETS_POST_TYPE );
253
254 if ( $post_type && isset( $post_type->cap->$cap ) ) {
255 return (string) $post_type->cap->$cap;
256 }
257
258 // Same value the post type registration maps 'edit_posts' and 'create_posts' to.
259 return 'edit_' . WINP_SNIPPETS_POST_TYPE . 's';
260 }
261
262 /**
263 * Execute callback: list snippets.
264 *
265 * @param array<string, mixed>|null $input Ability input.
266 *
267 * @return array<string, mixed>
268 */
269 public function list_snippets( $input = null ) {
270 $input = is_array( $input ) ? $input : [];
271 $page = isset( $input['page'] ) ? max( 1, (int) $input['page'] ) : 1;
272 $per_page = isset( $input['per_page'] ) ? (int) $input['per_page'] : 20;
273 $per_page = min( self::MAX_PER_PAGE, max( 1, $per_page ) );
274 $status = isset( $input['status'] ) ? sanitize_key( (string) $input['status'] ) : 'any';
275
276 $statuses = [
277 'any' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
278 'publish' => [ 'publish' ],
279 'draft' => [ 'draft' ],
280 'trash' => [ 'trash' ],
281 ];
282
283 $args = [
284 'post_type' => WINP_SNIPPETS_POST_TYPE,
285 'post_status' => isset( $statuses[ $status ] ) ? $statuses[ $status ] : $statuses['any'],
286 // As on the snippets list screen: private snippets of other users need read_private_posts.
287 'perm' => 'readable',
288 'posts_per_page' => $per_page,
289 'paged' => $page,
290 'orderby' => 'ID',
291 'order' => 'DESC',
292 ];
293
294 if ( ! empty( $input['search'] ) ) {
295 $args['s'] = sanitize_text_field( (string) $input['search'] );
296 }
297
298 $meta_query = [];
299
300 if ( ! empty( $input['type'] ) ) {
301 $type = sanitize_key( (string) $input['type'] );
302
303 if ( WINP_SNIPPET_TYPE_PHP === $type ) {
304 // Snippets without a stored type are PHP snippets.
305 $meta_query[] = [
306 'relation' => 'OR',
307 [
308 'key' => 'wbcr_inp_snippet_type',
309 'value' => $type,
310 ],
311 [
312 'key' => 'wbcr_inp_snippet_type',
313 'compare' => 'NOT EXISTS',
314 ],
315 ];
316 } else {
317 $meta_query[] = [
318 'key' => 'wbcr_inp_snippet_type',
319 'value' => $type,
320 ];
321 }
322 }
323
324 if ( ! empty( $input['scope'] ) ) {
325 $meta_query[] = [
326 'key' => 'wbcr_inp_snippet_scope',
327 'value' => $this->scope_to_meta( sanitize_key( (string) $input['scope'] ) ),
328 ];
329 }
330
331 if ( isset( $input['active'] ) ) {
332 if ( $input['active'] ) {
333 $meta_query[] = [
334 'key' => 'wbcr_inp_snippet_activate',
335 'value' => '1',
336 ];
337 } else {
338 $meta_query[] = [
339 'relation' => 'OR',
340 [
341 'key' => 'wbcr_inp_snippet_activate',
342 'value' => '1',
343 'compare' => '!=',
344 ],
345 [
346 'key' => 'wbcr_inp_snippet_activate',
347 'compare' => 'NOT EXISTS',
348 ],
349 ];
350 }
351 }
352
353 if ( ! empty( $meta_query ) ) {
354 $meta_query['relation'] = 'AND';
355 $args['meta_query'] = $meta_query;
356 }
357
358 $query = new WP_Query( $args );
359 $snippets = [];
360
361 foreach ( $query->posts as $post ) {
362 if ( $post instanceof WP_Post ) {
363 $snippets[] = $this->format_snippet( $post, false );
364 }
365 }
366
367 return [
368 'snippets' => $snippets,
369 'total' => (int) $query->found_posts,
370 'page' => $page,
371 'per_page' => $per_page,
372 'total_pages' => (int) $query->max_num_pages,
373 ];
374 }
375
376 /**
377 * Execute callback: get one snippet.
378 *
379 * @param array<string, mixed>|null $input Ability input.
380 *
381 * @return array<string, mixed>|WP_Error
382 */
383 public function get_snippet( $input = null ) {
384 $input = is_array( $input ) ? $input : [];
385 $post = $this->get_snippet_post( isset( $input['snippet_id'] ) ? (int) $input['snippet_id'] : 0 );
386
387 if ( is_wp_error( $post ) ) {
388 return $post;
389 }
390
391 // Same check the snippet editor applies before showing the code (post.php, edit_post).
392 if ( ! current_user_can( 'edit_post', $post->ID ) ) {
393 return new WP_Error( 'woody_forbidden', __( 'You are not allowed to edit this snippet.', 'insert-php' ) );
394 }
395
396 return $this->format_snippet( $post, true );
397 }
398
399 /**
400 * Execute callback: create or update a snippet.
401 *
402 * @param array<string, mixed>|null $input Ability input.
403 *
404 * @return array<string, mixed>|WP_Error
405 */
406 public function upsert_snippet( $input = null ) {
407 $input = is_array( $input ) ? $input : [];
408 $dry_run = ! empty( $input['dry_run'] );
409 $snippet_id = isset( $input['snippet_id'] ) ? (int) $input['snippet_id'] : 0;
410 $post = null;
411
412 if ( ! post_type_exists( WINP_SNIPPETS_POST_TYPE ) ) {
413 return new WP_Error( 'woody_unavailable', __( 'The snippets post type is not registered in this context.', 'insert-php' ) );
414 }
415
416 if ( $snippet_id ) {
417 $post = $this->get_snippet_post( $snippet_id );
418
419 if ( is_wp_error( $post ) ) {
420 return $post;
421 }
422
423 if ( 'trash' === $post->post_status ) {
424 return new WP_Error( 'woody_snippet_trashed', __( 'The snippet is in the trash and cannot be edited.', 'insert-php' ) );
425 }
426
427 if ( ! current_user_can( 'edit_post', $snippet_id ) ) {
428 return new WP_Error( 'woody_forbidden', __( 'You are not allowed to edit this snippet.', 'insert-php' ) );
429 }
430 } elseif ( ! current_user_can( $this->get_post_type_cap( 'create_posts' ) ) ) {
431 return new WP_Error( 'woody_forbidden', __( 'You are not allowed to create snippets.', 'insert-php' ) );
432 }
433
434 $data = $this->resolve_snippet_data( $input, $post );
435
436 if ( is_wp_error( $data ) ) {
437 return $data;
438 }
439
440 $was_active = $post ? $this->is_active( $post->ID ) : false;
441 $is_executable = in_array( $data['type'], [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL ], true );
442 $validation = [
443 'valid' => true,
444 'checked' => false,
445 'message' => '',
446 ];
447
448 if ( $is_executable && '' !== trim( $data['code'] ) && $data['execution_changed'] ) {
449 $validation = $this->check_code( $data, $was_active );
450 }
451
452 // New snippets are always inactive. Like the editor, an update keeps an active snippet active only when the
453 // "activate by default" setting is on, and changed executable code is saved inactive because it has not run yet.
454 $keep_active = $was_active && ! ( $is_executable && $data['execution_changed'] ) && get_option( 'wbcr_inp_activate_by_default', true );
455 $deactivated = $was_active && ! $keep_active;
456 $operation = $post ? 'update' : 'create';
457
458 if ( $dry_run ) {
459 $message = $validation['message'];
460
461 if ( $validation['valid'] && $deactivated ) {
462 $message = __( 'Saving will leave the snippet inactive. Activate it again with woody/set-snippet-state.', 'insert-php' );
463 }
464
465 return [
466 'dry_run' => true,
467 'operation' => $operation,
468 'valid' => $validation['valid'],
469 'validated' => $validation['checked'],
470 'deactivated' => $validation['valid'] && $deactivated,
471 'message' => $message,
472 ];
473 }
474
475 if ( ! $validation['valid'] ) {
476 return new WP_Error( 'woody_invalid_code', $validation['message'] );
477 }
478
479 $saved_id = $this->save_snippet( $data, $post, $keep_active );
480
481 if ( is_wp_error( $saved_id ) ) {
482 return $saved_id;
483 }
484
485 $saved = get_post( $saved_id );
486
487 if ( ! $saved instanceof WP_Post ) {
488 return new WP_Error( 'woody_save_failed', __( 'The snippet could not be saved.', 'insert-php' ) );
489 }
490
491 $revisions = wp_get_post_revisions(
492 $saved_id,
493 [
494 'posts_per_page' => 1,
495 'fields' => 'ids',
496 ]
497 );
498
499 $latest = reset( $revisions );
500 $revision_id = $latest instanceof WP_Post ? $latest->ID : ( is_int( $latest ) ? $latest : 0 );
501
502 return [
503 'dry_run' => false,
504 'operation' => $operation,
505 'valid' => true,
506 'validated' => $validation['checked'],
507 'deactivated' => $deactivated,
508 'message' => $deactivated ? __( 'The snippet was saved inactive. Activate it again with woody/set-snippet-state.', 'insert-php' ) : '',
509 'revision_id' => $revision_id,
510 'snippet' => $this->format_snippet( $saved, true ),
511 ];
512 }
513
514 /**
515 * Check new or changed executable code without running it.
516 *
517 * @param array<string, mixed> $data Resolved snippet data.
518 * @param bool $was_active Whether the snippet was active before the update.
519 *
520 * @return array{valid:bool,checked:bool,message:string}
521 */
522 private function check_code( array $data, bool $was_active ): array {
523 $code = $this->prepare_code( $data['code'], $data['type'] );
524 $validation = WINP_Code_Validator::check_syntax( $code, $data['type'] );
525
526 // An active snippet may already have run in this request, so its own declarations would be
527 // reported as conflicts; activating it again runs the same check once it is inactive.
528 if ( ! $validation['valid'] || $was_active ) {
529 return $validation;
530 }
531
532 $redeclaration_error = WINP_Code_Validator::get_redeclaration_error( $code, $data['type'] );
533
534 if ( null !== $redeclaration_error ) {
535 $validation['valid'] = false;
536 $validation['message'] = $redeclaration_error->getMessage();
537 }
538
539 return $validation;
540 }
541
542 /**
543 * Execute callback: activate or deactivate a snippet.
544 *
545 * @param array<string, mixed>|null $input Ability input.
546 *
547 * @return array<string, mixed>|WP_Error
548 */
549 public function set_snippet_state( $input = null ) {
550 $input = is_array( $input ) ? $input : [];
551 $snippet_id = isset( $input['snippet_id'] ) ? (int) $input['snippet_id'] : 0;
552 $state = isset( $input['state'] ) ? sanitize_key( (string) $input['state'] ) : '';
553
554 if ( ! in_array( $state, [ 'active', 'inactive' ], true ) ) {
555 return new WP_Error( 'woody_invalid_state', __( 'State must be "active" or "inactive".', 'insert-php' ) );
556 }
557
558 $post = $this->get_snippet_post( $snippet_id );
559
560 if ( is_wp_error( $post ) ) {
561 return $post;
562 }
563
564 if ( post_type_exists( WINP_SNIPPETS_POST_TYPE ) && ! current_user_can( 'edit_post', $snippet_id ) ) {
565 return new WP_Error( 'woody_forbidden', __( 'You are not allowed to edit this snippet.', 'insert-php' ) );
566 }
567
568 $is_active = $this->is_active( $snippet_id );
569 $activate = 'active' === $state;
570
571 if ( $activate === $is_active ) {
572 return [
573 'changed' => false,
574 'snippet' => $this->format_snippet( $post, false ),
575 ];
576 }
577
578 if ( $activate ) {
579 if ( 'trash' === $post->post_status ) {
580 return new WP_Error( 'woody_snippet_trashed', __( 'The snippet is in the trash and cannot be activated.', 'insert-php' ) );
581 }
582
583 $error = $this->check_before_activation( $post );
584
585 if ( is_wp_error( $error ) ) {
586 return $error;
587 }
588 }
589
590 update_post_meta( $snippet_id, 'wbcr_inp_snippet_activate', $activate ? 1 : 0 );
591
592 return [
593 'changed' => true,
594 'snippet' => $this->format_snippet( $post, false ),
595 ];
596 }
597
598 /**
599 * Run the checks the activation switch runs before a snippet is activated.
600 *
601 * @param WP_Post $post Snippet post.
602 *
603 * @return true|WP_Error
604 */
605 private function check_before_activation( WP_Post $post ) {
606 $scope = (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_scope', true );
607 $type = $this->get_type( $post->ID );
608
609 $skipped_types = [ WINP_SNIPPET_TYPE_TEXT, WINP_SNIPPET_TYPE_AD, WINP_SNIPPET_TYPE_CSS, WINP_SNIPPET_TYPE_JS ];
610
611 if ( ! in_array( $scope, [ 'evrywhere', 'auto' ], true ) || in_array( $type, $skipped_types, true ) ) {
612 return true;
613 }
614
615 if ( WINP_Helper::is_safe_mode() ) {
616 return new WP_Error( 'woody_safe_mode', __( 'Safe mode is enabled, snippet code cannot be executed. Disable safe mode first.', 'insert-php' ) );
617 }
618
619 if ( in_array( $type, [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL ], true ) ) {
620 return $this->run_activation_check( $post, $type );
621 }
622
623 $error = WINP_Plugin::app()->get_execute_object()->getSnippetError( $post->ID );
624
625 if ( $error ) {
626 return $this->activation_error( is_array( $error ) && isset( $error['message'] ) ? (string) $error['message'] : '' );
627 }
628
629 return true;
630 }
631
632 /**
633 * Run executable snippet code once before it is activated.
634 *
635 * Saving never runs the code, so this is the first run. The executor runs it, so a
636 * fatal error is still attributed to the snippet and logged. Like the editor's
637 * validation, errors, warnings raised by the snippet's own code and a false return
638 * refuse the activation.
639 *
640 * @param WP_Post $post Snippet post.
641 * @param string $type Snippet type.
642 *
643 * @return true|WP_Error
644 */
645 private function run_activation_check( WP_Post $post, string $type ) {
646 $code = $this->prepare_code( (string) WINP_Helper::get_snippet_code( $post ), $type );
647
648 // The executor skips empty code, '0' included, and reports it as a false return.
649 if ( empty( $code ) ) {
650 return true;
651 }
652
653 $redeclaration_error = WINP_Code_Validator::get_redeclaration_error( $code, $type );
654
655 // Redeclaring a function or class inside eval() ends the request, so detect it before running the snippet.
656 if ( null !== $redeclaration_error ) {
657 return $this->activation_error( $redeclaration_error->getMessage() );
658 }
659
660 $warnings = [];
661 $result = null;
662 $executed = false;
663 $failure = null;
664 $buffer_level = ob_get_level();
665 $previous = self::get_error_handler();
666 $marker = static function (): bool {
667 return false;
668 };
669
670 // The marker sits below the check's handler, so cleanup knows where to stop.
671 set_error_handler( $marker ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
672 set_error_handler( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
673 static function ( int $errno, string $errstr, string $errfile, int $errline ) use ( &$warnings ): bool {
674 // Like the editor's save check, errors raised outside the snippet's own code go to PHP's standard handler.
675 if ( false === strpos( $errfile, "eval()'d code" ) ) {
676 return false;
677 }
678
679 // translators: %1$d is the line number, %2$s is the error message.
680 $warnings[] = sprintf( __( 'Line %1$d: %2$s', 'insert-php' ), $errline, $errstr );
681
682 return true;
683 }
684 );
685
686 try {
687 $result = WINP_Plugin::app()->get_execute_object()->executeSnippet( $code, $post->ID );
688 $executed = true;
689
690 // Closing a buffer the snippet left open runs its callback, which can throw as well.
691 self::discard_output_buffers( $buffer_level );
692 } catch ( Throwable $e ) {
693 // The executor leaves the snippet context open when the code throws.
694 if ( ! $executed ) {
695 WINP_Error_Handler::clear_current_snippet();
696 }
697
698 $failure = $e;
699 } finally {
700 self::restore_error_handlers( $marker, $previous );
701 }
702
703 if ( null !== $failure ) {
704 self::discard_output_buffers( $buffer_level );
705
706 return $this->activation_error(
707 false !== strpos( $failure->getFile(), "eval()'d code" )
708 // translators: %1$d is the line number, %2$s is the error message.
709 ? sprintf( __( 'Error on line %1$d: %2$s', 'insert-php' ), $failure->getLine(), $failure->getMessage() )
710 // translators: %s is the error message.
711 : sprintf( __( 'Error: %s', 'insert-php' ), $failure->getMessage() )
712 );
713 }
714
715 if ( ! empty( $warnings ) ) {
716 return $this->activation_error( implode( "\n", $warnings ) );
717 }
718
719 if ( false === $result ) {
720 return $this->activation_error( __( 'The code returned false.', 'insert-php' ) );
721 }
722
723 return true;
724 }
725
726 /**
727 * Build the error returned when a snippet is not activated.
728 *
729 * @param string $message Error details.
730 *
731 * @return WP_Error
732 */
733 private function activation_error( string $message ): WP_Error {
734 return new WP_Error(
735 'woody_invalid_code',
736 trim( __( 'The snippet was not activated because its code contains an error.', 'insert-php' ) . ' ' . wp_strip_all_tags( $message ) )
737 );
738 }
739
740 /**
741 * Remove the check's handlers and any the snippet installed above them.
742 *
743 * PHP reads an empty handler stack and a null entry the same way, so the
744 * check installs a marker below its own handler and cleanup stops there.
745 *
746 * @param Closure $marker Handler installed below the check's handler; it returns false.
747 * @param callable|null $previous Handler that was active before the check. Not typed natively:
748 * a private method another class registered is not callable here.
749 * @phpstan-param Closure(): bool $marker
750 *
751 * @return void
752 */
753 private static function restore_error_handlers( Closure $marker, $previous ): void {
754 $null_reads = 0;
755
756 // Only a long run of null reads, which an emptied stack gives forever, ends the loop before the marker.
757 while ( $null_reads < 100 ) {
758 $current = self::get_error_handler();
759
760 if ( $current === $marker ) {
761 restore_error_handler();
762 return;
763 }
764
765 // The snippet removed the marker as well.
766 if ( null !== $previous && $current === $previous ) {
767 return;
768 }
769
770 $null_reads = null === $current ? $null_reads + 1 : 0;
771 restore_error_handler();
772 }
773 }
774
775 /**
776 * Read the active error handler without changing it.
777 *
778 * @return callable|null Not typed natively: a private method another class registered is not callable here.
779 */
780 private static function get_error_handler() {
781 $current = set_error_handler( '__return_false' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler
782 restore_error_handler();
783
784 return $current;
785 }
786
787 /**
788 * Close the output buffers opened above a level, stopping at one that cannot be removed.
789 *
790 * A snippet can open a buffer without the removable flag, which ob_end_clean() cannot close.
791 * A callback on a buffer can throw when the buffer closes; the caller handles that.
792 *
793 * @param int $level Buffer level to return to.
794 *
795 * @return void
796 */
797 private static function discard_output_buffers( int $level ): void {
798 while ( ob_get_level() > $level ) {
799 $buffer = ob_get_status();
800
801 if ( ! ( (int) ( $buffer['flags'] ?? 0 ) & PHP_OUTPUT_HANDLER_REMOVABLE ) ) {
802 return;
803 }
804
805 ob_end_clean();
806 }
807 }
808
809 /**
810 * Merge the input over the stored snippet and validate the result.
811 *
812 * @param array<string, mixed> $input Ability input.
813 * @param WP_Post|null $post Existing snippet, null when creating.
814 *
815 * @return array<string, mixed>|WP_Error
816 */
817 private function resolve_snippet_data( $input, $post ) {
818 $old_type = $post ? $this->get_type( $post->ID ) : '';
819 $old_scope = $post ? (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_scope', true ) : '';
820 $old_scope = '' === $old_scope ? 'shortcode' : $old_scope;
821 $old_code = $post ? (string) WINP_Helper::get_snippet_code( $post ) : '';
822
823 if ( ! $post ) {
824 foreach ( [ 'title', 'code', 'type' ] as $required ) {
825 if ( ! isset( $input[ $required ] ) || '' === trim( (string) $input[ $required ] ) ) {
826 // translators: %s is the name of the missing input field.
827 return new WP_Error( 'woody_missing_field', sprintf( __( 'The "%s" field is required to create a snippet.', 'insert-php' ), $required ) );
828 }
829 }
830 }
831
832 $type = isset( $input['type'] ) ? sanitize_key( (string) $input['type'] ) : $old_type;
833
834 if ( $post && $type !== $old_type ) {
835 return new WP_Error( 'woody_type_immutable', __( 'The type of an existing snippet cannot be changed.', 'insert-php' ) );
836 }
837
838 $writable_types = [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL, WINP_SNIPPET_TYPE_HTML, WINP_SNIPPET_TYPE_TEXT, WINP_SNIPPET_TYPE_AD ];
839 $file_types = [ WINP_SNIPPET_TYPE_CSS, WINP_SNIPPET_TYPE_JS ];
840
841 if ( in_array( $type, $file_types, true ) ) {
842 // CSS and JS snippets are served from a file that only the editor save handler generates.
843 if ( ! $post || isset( $input['code'] ) ) {
844 return new WP_Error( 'woody_type_not_supported', __( 'The code of CSS and JS snippets can only be saved from the snippet editor.', 'insert-php' ) );
845 }
846 } elseif ( ! in_array( $type, $writable_types, true ) ) {
847 return new WP_Error( 'woody_invalid_type', __( 'Unknown snippet type.', 'insert-php' ) );
848 }
849
850 $title = $post ? $post->post_title : '';
851
852 if ( isset( $input['title'] ) ) {
853 $title = sanitize_text_field( (string) $input['title'] );
854
855 if ( '' === $title ) {
856 return new WP_Error( 'woody_missing_field', __( 'The snippet title cannot be empty.', 'insert-php' ) );
857 }
858 }
859
860 $code = isset( $input['code'] ) ? (string) $input['code'] : $old_code;
861
862 // Scope: PHP snippets run everywhere or by shortcode, the other types are inserted automatically or by shortcode.
863 $scope = isset( $input['scope'] ) ? $this->scope_to_meta( sanitize_key( (string) $input['scope'] ) ) : $old_scope;
864
865 $allowed_scopes = WINP_SNIPPET_TYPE_PHP === $type ? [ 'evrywhere', 'shortcode' ] : [ 'auto', 'shortcode' ];
866
867 if ( ! in_array( $scope, $allowed_scopes, true ) ) {
868 return new WP_Error(
869 'woody_invalid_scope',
870 WINP_SNIPPET_TYPE_PHP === $type
871 ? __( 'PHP snippets support the "everywhere" and "shortcode" scopes.', 'insert-php' )
872 : __( 'This snippet type supports the "auto" and "shortcode" scopes.', 'insert-php' )
873 );
874 }
875
876 $data = [
877 'type' => $type,
878 'title' => $title,
879 'code' => $code,
880 'scope' => $scope,
881 'execution_changed' => ! $post || $code !== $old_code || $scope !== $old_scope,
882 ];
883
884 if ( isset( $input['description'] ) ) {
885 $data['description'] = sanitize_text_field( (string) $input['description'] );
886 }
887
888 $has_placement = isset( $input['location'] ) || isset( $input['location_number'] );
889
890 if ( WINP_SNIPPET_TYPE_PHP === $type ) {
891 if ( $has_placement || isset( $input['conditions'] ) ) {
892 return new WP_Error( 'woody_not_applicable', __( 'PHP snippets do not support an insertion location or display conditions.', 'insert-php' ) );
893 }
894
895 return $data;
896 }
897
898 if ( $has_placement || ! $post ) {
899 $placement = $this->resolve_placement( $input, $post );
900
901 if ( is_wp_error( $placement ) ) {
902 return $placement;
903 }
904
905 $data['location'] = $placement['location'];
906 $data['location_number'] = $placement['location_number'];
907 }
908
909 if ( isset( $input['conditions'] ) ) {
910 $conditions = $this->parse_conditions( (string) $input['conditions'] );
911
912 if ( is_wp_error( $conditions ) ) {
913 return $conditions;
914 }
915
916 $data['conditions'] = $conditions;
917 }
918
919 return $data;
920 }
921
922 /**
923 * Validate the automatic insertion location.
924 *
925 * @param array<string, mixed> $input Ability input.
926 * @param WP_Post|null $post Existing snippet, null when creating.
927 *
928 * @return array{location:string,location_number:int}|WP_Error
929 */
930 private function resolve_placement( $input, $post ) {
931 $location = $post ? (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_location', true ) : '';
932 $number = $post ? (int) get_post_meta( $post->ID, 'wbcr_inp_snippet_p_number', true ) : 0;
933
934 if ( isset( $input['location'] ) ) {
935 $location = sanitize_key( (string) $input['location'] );
936 }
937
938 if ( isset( $input['location_number'] ) ) {
939 $number = max( 0, (int) $input['location_number'] );
940 }
941
942 // The editor default.
943 $location = '' === $location ? 'header' : $location;
944 $locations = ( new WINP_Insertion_Locations() )->getList();
945
946 if ( ! isset( $locations[ $location ] ) ) {
947 return new WP_Error(
948 'woody_invalid_location',
949 // translators: %s is a comma separated list of location slugs.
950 sprintf( __( 'Unknown insertion location. Available locations: %s.', 'insert-php' ), implode( ', ', array_keys( $locations ) ) )
951 );
952 }
953
954 // The editor disables the WooCommerce locations when WooCommerce is not active.
955 if ( 0 === strpos( $location, 'woo_' ) && ! class_exists( 'WooCommerce' ) ) {
956 return new WP_Error( 'woody_woocommerce_required', __( 'WooCommerce locations require WooCommerce to be active.', 'insert-php' ) );
957 }
958
959 $settings = isset( $locations[ $location ][2] ) && is_array( $locations[ $location ][2] ) ? $locations[ $location ][2] : [];
960 $requires_number = ! empty( $settings['requiresLocationNumber'] );
961
962 if ( $requires_number && $number < 1 ) {
963 return new WP_Error( 'woody_location_number_required', __( 'This insertion location requires a location_number of 1 or more.', 'insert-php' ) );
964 }
965
966 return [
967 'location' => $location,
968 'location_number' => $number,
969 ];
970 }
971
972 /**
973 * Get the upgrade link reported by the abilities that need the premium plugin.
974 *
975 * @param string $area Gated feature, used as the campaign.
976 *
977 * @return string
978 */
979 private function get_upgrade_url( $area ) {
980 return tsdk_utmify( WINP_UPGRADE, $area, 'mcp' );
981 }
982
983 /**
984 * Parse and validate the display conditions JSON.
985 *
986 * The stored structure is the one the conditions editor posts: a list of
987 * groups, each holding AND-ed scopes that hold OR-ed conditions.
988 *
989 * @param string $json Conditions JSON.
990 *
991 * @return array<int, stdClass>|WP_Error
992 */
993 private function parse_conditions( $json ) {
994 if ( strlen( $json ) > self::MAX_CONDITIONS_LENGTH ) {
995 return new WP_Error( 'woody_invalid_conditions', __( 'The conditions value is too long.', 'insert-php' ) );
996 }
997
998 $json = trim( $json );
999
1000 if ( '' === $json ) {
1001 return [];
1002 }
1003
1004 $groups = json_decode( $json );
1005
1006 if ( ! is_array( $groups ) ) {
1007 return new WP_Error( 'woody_invalid_conditions', __( 'Conditions must be a JSON array of condition groups.', 'insert-php' ) );
1008 }
1009
1010 $free_params = [
1011 'user-role',
1012 'user-registered',
1013 'user-cookie-name',
1014 'location-page',
1015 'location-referrer',
1016 'location-post-type',
1017 'location-taxonomy',
1018 'page-taxonomy',
1019 'location-some-page',
1020 ];
1021 $operators = [ 'equals', 'notequal', 'less', 'older', 'greater', 'younger', 'contains', 'notcontain', 'between' ];
1022 $is_premium = WINP_Plugin::app()->is_premium();
1023 $result = [];
1024
1025 foreach ( $groups as $group ) {
1026 if ( ! is_object( $group ) || ! isset( $group->conditions ) || ! is_array( $group->conditions ) ) {
1027 return new WP_Error( 'woody_invalid_conditions', __( 'Each condition group needs a "conditions" array.', 'insert-php' ) );
1028 }
1029
1030 $group_type = isset( $group->type ) ? sanitize_key( (string) $group->type ) : 'showif';
1031
1032 if ( ! in_array( $group_type, [ 'showif', 'hideif' ], true ) ) {
1033 return new WP_Error( 'woody_invalid_conditions', __( 'A condition group type must be "showif" or "hideif".', 'insert-php' ) );
1034 }
1035
1036 $scopes = [];
1037
1038 foreach ( $group->conditions as $scope ) {
1039 if ( ! is_object( $scope ) || ! isset( $scope->conditions ) || ! is_array( $scope->conditions ) ) {
1040 return new WP_Error( 'woody_invalid_conditions', __( 'Each condition scope needs a "conditions" array.', 'insert-php' ) );
1041 }
1042
1043 $conditions = [];
1044
1045 foreach ( $scope->conditions as $condition ) {
1046 if ( ! is_object( $condition ) || ! isset( $condition->param, $condition->operator ) ) {
1047 return new WP_Error( 'woody_invalid_conditions', __( 'Each condition needs a "param" and an "operator".', 'insert-php' ) );
1048 }
1049
1050 $param = sanitize_key( (string) $condition->param );
1051 $operator = sanitize_key( (string) $condition->operator );
1052
1053 if ( ! in_array( $operator, $operators, true ) ) {
1054 // translators: %s is a comma separated list of operators.
1055 return new WP_Error( 'woody_invalid_conditions', sprintf( __( 'Unknown condition operator. Available operators: %s.', 'insert-php' ), implode( ', ', $operators ) ) );
1056 }
1057
1058 $is_premium_param = 0 === strpos( $param, 'technology-' ) || 0 === strpos( $param, 'auditory-' );
1059
1060 if ( $is_premium_param && ! $is_premium ) {
1061 $upgrade_url = $this->get_upgrade_url( 'display-conditions' );
1062
1063 return new WP_Error(
1064 'woody_premium_required',
1065 // translators: %1$s is the condition parameter, %2$s is the upgrade URL.
1066 sprintf( __( 'The "%1$s" condition requires Woody Code Snippets Premium. Upgrade: %2$s', 'insert-php' ), $param, $upgrade_url ),
1067 [ 'upgrade_url' => $upgrade_url ]
1068 );
1069 }
1070
1071 if ( ! $is_premium_param && ! in_array( $param, $free_params, true ) ) {
1072 // translators: %s is a comma separated list of condition parameters.
1073 return new WP_Error( 'woody_invalid_conditions', sprintf( __( 'Unknown condition param. Available params: %s.', 'insert-php' ), implode( ', ', $free_params ) ) );
1074 }
1075
1076 $clean = new stdClass();
1077 $clean->param = $param;
1078 $clean->operator = $operator;
1079 $clean->type = isset( $condition->type ) ? sanitize_key( (string) $condition->type ) : 'text';
1080 $clean->value = $this->sanitize_condition_value( isset( $condition->value ) ? $condition->value : '', 0 );
1081
1082 $conditions[] = $clean;
1083 }
1084
1085 $clean_scope = new stdClass();
1086 $clean_scope->type = 'scope';
1087 $clean_scope->conditions = $conditions;
1088
1089 $scopes[] = $clean_scope;
1090 }
1091
1092 $clean_group = new stdClass();
1093 $clean_group->conditions = $scopes;
1094 $clean_group->type = $group_type;
1095
1096 $result[] = $clean_group;
1097 }
1098
1099 return $result;
1100 }
1101
1102 /**
1103 * Sanitize a condition value, keeping its scalar, list or object shape.
1104 *
1105 * @param mixed $value Raw value.
1106 * @param int $depth Current nesting depth.
1107 *
1108 * @return mixed
1109 */
1110 private function sanitize_condition_value( $value, $depth ) {
1111 if ( $depth > self::MAX_VALUE_DEPTH ) {
1112 return '';
1113 }
1114
1115 if ( is_object( $value ) ) {
1116 $clean = new stdClass();
1117
1118 foreach ( get_object_vars( $value ) as $key => $item ) {
1119 $key = preg_replace( '/[^A-Za-z0-9_\-]/', '', (string) $key );
1120
1121 if ( '' !== $key && null !== $key ) {
1122 $clean->$key = $this->sanitize_condition_value( $item, $depth + 1 );
1123 }
1124 }
1125
1126 return $clean;
1127 }
1128
1129 if ( is_array( $value ) ) {
1130 $clean = [];
1131
1132 foreach ( $value as $item ) {
1133 $clean[] = $this->sanitize_condition_value( $item, $depth + 1 );
1134 }
1135
1136 return $clean;
1137 }
1138
1139 if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
1140 return $value;
1141 }
1142
1143 return sanitize_text_field( (string) $value );
1144 }
1145
1146 /**
1147 * Persist the snippet.
1148 *
1149 * @param array<string, mixed> $data Validated snippet data.
1150 * @param WP_Post|null $post Existing snippet, null when creating.
1151 * @param bool $keep_active Whether an updated snippet stays active; new snippets are always inactive.
1152 *
1153 * @return int|WP_Error Snippet ID.
1154 */
1155 private function save_snippet( $data, $post, $keep_active ) {
1156 $postarr = [
1157 'post_title' => $data['title'],
1158 'post_content' => $data['code'],
1159 'post_type' => WINP_SNIPPETS_POST_TYPE,
1160 ];
1161
1162 if ( $post ) {
1163 $postarr['ID'] = $post->ID;
1164 } else {
1165 $postarr['post_status'] = 'publish';
1166 }
1167
1168 // Like the editor, keep the content filters away from code; text and ad snippets stay filtered.
1169 $is_code = ! in_array( $data['type'], [ WINP_SNIPPET_TYPE_TEXT, WINP_SNIPPET_TYPE_AD ], true );
1170 $had_kses = $is_code && false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' );
1171 $had_rel = $is_code && false !== has_filter( 'content_save_pre', 'wp_targeted_link_rel' );
1172
1173 if ( $had_kses ) {
1174 kses_remove_filters();
1175 }
1176
1177 if ( $had_rel ) {
1178 remove_filter( 'content_save_pre', 'wp_targeted_link_rel' );
1179 }
1180
1181 // Switch the snippet off before its new code is stored, so no request in between runs that code.
1182 if ( $post && ! $keep_active ) {
1183 update_post_meta( $post->ID, 'wbcr_inp_snippet_activate', 0 );
1184 }
1185
1186 $saved_id = $post ? wp_update_post( wp_slash( $postarr ), true ) : wp_insert_post( wp_slash( $postarr ), true );
1187
1188 if ( $had_kses ) {
1189 kses_init_filters();
1190 }
1191
1192 if ( $had_rel ) {
1193 add_filter( 'content_save_pre', 'wp_targeted_link_rel' );
1194 }
1195
1196 if ( is_wp_error( $saved_id ) ) {
1197 return $saved_id;
1198 }
1199
1200 update_post_meta( $saved_id, 'wbcr_inp_snippet_type', $data['type'] );
1201 update_post_meta( $saved_id, 'wbcr_inp_snippet_scope', $data['scope'] );
1202
1203 if ( isset( $data['location'] ) ) {
1204 update_post_meta( $saved_id, 'wbcr_inp_snippet_location', $data['location'] );
1205 update_post_meta( $saved_id, 'wbcr_inp_snippet_p_number', (string) $data['location_number'] );
1206 }
1207
1208 if ( isset( $data['description'] ) ) {
1209 update_post_meta( $saved_id, 'wbcr_inp_snippet_description', $data['description'] );
1210 }
1211
1212 if ( isset( $data['conditions'] ) ) {
1213 update_post_meta( $saved_id, 'wbcr_inp_snippet_filters', empty( $data['conditions'] ) ? '' : $data['conditions'] );
1214 update_post_meta( $saved_id, 'wbcr_inp_changed_filters', 1 );
1215 }
1216
1217 if ( ! $post ) {
1218 update_post_meta( $saved_id, 'wbcr_inp_snippet_priority', WINP_Helper::get_next_snippet_priority() );
1219 }
1220
1221 update_post_meta( $saved_id, 'wbcr_inp_snippet_activate', $post && $keep_active ? 1 : 0 );
1222
1223 return $saved_id;
1224 }
1225
1226 /**
1227 * Remove the PHP tags the executor removes before evaluating a PHP snippet.
1228 *
1229 * Same replacements as WINP_Execute_Snippet::prepareCode(), which needs a
1230 * saved snippet to resolve the type.
1231 *
1232 * @param string $code Snippet code.
1233 * @param string $type Snippet type.
1234 *
1235 * @return string
1236 */
1237 private function prepare_code( $code, $type ) {
1238 if ( WINP_SNIPPET_TYPE_PHP !== $type ) {
1239 return $code;
1240 }
1241
1242 $code = (string) preg_replace( '|^[\s]*<\?(php)?|', '', $code );
1243
1244 return (string) preg_replace( '|\?>[\s]*$|', '', $code );
1245 }
1246
1247 /**
1248 * Get a snippet post by ID.
1249 *
1250 * @param int $snippet_id Snippet ID.
1251 *
1252 * @return WP_Post|WP_Error
1253 */
1254 private function get_snippet_post( $snippet_id ) {
1255 $post = $snippet_id > 0 ? get_post( $snippet_id ) : null;
1256
1257 if ( ! $post instanceof WP_Post || WINP_SNIPPETS_POST_TYPE !== $post->post_type || 'auto-draft' === $post->post_status ) {
1258 return new WP_Error( 'woody_snippet_not_found', __( 'Snippet not found.', 'insert-php' ) );
1259 }
1260
1261 return $post;
1262 }
1263
1264 /**
1265 * Get the stored snippet type.
1266 *
1267 * @param int $snippet_id Snippet ID.
1268 *
1269 * @return string
1270 */
1271 private function get_type( $snippet_id ) {
1272 $type = get_post_meta( $snippet_id, 'wbcr_inp_snippet_type', true );
1273
1274 return is_string( $type ) && '' !== $type ? $type : WINP_SNIPPET_TYPE_PHP;
1275 }
1276
1277 /**
1278 * Whether the snippet is activated.
1279 *
1280 * @param int $snippet_id Snippet ID.
1281 *
1282 * @return bool
1283 */
1284 private function is_active( $snippet_id ) {
1285 return (bool) (int) get_post_meta( $snippet_id, 'wbcr_inp_snippet_activate', true );
1286 }
1287
1288 /**
1289 * Convert the public scope name to the stored value.
1290 *
1291 * @param string $scope Public scope name.
1292 *
1293 * @return string
1294 */
1295 private function scope_to_meta( $scope ) {
1296 return 'everywhere' === $scope ? 'evrywhere' : $scope;
1297 }
1298
1299 /**
1300 * Build the public representation of a snippet.
1301 *
1302 * @param WP_Post $post Snippet post.
1303 * @param bool $with_code Whether to include the code and display conditions.
1304 *
1305 * @return array<string, mixed>
1306 */
1307 private function format_snippet( WP_Post $post, $with_code ) {
1308 require_once WINP_PLUGIN_DIR . '/includes/class.snippet.php';
1309
1310 $type = $this->get_type( $post->ID );
1311 $scope = (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_scope', true );
1312 $scope = '' === $scope ? 'shortcode' : $scope;
1313
1314 $snippet = [
1315 'id' => $post->ID,
1316 'title' => $post->post_title,
1317 'type' => $type,
1318 'status' => $post->post_status,
1319 'active' => $this->is_active( $post->ID ),
1320 'scope' => 'evrywhere' === $scope ? 'everywhere' : $scope,
1321 'location' => (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_location', true ),
1322 'location_number' => (int) get_post_meta( $post->ID, 'wbcr_inp_snippet_p_number', true ),
1323 'priority' => (int) get_post_meta( $post->ID, 'wbcr_inp_snippet_priority', true ),
1324 'description' => (string) get_post_meta( $post->ID, 'wbcr_inp_snippet_description', true ),
1325 'shortcode' => wp_specialchars_decode( WINP_Helper::get_shortcode_text( $post ), ENT_QUOTES ),
1326 // False when DISALLOW_UNFILTERED_HTML stops the plugin from running this snippet type.
1327 'execution_allowed' => ( new WINP_Snippet( $post ) )->is_allowed(),
1328 'modified_gmt' => $post->post_modified_gmt,
1329 ];
1330
1331 if ( $with_code ) {
1332 $filters = get_post_meta( $post->ID, 'wbcr_inp_snippet_filters', true );
1333 $encoded = empty( $filters ) ? false : wp_json_encode( $filters );
1334 $decoded = false === $encoded ? [] : json_decode( $encoded, true );
1335
1336 $snippet['code'] = (string) WINP_Helper::get_snippet_code( $post );
1337 $snippet['conditions'] = is_array( $decoded ) ? array_values( $decoded ) : [];
1338 }
1339
1340 return $snippet;
1341 }
1342
1343 /**
1344 * Input schema of woody/list-snippets.
1345 *
1346 * @return array<string, mixed>
1347 */
1348 private function get_list_input_schema() {
1349 return [
1350 'type' => 'object',
1351 'properties' => [
1352 'type' => [
1353 'type' => 'string',
1354 'description' => __( 'Only snippets of this type.', 'insert-php' ),
1355 'enum' => [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL, WINP_SNIPPET_TYPE_HTML, WINP_SNIPPET_TYPE_CSS, WINP_SNIPPET_TYPE_JS, WINP_SNIPPET_TYPE_TEXT, WINP_SNIPPET_TYPE_AD ],
1356 ],
1357 'scope' => [
1358 'type' => 'string',
1359 'description' => __( 'Only snippets with this insertion scope.', 'insert-php' ),
1360 'enum' => [ 'everywhere', 'auto', 'shortcode' ],
1361 ],
1362 'active' => [
1363 'type' => 'boolean',
1364 'description' => __( 'Only active (true) or inactive (false) snippets.', 'insert-php' ),
1365 ],
1366 'status' => [
1367 'type' => 'string',
1368 'description' => __( 'Post status filter. "any" excludes the trash.', 'insert-php' ),
1369 'enum' => [ 'any', 'publish', 'draft', 'trash' ],
1370 'default' => 'any',
1371 ],
1372 'search' => [
1373 'type' => 'string',
1374 'description' => __( 'Search term matched against the snippet title and code.', 'insert-php' ),
1375 ],
1376 'page' => [
1377 'type' => 'integer',
1378 'minimum' => 1,
1379 'default' => 1,
1380 ],
1381 'per_page' => [
1382 'type' => 'integer',
1383 'minimum' => 1,
1384 'maximum' => self::MAX_PER_PAGE,
1385 'default' => 20,
1386 ],
1387 ],
1388 'additionalProperties' => false,
1389 ];
1390 }
1391
1392 /**
1393 * Input schema of woody/upsert-snippet.
1394 *
1395 * @return array<string, mixed>
1396 */
1397 private function get_upsert_input_schema() {
1398 return [
1399 'type' => 'object',
1400 'properties' => [
1401 'snippet_id' => [
1402 'type' => 'integer',
1403 'description' => __( 'ID of the snippet to update. Omit to create a new, inactive snippet.', 'insert-php' ),
1404 'minimum' => 1,
1405 ],
1406 'title' => [
1407 'type' => 'string',
1408 'description' => __( 'Snippet title. Required when creating.', 'insert-php' ),
1409 ],
1410 'code' => [
1411 'type' => 'string',
1412 'description' => __( 'Snippet code or content. Required when creating.', 'insert-php' ),
1413 ],
1414 'type' => [
1415 'type' => 'string',
1416 'description' => __( 'Snippet type. Required when creating; it cannot be changed afterwards.', 'insert-php' ),
1417 'enum' => [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL, WINP_SNIPPET_TYPE_HTML, WINP_SNIPPET_TYPE_TEXT, WINP_SNIPPET_TYPE_AD ],
1418 ],
1419 'scope' => [
1420 'type' => 'string',
1421 'description' => __( 'Where the snippet runs. PHP snippets: "everywhere" or "shortcode". Other types: "auto" (automatic insertion at "location") or "shortcode". Defaults to "shortcode".', 'insert-php' ),
1422 'enum' => [ 'everywhere', 'auto', 'shortcode' ],
1423 ],
1424 'location' => [
1425 'type' => 'string',
1426 'description' => __( 'Automatic insertion location for non-PHP snippets, for example header, footer, before_content, after_content, before_paragraph, after_paragraph, before_post, after_post. Defaults to "header".', 'insert-php' ),
1427 ],
1428 'location_number' => [
1429 'type' => 'integer',
1430 'description' => __( 'Paragraph or post number for locations that need one, such as before_paragraph.', 'insert-php' ),
1431 'minimum' => 0,
1432 ],
1433 'description' => [
1434 'type' => 'string',
1435 'description' => __( 'Snippet description.', 'insert-php' ),
1436 ],
1437 'conditions' => [
1438 'type' => 'string',
1439 'description' => __( 'Display conditions for non-PHP snippets as a JSON string, in the format the conditions editor stores: [{"type":"showif","conditions":[{"type":"scope","conditions":[{"param":"location-some-page","operator":"equals","type":"select","value":"base_web"}]}]}]. Groups hold AND-ed scopes, scopes hold OR-ed conditions. Pass "[]" to clear. Params starting with technology- or auditory- require the premium plugin.', 'insert-php' ),
1440 ],
1441 'dry_run' => [
1442 'type' => 'boolean',
1443 'description' => __( 'Check only; nothing is saved. The code is never run.', 'insert-php' ),
1444 'default' => false,
1445 ],
1446 ],
1447 'additionalProperties' => false,
1448 ];
1449 }
1450
1451 /**
1452 * Output schema of a snippet.
1453 *
1454 * @param bool $with_code Whether the code and display conditions are included.
1455 *
1456 * @return array<string, mixed>
1457 */
1458 private function get_snippet_schema( $with_code ) {
1459 $properties = [
1460 'id' => [ 'type' => 'integer' ],
1461 'title' => [ 'type' => 'string' ],
1462 'type' => [ 'type' => 'string' ],
1463 'status' => [ 'type' => 'string' ],
1464 'active' => [ 'type' => 'boolean' ],
1465 'scope' => [ 'type' => 'string' ],
1466 'location' => [ 'type' => 'string' ],
1467 'location_number' => [ 'type' => 'integer' ],
1468 'priority' => [ 'type' => 'integer' ],
1469 'description' => [ 'type' => 'string' ],
1470 'shortcode' => [ 'type' => 'string' ],
1471 'execution_allowed' => [ 'type' => 'boolean' ],
1472 'modified_gmt' => [ 'type' => 'string' ],
1473 ];
1474
1475 if ( $with_code ) {
1476 $properties['code'] = [ 'type' => 'string' ];
1477 $properties['conditions'] = [
1478 'type' => 'array',
1479 'items' => [ 'type' => 'object' ],
1480 ];
1481 }
1482
1483 return [
1484 'type' => 'object',
1485 'properties' => $properties,
1486 ];
1487 }
1488 }
1489