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
← All changes | includes/class.abilities.php +249 -56 2.7.8 → 2.7.9 View file →
@@ -140,9 +140,9 @@
140 140 wp_register_ability(
141 141 'woody/upsert-snippet',
142 142 [
143 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. PHP and universal snippets that run everywhere or are inserted automatically are validated first by evaluating the code once, exactly as the snippet editor does; nothing is saved when validation fails. With dry_run=true only the validation runs and nothing is saved. After an update an active snippet stays active only when the "activate by default" setting is on; this ability never activates an inactive snippet, use woody/set-snippet-state for that. CSS and JS snippet code cannot be written through this ability.', '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 145 'category' => self::CATEGORY,
146 146 'input_schema' => $this->get_upsert_input_schema(),
147 147 'output_schema' => [
148 148 'type' => 'object',
@@ -154,10 +154,14 @@
154 154 ],
155 155 'valid' => [ 'type' => 'boolean' ],
156 156 'validated' => [
157 157 'type' => 'boolean',
158 - 'description' => __( 'Whether the code was evaluated. False for snippet types or scopes the editor does not validate.', 'insert-php' ),
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 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 + ],
160 164 'message' => [ 'type' => 'string' ],
161 165 'revision_id' => [ 'type' => 'integer' ],
162 166 'snippet' => $this->get_snippet_schema( true ),
163 167 ],
@@ -179,9 +183,9 @@
179 183 wp_register_ability(
180 184 'woody/set-snippet-state',
181 185 [
182 186 'label' => __( 'Activate or deactivate snippet', 'insert-php' ),
183 - 'description' => __( 'Activates or deactivates one snippet. Activating a PHP, universal or HTML snippet that runs everywhere or is inserted automatically first executes it once to check for errors, exactly as the activation switch in the snippets list does, and is refused when the check fails.', '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' ),
184 188 'category' => self::CATEGORY,
185 189 'input_schema' => [
186 190 'type' => 'object',
187 191 'properties' => [
@@ -432,43 +436,40 @@
432 436 if ( is_wp_error( $data ) ) {
433 437 return $data;
434 438 }
435 439
436 - $was_active = $post ? $this->is_active( $post->ID ) : false;
437 -
438 - // Same rule the editor uses to decide whether the code has to be evaluated before saving.
439 - $is_executable = in_array( $data['type'], [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL ], true );
440 - $needs_validation = $is_executable && 'shortcode' !== $data['scope'] && '' !== trim( $data['code'] ) && $data['execution_changed'];
441 - $validation = [
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 = [
442 443 'valid' => true,
444 + 'checked' => false,
443 445 'message' => '',
444 446 ];
445 447
446 - if ( $needs_validation ) {
447 - if ( WINP_Helper::is_safe_mode() ) {
448 - return new WP_Error( 'woody_safe_mode', __( 'Safe mode is enabled, snippet code cannot be evaluated. Disable safe mode first.', 'insert-php' ) );
449 - }
448 + if ( $is_executable && '' !== trim( $data['code'] ) && $data['execution_changed'] ) {
449 + $validation = $this->check_code( $data, $was_active );
450 + }
450 451
451 - $validation = WINP_Code_Validator::validate_code( $this->prepare_code( $data['code'], $data['type'] ), $data['type'] );
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';
452 457
453 - if ( ! $validation['valid'] ) {
454 - $validation['message'] = wp_strip_all_tags( str_replace( '<br>', "\n", $validation['message'] ) );
458 + if ( $dry_run ) {
459 + $message = $validation['message'];
455 460
456 - if ( $was_active ) {
457 - $validation['message'] .= ' ' . __( 'The snippet is currently active and has already run in this request; deactivate it first if the error is caused by its own declarations.', 'insert-php' );
458 - }
461 + if ( $validation['valid'] && $deactivated ) {
462 + $message = __( 'Saving will leave the snippet inactive. Activate it again with woody/set-snippet-state.', 'insert-php' );
459 463 }
460 - }
461 464
462 - $operation = $post ? 'update' : 'create';
463 -
464 - if ( $dry_run ) {
465 465 return [
466 - 'dry_run' => true,
467 - 'operation' => $operation,
468 - 'valid' => $validation['valid'],
469 - 'validated' => $needs_validation,
470 - 'message' => $validation['message'],
466 + 'dry_run' => true,
467 + 'operation' => $operation,
468 + 'valid' => $validation['valid'],
469 + 'validated' => $validation['checked'],
470 + 'deactivated' => $validation['valid'] && $deactivated,
471 + 'message' => $message,
471 472 ];
472 473 }
473 474
474 475 if ( ! $validation['valid'] ) {
@@ -474,9 +475,9 @@
474 475 if ( ! $validation['valid'] ) {
475 476 return new WP_Error( 'woody_invalid_code', $validation['message'] );
476 477 }
477 478
478 - $saved_id = $this->save_snippet( $data, $post, $was_active );
479 + $saved_id = $this->save_snippet( $data, $post, $keep_active );
479 480
480 481 if ( is_wp_error( $saved_id ) ) {
481 482 return $saved_id;
482 483 }
@@ -501,10 +502,11 @@
501 502 return [
502 503 'dry_run' => false,
503 504 'operation' => $operation,
504 505 'valid' => true,
505 - 'validated' => $needs_validation,
506 - 'message' => '',
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' ) : '',
507 509 'revision_id' => $revision_id,
508 510 'snippet' => $this->format_snippet( $saved, true ),
509 511 ];
510 512 }
@@ -509,8 +511,36 @@
509 511 ];
510 512 }
511 513
512 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 + /**
513 543 * Execute callback: activate or deactivate a snippet.
514 544 *
515 545 * @param array<string, mixed>|null $input Ability input.
516 546 *
@@ -586,36 +616,198 @@
586 616 return new WP_Error( 'woody_safe_mode', __( 'Safe mode is enabled, snippet code cannot be executed. Disable safe mode first.', 'insert-php' ) );
587 617 }
588 618
589 619 if ( in_array( $type, [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL ], true ) ) {
590 - // Redeclaring a function inside eval() ends the request, so detect it before executing the snippet.
591 - $code = $this->prepare_code( (string) WINP_Helper::get_snippet_code( $post ), $type );
592 - $redeclaration = WINP_Code_Validator::find_function_redeclaration( $code, $type );
593 -
594 - if ( null !== $redeclaration ) {
595 - return new WP_Error(
596 - 'woody_invalid_code',
597 - // translators: %1$d is the line number, %2$s is the fully qualified function name.
598 - sprintf( __( 'Line %1$d: Cannot redeclare function %2$s(). Rename the function or guard its declaration with function_exists().', 'insert-php' ), $redeclaration['line'], $redeclaration['name'] )
599 - );
600 - }
620 + return $this->run_activation_check( $post, $type );
601 621 }
602 622
603 623 $error = WINP_Plugin::app()->get_execute_object()->getSnippetError( $post->ID );
604 624
605 625 if ( $error ) {
606 - $message = is_array( $error ) && isset( $error['message'] ) ? wp_strip_all_tags( (string) $error['message'] ) : '';
626 + return $this->activation_error( is_array( $error ) && isset( $error['message'] ) ? (string) $error['message'] : '' );
627 + }
607 628
608 - return new WP_Error(
609 - 'woody_invalid_code',
610 - trim( __( 'The snippet was not activated because its code contains an error.', 'insert-php' ) . ' ' . $message )
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() )
611 712 );
612 713 }
613 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 +
614 723 return true;
615 724 }
616 725
617 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 + /**
618 810 * Merge the input over the stored snippet and validate the result.
619 811 *
620 812 * @param array<string, mixed> $input Ability input.
621 813 * @param WP_Post|null $post Existing snippet, null when creating.
@@ -953,15 +1145,15 @@
953 1145
954 1146 /**
955 1147 * Persist the snippet.
956 1148 *
957 - * @param array<string, mixed> $data Validated snippet data.
958 - * @param WP_Post|null $post Existing snippet, null when creating.
959 - * @param bool $was_active Whether the snippet was active before the update.
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.
960 1152 *
961 1153 * @return int|WP_Error Snippet ID.
962 1154 */
963 - private function save_snippet( $data, $post, $was_active ) {
1155 + private function save_snippet( $data, $post, $keep_active ) {
964 1156 $postarr = [
965 1157 'post_title' => $data['title'],
966 1158 'post_content' => $data['code'],
967 1159 'post_type' => WINP_SNIPPETS_POST_TYPE,
@@ -985,8 +1177,13 @@
985 1177 if ( $had_rel ) {
986 1178 remove_filter( 'content_save_pre', 'wp_targeted_link_rel' );
987 1179 }
988 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 +
989 1186 $saved_id = $post ? wp_update_post( wp_slash( $postarr ), true ) : wp_insert_post( wp_slash( $postarr ), true );
990 1187
991 1188 if ( $had_kses ) {
992 1189 kses_init_filters();
@@ -1020,14 +1217,10 @@
1020 1217 if ( ! $post ) {
1021 1218 update_post_meta( $saved_id, 'wbcr_inp_snippet_priority', WINP_Helper::get_next_snippet_priority() );
1022 1219 }
1023 1220
1024 - // New snippets are always inactive. Like the editor, an update keeps an active
1025 - // snippet active only when the "activate by default" setting is on.
1026 - $keep_active = $post && $was_active && get_option( 'wbcr_inp_activate_by_default', true );
1221 + update_post_meta( $saved_id, 'wbcr_inp_snippet_activate', $post && $keep_active ? 1 : 0 );
1027 1222
1028 - update_post_meta( $saved_id, 'wbcr_inp_snippet_activate', $keep_active ? 1 : 0 );
1029 -
1030 1223 return $saved_id;
1031 1224 }
1032 1225
1033 1226 /**
@@ -1246,9 +1439,9 @@
1246 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' ),
1247 1440 ],
1248 1441 'dry_run' => [
1249 1442 'type' => 'boolean',
1250 - 'description' => __( 'Validate only; nothing is saved. Validation of PHP and universal snippets evaluates the code once.', 'insert-php' ),
1443 + 'description' => __( 'Check only; nothing is saved. The code is never run.', 'insert-php' ),
1251 1444 'default' => false,
1252 1445 ],
1253 1446 ],
1254 1447 'additionalProperties' => false,