PluginProbe
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts / 2.7.8
Woody Code Snippets – Insert PHP, CSS, JS, and Header/Footer Scripts v2.7.8
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 +56 -249 2.7.9 → 2.7.8 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. 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' ),
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' ),
145 145 'category' => self::CATEGORY,
146 146 'input_schema' => $this->get_upsert_input_schema(),
147 147 'output_schema' => [
148 148 'type' => 'object',
@@ -154,14 +154,10 @@
154 154 ],
155 155 'valid' => [ 'type' => 'boolean' ],
156 156 'validated' => [
157 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' ),
158 + 'description' => __( 'Whether the code was evaluated. False for snippet types or scopes the editor does not validate.', '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 - ],
164 160 'message' => [ 'type' => 'string' ],
165 161 'revision_id' => [ 'type' => 'integer' ],
166 162 'snippet' => $this->get_snippet_schema( true ),
167 163 ],
@@ -183,9 +179,9 @@
183 179 wp_register_ability(
184 180 'woody/set-snippet-state',
185 181 [
186 182 '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' ),
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' ),
188 184 'category' => self::CATEGORY,
189 185 'input_schema' => [
190 186 'type' => 'object',
191 187 'properties' => [
@@ -436,40 +432,43 @@
436 432 if ( is_wp_error( $data ) ) {
437 433 return $data;
438 434 }
439 435
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 = [
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 = [
443 442 'valid' => true,
444 - 'checked' => false,
445 443 'message' => '',
446 444 ];
447 445
448 - if ( $is_executable && '' !== trim( $data['code'] ) && $data['execution_changed'] ) {
449 - $validation = $this->check_code( $data, $was_active );
450 - }
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 + }
451 450
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';
451 + $validation = WINP_Code_Validator::validate_code( $this->prepare_code( $data['code'], $data['type'] ), $data['type'] );
457 452
458 - if ( $dry_run ) {
459 - $message = $validation['message'];
453 + if ( ! $validation['valid'] ) {
454 + $validation['message'] = wp_strip_all_tags( str_replace( '<br>', "\n", $validation['message'] ) );
460 455
461 - if ( $validation['valid'] && $deactivated ) {
462 - $message = __( 'Saving will leave the snippet inactive. Activate it again with woody/set-snippet-state.', 'insert-php' );
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 + }
463 459 }
460 + }
464 461
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' => $validation['checked'],
470 - 'deactivated' => $validation['valid'] && $deactivated,
471 - 'message' => $message,
466 + 'dry_run' => true,
467 + 'operation' => $operation,
468 + 'valid' => $validation['valid'],
469 + 'validated' => $needs_validation,
470 + 'message' => $validation['message'],
472 471 ];
473 472 }
474 473
475 474 if ( ! $validation['valid'] ) {
@@ -475,9 +474,9 @@
475 474 if ( ! $validation['valid'] ) {
476 475 return new WP_Error( 'woody_invalid_code', $validation['message'] );
477 476 }
478 477
479 - $saved_id = $this->save_snippet( $data, $post, $keep_active );
478 + $saved_id = $this->save_snippet( $data, $post, $was_active );
480 479
481 480 if ( is_wp_error( $saved_id ) ) {
482 481 return $saved_id;
483 482 }
@@ -502,11 +501,10 @@
502 501 return [
503 502 'dry_run' => false,
504 503 'operation' => $operation,
505 504 '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' ) : '',
505 + 'validated' => $needs_validation,
506 + 'message' => '',
509 507 'revision_id' => $revision_id,
510 508 'snippet' => $this->format_snippet( $saved, true ),
511 509 ];
512 510 }
@@ -511,36 +509,8 @@
511 509 ];
512 510 }
513 511
514 512 /**
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 513 * Execute callback: activate or deactivate a snippet.
544 514 *
545 515 * @param array<string, mixed>|null $input Ability input.
546 516 *
@@ -616,198 +586,36 @@
616 586 return new WP_Error( 'woody_safe_mode', __( 'Safe mode is enabled, snippet code cannot be executed. Disable safe mode first.', 'insert-php' ) );
617 587 }
618 588
619 589 if ( in_array( $type, [ WINP_SNIPPET_TYPE_PHP, WINP_SNIPPET_TYPE_UNIVERSAL ], true ) ) {
620 - return $this->run_activation_check( $post, $type );
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 + }
621 601 }
622 602
623 603 $error = WINP_Plugin::app()->get_execute_object()->getSnippetError( $post->ID );
624 604
625 605 if ( $error ) {
626 - return $this->activation_error( is_array( $error ) && isset( $error['message'] ) ? (string) $error['message'] : '' );
627 - }
606 + $message = is_array( $error ) && isset( $error['message'] ) ? wp_strip_all_tags( (string) $error['message'] ) : '';
628 607
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() )
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 )
712 611 );
713 612 }
714 613
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 614 return true;
724 615 }
725 616
726 617 /**
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 618 * Merge the input over the stored snippet and validate the result.
811 619 *
812 620 * @param array<string, mixed> $input Ability input.
813 621 * @param WP_Post|null $post Existing snippet, null when creating.
@@ -1145,15 +953,15 @@
1145 953
1146 954 /**
1147 955 * Persist the snippet.
1148 956 *
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.
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.
1152 960 *
1153 961 * @return int|WP_Error Snippet ID.
1154 962 */
1155 - private function save_snippet( $data, $post, $keep_active ) {
963 + private function save_snippet( $data, $post, $was_active ) {
1156 964 $postarr = [
1157 965 'post_title' => $data['title'],
1158 966 'post_content' => $data['code'],
1159 967 'post_type' => WINP_SNIPPETS_POST_TYPE,
@@ -1177,13 +985,8 @@
1177 985 if ( $had_rel ) {
1178 986 remove_filter( 'content_save_pre', 'wp_targeted_link_rel' );
1179 987 }
1180 988
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 989 $saved_id = $post ? wp_update_post( wp_slash( $postarr ), true ) : wp_insert_post( wp_slash( $postarr ), true );
1187 990
1188 991 if ( $had_kses ) {
1189 992 kses_init_filters();
@@ -1217,10 +1020,14 @@
1217 1020 if ( ! $post ) {
1218 1021 update_post_meta( $saved_id, 'wbcr_inp_snippet_priority', WINP_Helper::get_next_snippet_priority() );
1219 1022 }
1220 1023
1221 - update_post_meta( $saved_id, 'wbcr_inp_snippet_activate', $post && $keep_active ? 1 : 0 );
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 );
1222 1027
1028 + update_post_meta( $saved_id, 'wbcr_inp_snippet_activate', $keep_active ? 1 : 0 );
1029 +
1223 1030 return $saved_id;
1224 1031 }
1225 1032
1226 1033 /**
@@ -1439,9 +1246,9 @@
1439 1246 '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 1247 ],
1441 1248 'dry_run' => [
1442 1249 'type' => 'boolean',
1443 - 'description' => __( 'Check only; nothing is saved. The code is never run.', 'insert-php' ),
1250 + 'description' => __( 'Validate only; nothing is saved. Validation of PHP and universal snippets evaluates the code once.', 'insert-php' ),
1444 1251 'default' => false,
1445 1252 ],
1446 1253 ],
1447 1254 'additionalProperties' => false,