PluginProbe
Code Engine – PHP Snippets, AI Functions & Automation for WordPress / trunk
Code Engine – PHP Snippets, AI Functions & Automation for WordPress vtrunk
0.5.6 0.5.5 0.5.4 0.5.3 0.5.2 0.5.1 0.5.0 0.4.9 0.4.8 0.4.7 0.4.6 trunk 0.0.1 0.0.2 0.2.8 0.2.9 0.3.0 0.3.1 0.3.2 0.3.3 0.3.4 0.3.5 0.3.6 0.3.7 0.3.8 All 32 releases
code-engine / classes / core.php

core.php in Code Engine – PHP Snippets, AI Functions & Automation for WordPress trunk, at classes/core.php

1,095 lines 34.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 require_once ( MWCODE_PATH . '/vendor/autoload.php' );
4 use PhpParser\ParserFactory;
5 use PhpParser\NodeDumper;
6 use PhpParser\Error;
7
8 class Meow_MWCODE_Core
9 {
10 public $admin = null;
11 public $snippet = null;
12 public $is_rest = false;
13 public $is_cli = false;
14 public $site_url = null;
15 public $mwcode = null;
16 public $licenser = null;
17
18 // IDs of global snippets already executed this request (by the plugins_loaded pass
19 // or by load_global_snippets), so a global never runs twice and never re-declares.
20 public $loaded_global_ids = [];
21
22 private $option_name = 'mwcode_options';
23
24 public function __construct() {
25 global $mwcode;
26
27 $this->site_url = get_site_url();
28 $this->is_rest = MeowKit_MWCODE_Helpers::is_rest();
29 $this->is_cli = defined( 'WP_CLI' ) && WP_CLI;
30
31 // Snippets
32 $snippet = new Meow_MWCODE_Modules_Snippet( $this );
33 $this->snippet = $snippet;
34
35 // Create API before plugins_loaded
36 $this->mwcode = new Meow_MWCODE_API( $this, $snippet );
37 $mwcode = $this->mwcode;
38
39 // Add the shortcode for the "content" snippets
40 add_shortcode( 'code-engine', [ $this, 'content_shortcode' ] );
41
42 add_action( 'plugins_loaded', array( $this, 'init' ) );
43 }
44
45 function init() {
46 // Initialize the licenser for Pro version
47 if ( class_exists( 'MeowKitPro_MWCODE_Licenser' ) ) {
48 $this->licenser = new MeowKitPro_MWCODE_Licenser( MWCODE_PREFIX, MWCODE_ENTRY, MWCODE_DOMAIN, MWCODE_ITEM_ID, MWCODE_VERSION );
49 }
50
51 // Part of the core, settings and stuff
52 $this->admin = new Meow_MWCODE_Admin( $this );
53
54 // Only for REST
55 if ( $this->is_rest ) {
56 new Meow_MWCODE_Rest( $this, $this->admin, $this->snippet );
57 }
58
59 // MCP integration - check both class and global variable
60 if ( class_exists( 'Meow_MWAI_Core' ) || isset( $GLOBALS['mwai'] ) ) {
61 new Meow_MWCODE_MCP( $this );
62 }
63 }
64
65 /**
66 *
67 * Roles & Access Rights
68 *
69 */
70 #region Roles & Access Rights
71 public function can_access_settings() {
72 return apply_filters( 'mwcode_allow_setup', current_user_can( 'manage_options' ) );
73 }
74
75 public function can_access_features() {
76 return apply_filters( 'mwcode_allow_usage', current_user_can( 'administrator' ) );
77 }
78
79 public function check_rest_nonce( $request ) {
80 $nonce = $request->get_header( 'X-WP-Nonce' );
81 return wp_verify_nonce( $nonce, 'wp_rest' );
82 }
83 #endregion
84
85 #region Options
86
87 function get_option( $option, $default = null ) {
88 $options = $this->get_all_options();
89 return $options[$option] ?? $default;
90 }
91
92 function list_options() {
93 return [
94 //Safemode
95 "safe_mode_status" => "on", // on, off, whitelist
96 "safe_mode_whitelist" => [],
97 //"disallow_block_php" => true, // Do not allow PHP code to be execute through Blocks "code" parameter
98 "code_blocks" => false,
99 "code_blocks_whitelist" => [], // Whitelist for code blocks, if empty, all code blocks are allowed
100
101 //LOGS
102 "server_debug_mode" => false,
103
104 //UI
105 "ui_show_preview" => false,
106
107 //AI
108 "ai_suggestions" => false,
109 "ai_engine_status"=> false,
110 "mwai_active" => false,
111 "ai_engine_message" => "",
112
113 //API
114 "api_endpoint" => false,
115 "api_token" => md5( time() . rand() ),
116
117 //MCP
118 "mcp_support" => false,
119 "mcp_functions" => false,
120
121 //MAINTENANCE
122 "clean_uninstall" => false,
123 ];
124 }
125
126 function get_all_options( ) {
127 $options = get_option( $this->option_name, [] );
128 $defaults = $this->list_options();
129
130 // Merge with defaults to ensure all options exist
131 $options = array_merge( $defaults, $options );
132
133 $options = $this->sanitize_options( $options );
134 return $options;
135 }
136
137 function update_options( $options ) {
138
139 $options = $this->sanitize_options( $options );
140
141 if ( !update_option( $this->option_name, $options, false ) ) {
142 //$this->log( '💾 There was an issue updating the options.' );
143 }
144
145 return $options;
146 }
147
148 function update_option( $option, $value ) {
149 $options = $this->get_all_options();
150 $options[$option] = $value;
151 return $this->update_options( $options );
152 }
153
154 function reset_options() {
155 if ( $this->get_all_options() === $this->list_options() ) {
156 return true;
157 }
158 return $this->update_options( $this->list_options() );
159 }
160
161 // Validate and keep the options clean and logical.
162 function sanitize_options( $options ) {
163 $options_modified = false;
164
165 // Ensure mcp_support exists in options
166 if ( !isset( $options['mcp_support'] ) ) {
167 $options['mcp_support'] = false;
168 }
169
170 // Make sure safe mode whitelist is an array
171 if ( ! is_array( $options['safe_mode_whitelist'] ) ) {
172 $options['safe_mode_whitelist'] = explode( ",", $options['safe_mode_whitelist'] );
173 $options_modified = true;
174 }
175
176 // Update AI Engine status
177 $options = $this->updateAIEngineStatus( $options );
178
179 // Disable AI related features if AI Engine is not available
180 if ( ! $options['ai_engine_status'] ) {
181 if ( $options['ai_suggestions'] !== false ) {
182 $options['ai_suggestions'] = false;
183 $options_modified = true;
184 }
185 // Note: We don't disable MCP support here anymore
186 // It will be checked at runtime in the MCP class
187 }
188
189 return $options;
190 }
191
192 private function updateAIEngineStatus( &$options ) {
193 global $mwai;
194
195 // AI Engine is active (regardless of whether an API key is configured).
196 // MCP exposure only needs AI Engine present, not a key, so the MCP toggles
197 // gate on this rather than on mwai_has_ai.
198 $options['mwai_active'] = !empty( $mwai );
199 $options['mwai_has_ai'] = !empty( $mwai ) && method_exists( $mwai, 'hasAI' ) && $mwai->hasAI();
200 // Legacy
201 $options['ai_engine_status'] = $options['mwai_has_ai'];
202
203 return $options;
204 }
205
206 #endregion
207
208 #region Snippets
209
210 /**
211 * Get snippet.
212 *
213 * @param $id
214 * @return mixed
215 */
216 protected function get_snippet( $id ) {
217 if ( $this->snippet === null ) {
218 $this->snippet = new Meow_MWCODE_Modules_Snippet( $this );
219 }
220
221 return $this->snippet->select_one( $id );
222 }
223
224 function add_snippet( $params ) {
225
226 $response = [
227 "snippet" => null,
228 "result" => false,
229 ];
230
231 $this->snippet->validate( $params );
232
233 $params = $this->snippet->formatParamsForDatabase( $params );
234
235 // Route to UPDATE when an existing snippet id is provided (updateSnippet / the
236 // MCP mwcode_update_snippet tool). This previously always insert()ed, so an
237 // update tried to INSERT a row with an already-used primary key: that fails on
238 // the SQLite backend (Studio/Playground) with "Could not insert the snippet",
239 // and duplicates or errors elsewhere. The admin UI was unaffected because it
240 // calls snippet->update() directly.
241 $existing = !empty( $params['id'] ) ? $this->snippet->select_one( $params['id'] ) : null;
242 if ( $existing ) {
243 $this->snippet->update( $params );
244 $result = $params['id'];
245 }
246 else {
247 unset( $params['id'] );
248 $result = $this->snippet->insert( $params );
249 }
250 $snippet = $this->snippet->select_one( $result );
251
252 if( $result ) {
253 $params['id'] = (string)$result;
254
255 $this->snippet->create_or_update_function_snippet( $params );
256 $this->snippet->create_or_update_interval_snippet( $params );
257
258 $this->snippet->get_function_snippets_data( $snippet );
259 }
260
261 $response['snippet'] = $snippet;
262 $response['result'] = $result;
263
264 return $response;
265 }
266
267 private function sanitize_arg( $name, $value, $type = null) {
268 $real_type = gettype( $value );
269
270 if ( $name[0] !== '$' ) { $name = '$' . $name; }
271
272 if ( $type == null ) {
273 $type = $real_type;
274 }
275
276 if ( $type != 'array' && !empty( $value ) && !is_numeric( $value ) && $value[0] !== '"' && $value[strlen( $value ) - 1] !== '"' ) {
277 $value = '"' . esc_sql( $value ) . '"';
278 }
279
280 if ( $type === 'array' && $real_type === 'string' ) {
281 // We got a string like this: "["a", "b", "c"]" or "[ 1, 2, 3 ]"
282 // We need to convert it to an array
283 $value = str_replace( '"', '', $value );
284 $value = str_replace( '[', '', $value );
285 $value = str_replace( ']', '', $value );
286 $value = explode( ',', $value );
287 $value = array_map( 'trim', $value );
288 }
289
290 if ( $type === 'array' ) {
291 // Convert to PHP array format instead of JSON
292 $value = var_export( $value, true );
293 }
294
295 return [ $name, $value ];
296 }
297
298 function run_non_fn_snippet( $id, $code = null, $test = false, $prefix = '' ) {
299 // Retrieve the snippet code from the provided code or via the snippet ID.
300 if ( $code ) {
301 $snippet = [ 'code' => $code ];
302 } else {
303 $snippet = $this->get_snippet( $id );
304 }
305
306 // Remove any PHP opening tag.
307 $snippet['code'] = $this->snippet->sanitize_code( $snippet['code'] );
308
309 if ( $test ) {
310 $snippet['code'] = preg_replace( '/echo\s+(.+?);/s', 'echo $1 . "\n";', $snippet['code'] );
311 }
312
313 if( $prefix ) {
314 $snippet['code'] = $prefix . "\n" . $snippet['code'];
315 }
316
317 $error = null;
318 $output = null;
319
320 try {
321 ob_start();
322 eval( $snippet['code'] );
323 $output = ob_get_clean();
324 } catch ( Throwable $e ) {
325 $snippet_id = $id ? " ( ID: $id )" : '(Content Gutenberg Block)';
326 $this->log( '🔴 Error executing the snippet ' . $snippet_id . ' : ' . $e->getMessage() );
327 ob_clean();
328 } finally {
329 restore_error_handler();
330 }
331
332 // If in test mode, return output as an array of lines with an 'error' key if needed.
333 if ( $test ) {
334 $output = explode( "\n", trim( $output ) );
335 if ( $error !== null ) {
336 $output['error'] = $error->getMessage();
337 }
338 } else {
339 if ( $error !== null ) {
340 throw $error;
341 }
342 }
343
344 return $output;
345 }
346
347 function run_snippet( $id, $args = [], $params = [] )
348 {
349 // Static array to track defined functions
350 static $defined_functions = array();
351
352 if ( $id ) { // If there is an ID, we get the snippet, if not we get the data from the params
353 $snippet = $this->get_snippet( $id );
354 $this->snippet->get_function_snippets_data( $snippet ); // adds the function data to the snippet
355
356 $params = [ // We set the params according to the snippet we fetched
357 'test' => false, // If we pass an ID to the function, we are not testing the snippet
358 // 'test' => $params['test'] ?? false if needed we can still use ID and test at the same time (should not happen)
359 'code' => $snippet['code'],
360 'name' => $snippet['functionName'],
361 'args' => $snippet['functionArgs'],
362 'values' => $snippet['functionArgsDict'] // Contains the default values of the arguments
363 ];
364 }
365
366 // Arguments used to be sanitized into PHP-literal strings here (quoting,
367 // esc_sql, var_export) so they could be concatenated into a string of PHP and
368 // eval-ed. That is gone: the function is now called with call_user_func_array
369 // (see below), so values are passed as data and need no literal-formatting.
370 // The old formatting also prefixed argument keys with "$" via sanitize_arg,
371 // which stored the provided value under "$name" while the call read "name", so
372 // provided arguments never reached the function. Passing the raw values through
373 // fixes both issues at once.
374
375 // Make sure the function is existing and is the one in the snippet
376 if ( empty( $params['code'] ) ) {
377 throw new Exception( 'Code Engine: The snippet code appears to be empty.' );
378 }
379
380 if ( empty( $params['name'] ) || ! str_contains( $params['code'], $params['name'] ) ) {
381 throw new Exception( "Code Engine: Function name does not match. The name should be {$params['name']}." );
382 }
383
384 // Collect the provided values, keyed by their normalized (dollar-less) name.
385 // Incoming keys come from the AI/MCP schema, where register_function_tools()
386 // strips a leading "$" from the declared name. The stored arg names can still
387 // carry the "$", so we normalize both sides before matching below. Without this
388 // a value provided as "style" never binds to an argument declared "$style".
389 $provided = [];
390 if ( $args ) {
391 foreach ( $args as $name => $value ) {
392 $provided[ ltrim( $name, '$' ) ] = $value;
393 }
394
395 $this->log( '⚡ Arguments provided: ' . json_encode( $args ) );
396 }
397
398 // Global snippets are meant to be always accessible. On non-whitelisted REST routes
399 // (Workflow Engine, MCP, AI function-calling) the plugins_loaded pass blocks them, so
400 // make sure their helper library is loaded before we run a function that may call it.
401 $this->load_global_snippets();
402
403 // Make every *other* active PHP function snippet available so this function can
404 // call its siblings. We pass the current name as the exception so the target is
405 // still defined below (with the edited/test code when testing), not pre-defined here.
406 $this->define_all_functions( $params['name'] );
407
408 // Check if the function has already been defined
409 if ( !in_array( $params['name'], $defined_functions ) ) {
410
411 // If not, proceed with modification and definition
412 if ( $params['test'] ) { // Make sure the echo statement uses a line break
413 $params['code'] = preg_replace( '/echo\s+(.+?);/s', 'echo $1 . "\n";', $params['code'] );
414 } else { // Remove all echo statements
415 $params['code'] = preg_replace( '/echo\s+(.+?);/s', '', $params['code'] );
416 }
417
418 $params['code'] = "if (!function_exists('{$params['name']}')) {\n" . $params['code'] . "\n}\n";
419
420 // Add the function name to the array to avoid redefinition
421 $defined_functions[] = $params['name'];
422 } else {
423 // If already defined, just prepare to call the function without redefining it
424 $params['code'] = '';
425 }
426
427 // Resolve the arguments as REAL PHP values, in the function's declared order.
428 // The previous version concatenated each value into a string of PHP and eval-ed
429 // the call, which broke on any string or edge-case value with a parse error
430 // ("syntax error, unexpected token ')'"). call_user_func_array passes them as
431 // data, so no value can ever corrupt the call syntax.
432 $callArgs = [];
433 foreach ( $params['args'] as $arg ) {
434 $key = ltrim( $arg, '$' ); // Match the normalized name the caller sent.
435 $value = null; // Not provided and no default -> null.
436 // array_key_exists, not !empty: a legitimately provided 0, "0", "" or false
437 // must reach the function instead of silently falling back to the default.
438 if ( array_key_exists( $key, $provided ) ) {
439 $value = $provided[ $key ];
440 } else if ( isset( $params['values'][$arg]['default'] ) && $params['values'][$arg]['default'] !== '' ) {
441 $value = $params['values'][$arg]['default'];
442 }
443 // An array-typed argument can arrive as a string like "[1, 2, 3]"; turn it
444 // into a real array so the function receives what its signature expects.
445 if ( ( $params['values'][$arg]['type'] ?? null ) === 'array' && is_string( $value ) ) {
446 $decoded = json_decode( $value, true );
447 $value = is_array( $decoded ) ? $decoded : array_map( 'trim', explode( ',', trim( $value, "[] \t\n\r" ) ) );
448 }
449 $callArgs[] = $value;
450 }
451
452 $error = null;
453 $output = null;
454
455 try {
456 ob_start();
457 // $params['code'] holds the function definition (empty if it was already
458 // defined earlier this request). Declare it, then invoke it as data.
459 if ( $params['code'] !== '' ) {
460 eval( $params['code'] );
461 }
462 $mwcode_result = call_user_func_array( $params['name'], $callArgs );
463 echo print_r( $mwcode_result, true );
464 $output = ob_get_clean();
465
466 if ( $params['test'] ) {
467 $output = explode( "\n", $output );
468 }
469
470 } catch ( Throwable $e ) {
471 //$this->log('Code Engine: Error executing the function: ' . $e->getMessage());
472 $error = new Exception(' Error executing the function, ' . $e->getMessage());
473
474 if ( ob_get_level() > 0 ) {
475 ob_end_clean();
476 }
477 } finally {
478 restore_error_handler();
479 }
480
481 if ( $error !== null ) {
482 if( $params['test'] ){
483 $output['error'] = $error->getMessage();
484 } else {
485 throw $error;
486 }
487 }
488
489 return $output;
490 }
491
492
493 function parse_snippet( $code, $new_snippet = false ){
494 $parser = ( new ParserFactory( ) )->createForNewestSupportedVersion( );
495
496 if( !$this->snippet ){
497 $this->snippet = new Meow_MWCODE_Modules_Snippet( $this );
498 }
499
500 // First we check the function names are unique
501 $fn = $this->snippet->sanitize_and_check_functions( $code, $new_snippet );
502 if ( ! $fn['is_valid'] ) {
503
504 $lint = [
505 'line' => 1,
506 'attributes' => $fn['attributes'][0],
507 'raw_message' => implode(', ', $fn['errors'][0]),
508 'message' => implode(', ', $fn['errors'][0]),
509 ];
510
511 return $lint;
512 }
513
514 try {
515 $stmts = $parser->parse( $code );
516 $result = $stmts;
517 } catch ( PhpParser\Error $e ) {
518
519 $lint = [
520 'line' => $e->getStartLine(),
521 'attributes' => $e->getAttributes(),
522 'raw_message' => $e->getRawMessage(),
523 'message' => $e->getMessage(),
524 ];
525
526 return $lint;
527 }
528
529 return null;
530 }
531
532 /**
533 * Load the active global snippets (persistent + backend/frontend for this context)
534 * that haven't already run this request, so on-demand function execution has the same
535 * always-available helper library a normal page load would. Callable functions are
536 * typically small wrappers around these globals.
537 *
538 * On non-whitelisted REST routes (Workflow Engine, MCP, AI function-calling) the
539 * plugins_loaded pass blocks global snippets for safety; this restores them for the
540 * deliberate, authorized act of executing a snippet. The loaded-id registry guarantees
541 * each global runs at most once per request, so nothing is ever re-declared.
542 */
543 function load_global_snippets() {
544 global $current_mwcode_snippet;
545 static $done = false;
546 if ( $done ) {
547 return;
548 }
549 $done = true;
550
551 if ( empty( $this->snippet ) ) {
552 $this->snippet = new Meow_MWCODE_Modules_Snippet( $this );
553 }
554
555 $scope = is_admin() ? [ 'backend', 'persistent' ] : [ 'frontend', 'persistent' ];
556
557 $snippets = $this->snippet->select(
558 null, // offset
559 -1, // limit (all)
560 [
561 [ 'accessor' => 'active', 'value' => 1 ],
562 [ 'accessor' => 'scope', 'value' => $scope ],
563 ],
564 [ 'accessor' => 'priority', 'by' => 'DESC' ]
565 )['data'] ?? [];
566
567 foreach ( $snippets as $snippet ) {
568 // Skip globals already executed this request (e.g. by the plugins_loaded pass).
569 if ( in_array( $snippet['id'], $this->loaded_global_ids ) ) {
570 continue;
571 }
572 $this->loaded_global_ids[] = $snippet['id'];
573
574 $code = $this->snippet->sanitize_code( $snippet['code'] );
575 $current_mwcode_snippet = $snippet;
576 try {
577 ob_start();
578 eval( $code );
579 ob_end_clean();
580 } catch ( Throwable $e ) {
581 ob_end_clean();
582 $this->log( "⚠️ Code Engine: Failed to load global snippet \"{$snippet['name']}\": " . $e->getMessage() );
583 }
584 }
585 $current_mwcode_snippet = null;
586 }
587
588 /**
589 * Declare every active PHP function snippet in the current request, without
590 * invoking any of them, so function snippets can call one another.
591 *
592 * Function snippets are not auto-loaded on every request (unlike global/backend/
593 * frontend scopes) — they are meant to run on demand. This is the PHP counterpart
594 * to get_js_functions_to_push(): it makes the whole library of functions callable
595 * before a function is executed (via REST, MCP, AI function-calling, Workflow Engine).
596 *
597 * Idempotent: a static guard runs the full pass only once per request, and each
598 * definition is wrapped in function_exists() so nothing is ever redefined.
599 *
600 * @param string|null $except Function name to skip (the one run_snippet is about to
601 * define itself, so edited/test code keeps priority).
602 */
603 function define_all_functions( $except = null ) {
604 static $loaded = false;
605 if ( $loaded ) {
606 return;
607 }
608 $loaded = true;
609
610 if ( empty( $this->snippet ) ) {
611 $this->snippet = new Meow_MWCODE_Modules_Snippet( $this );
612 }
613
614 // One query for every active function snippet (code included), then enrich with
615 // the function metadata (name + target) the same way run_snippet does.
616 $snippets = $this->snippet->select(
617 null, // offset
618 -1, // limit (all)
619 [
620 [ 'accessor' => 'active', 'value' => 1 ],
621 [ 'accessor' => 'scope', 'value' => 'function' ],
622 ],
623 [] // sort
624 )['data'] ?? [];
625
626 if ( empty( $snippets ) ) {
627 return;
628 }
629
630 $this->snippet->get_function_snippets_data( $snippets );
631
632 foreach ( $snippets as $snippet ) {
633 $name = $snippet['functionName'] ?? '';
634 $target = strtolower( $snippet['functionTarget'] ?? 'php' );
635
636 // Skip JS functions (pushed to the front-end separately), the function the
637 // caller will define itself, and anything already declared in this request.
638 if ( $name === '' || $target === 'js' || $name === $except || function_exists( $name ) ) {
639 continue;
640 }
641
642 // Mirror run_snippet()'s non-test handling: drop echo statements, then declare
643 // (never call) the function, guarded so a later run_snippet() call is a no-op.
644 $code = $this->snippet->sanitize_code( $snippet['code'] );
645 $code = preg_replace( '/echo\s+(.+?);/s', '', $code );
646 $code = "if (!function_exists('{$name}')) {\n{$code}\n}\n";
647
648 try {
649 eval( $code );
650 } catch ( Throwable $e ) {
651 $this->log( "⚠️ Code Engine: Failed to pre-define function \"{$name}\": " . $e->getMessage() );
652 }
653 }
654 }
655
656 public function get_js_functions_to_push() {
657 $functions = $this->snippet->get_functions();
658 $js_functions = [];
659 foreach ( $functions as &$function ) {
660 if ( !isset( $function['target'] ) ) {
661 $function['target'] = 'php';
662 }
663 if ( $function['target'] == 'js' ) {
664 $js_functions[] = $function;
665 }
666 }
667 $snippets = [];
668 foreach ( $js_functions as $function ) {
669 $snippet = $this->snippet->select_one( $function['snippetId'] );
670 $snippet['function_info'] = $function; // Add function info to snippet
671 $snippets[] = $snippet;
672 }
673
674 return $this->generate_js_functions_code( $snippets );
675 }
676
677 function generate_js_functions_code ($snippets ) {
678 $code = "";
679 foreach ( $snippets as $snippet ) {
680 $function_code = $snippet['code'];
681 $function_info = $snippet['function_info'];
682
683 // Extract function name and arguments
684 preg_match( '/(?:const|let|var)?\s*(\w+)\s*=\s*\((.*?)\)\s*=>/', $function_code, $matches );
685 $function_name = $matches[1] ?? $function_info['name'];
686 $function_args = $matches[2] ?? '';
687
688 // Prepare default values
689 $default_args = [];
690 foreach ( $function_info['args'] as $arg ) {
691 if ( isset( $arg['default'] ) && $arg['default'] !== '' ) {
692 $default_args[$arg['name']] = $arg['default'];
693 }
694 }
695
696 // Modify function to use default values
697 if ( !empty( $default_args ) ) {
698 $new_args = explode( ',', $function_args );
699 foreach ( $new_args as &$arg ) {
700 $arg = trim( $arg );
701 if ( isset( $default_args[$arg] ) ) {
702 $arg .= " = " . json_encode( $default_args[$arg] );
703 }
704 }
705 $new_args_string = implode( ', ', $new_args );
706 $function_code = preg_replace(
707 '/(\w+)\s*=\s*\((.*?)\)\s*=>/',
708 "$1 = ($new_args_string) =>",
709 $function_code
710 );
711 }
712
713 $code .= $function_code . "\n\n";
714 }
715
716 return $code;
717 }
718
719
720 /**
721 * [STATIC] Execute active snippets.
722 *
723 * @return array
724 */
725 public function execute_active_snippets() {
726
727 $blocked = false;
728 $page = isset( $_GET["page"] ) ? sanitize_text_field( $_GET["page"] ) : null;
729
730
731 if ( $page === 'mwcode_settings' ) {
732 // If we blocks global snippets like nonce_life filter, we would block the settings page so let's remove the block for this page
733
734 $blocked = false;
735 //$blocked = true;
736 }
737 // Block REST requests that aren't whitelisted
738 elseif ( MeowKit_MWCODE_Helpers::is_rest() && !Meow_MWCODE_Core::is_white_listed_rest() ) {
739 $blocked = true;
740 }
741
742 if ( empty( $this->snippet ) ) {
743 $this->snippet = new Meow_MWCODE_Modules_Snippet( $this );
744 }
745
746 $ts = $this->get_option( 'thrown_snippet', null );
747 if ( !empty( $ts ) ) {
748 $this->log( "⚠️ Your snippet \"{$ts['name']}\" has thrown a fatal error last time, so we disabled it. Please check the logs for more information." );
749 $this->snippet->force_disable( $ts['id'] );
750 $this->update_option( 'thrown_snippet', null );
751 }
752
753 $scope = is_admin() ? [ 'backend', 'persistent' ] : [ 'frontend', 'persistent' ];
754 // Get all active snippets
755
756 $snippets = $this->snippet->select(
757 null, // offset
758 -1, // limit
759 [
760 [ 'accessor' => 'active', 'value' => 1 ],
761 [ 'accessor' => 'scope', 'value' => $scope ],
762 ], // filter
763 [ 'accessor' => 'priority', 'by' => 'DESC' ] // sort
764 )['data'];
765
766 if ( empty( $snippets ) ) {
767 return;
768 }
769
770 $snippets = array_map( function ( $snippet ) use ( $blocked ) {
771 $snippet['code'] = $this->snippet->sanitize_code( $snippet['code'] );
772 $snippet['blocked'] = $blocked;
773
774 // If the snippet must be executed only in the frontend, we bypass the block
775 if ( !is_admin() && $snippet['scope'] === 'frontend' ) {
776 $snippet['blocked'] = false;
777 }
778
779 return $snippet;
780 }, $snippets );
781
782 return $snippets;
783 }
784
785
786 #endregion
787
788 #region Shortcodes
789 function separate_mwcode_atts( $atts ) {
790
791 if( array_key_exists( 'id', $atts ) ) unset( $atts['id'] );
792 if( array_key_exists( 'target', $atts ) ) unset( $atts['target'] );
793 if( array_key_exists( 'code', $atts ) ) unset( $atts['code'] );
794
795 return $atts;
796 }
797
798 function content_shortcode( $atts ) {
799
800 $user_atts = $this->separate_mwcode_atts( $atts );
801
802 $atts = shortcode_atts( array(
803 'id' => null,
804 'target' => null, // js or php
805 'code' => null, // For Guttenberg block usage
806 ), $atts, 'code-engine' );
807
808 $id = $atts['id'];
809 $target = $atts['target'];
810 $code = $atts['code'];
811 $current_post = get_post();
812
813 $no_js = defined( 'DISALLOW_UNFILTERED_HTML' ) && DISALLOW_UNFILTERED_HTML;
814 $allow_php = $this->get_option( 'code_blocks', false );
815 $allow_php_whitelist = $this->get_option( 'code_blocks_whitelist', [] );
816
817 // If the ID is null, it means it comes from a Guttenberg block
818 $is_block = empty( $id ) && !empty( $code );
819
820 if( $is_block ) {
821
822 if( $target !== 'js' && $target !== 'php' ) {
823 return '<b>Code Engine:</b> Please provide a valid target (js or php).';
824 }
825
826 if ( $no_js && $target === 'js' ) {
827 return '<b>Code Engine:</b> Code Block JS are disabled because unfiltered HTML is not allowed on your server.';
828 }
829
830 if ( $target === 'php' ) {
831
832 if ( !$allow_php ) {
833 return '<b>Code Engine:</b> Code Block PHP are disabled. If you are an administrator, you can enable it in the settings, this is not recommended. Please use a Content Snippet ( PHP ) instead.';
834 }
835
836 if ( !empty( $allow_php_whitelist ) && !in_array( $current_post->ID, $allow_php_whitelist ) ) {
837 return '<b>Code Engine:</b> Code Block PHP are disabled for this post. If you are an administrator, you can enable it in the settings, this is not recommended. Please use a Content Snippet ( PHP ) instead.';
838 }
839 }
840
841 // Because the code from Blocks are sanitized, we need to replace the &quot; with "
842 $code = str_replace( '&quot;', '"', $code );
843
844 if ( $target === 'js' ) {
845 $output = '<script>' . $code . '</script>';
846 }
847
848 if ( $target === 'php' ) {
849 $output = $this->run_non_fn_snippet( null, $code );
850 }
851
852 return $output;
853 }
854
855 // If not a block, we get the snippet by ID
856 // If the ID is not null, it means it comes from a shortcode
857 if ( empty( $id ) && empty( $code ) ) {
858 return '<b>Code Engine:</b> Please provide a snippet ID.';
859 }
860
861 $snippet = $this->get_snippet( $id );
862
863 if ( empty( $snippet ) ) {
864 return '<b>Code Engine:</b> The snippet does not exist.';
865 }
866
867 //Check if the snippet scope is either content_php or content_js
868 $is_content_php = $snippet['scope'] === 'content_php';
869 $is_content_js = $snippet['scope'] === 'content_js';
870
871 if ( !$is_content_php && !$is_content_js ) {
872 return '<b>Code Engine:</b> The snippet is not a content snippet.';
873 }
874
875 if( $no_js && $is_content_js ) {
876 return '<b>Code Engine:</b> Code Engine JS snippets are disabled because unfiltered HTML is not allowed on your server.';
877 }
878
879 //Check if the snippet is active
880 if ( !$snippet['active'] ) {
881 return '<b>Code Engine:</b> The snippet is not active.';
882 }
883
884 $output = '<b>Code Engine:</b> No output.';
885
886 if ( $is_content_js ) {
887 $output = '<script>' . $snippet['code'] . '</script>';
888 }
889
890 if ( $is_content_php ) {
891 $prefix = "\$mwcode_atts = unserialize( '" . serialize( $user_atts ) . "' );";
892 $output = $this->run_non_fn_snippet( $id, null, false, $prefix );
893 }
894
895 return $output;
896 }
897
898 #endregion
899
900 #region Logs
901
902 function get_logs() {
903 $log_file_path = $this->get_logs_path();
904
905 if ( !file_exists( $log_file_path ) ) {
906 return "Empty log file.";
907 }
908
909 $content = file_get_contents( $log_file_path );
910 $lines = explode( "\n", $content );
911 $lines = array_filter( $lines );
912 $lines = array_reverse( $lines );
913 $content = implode( "\n", $lines );
914 return $content;
915 }
916
917 function clear_logs() {
918 $logPath = $this->get_logs_path();
919 if ( file_exists( $logPath ) ) {
920 unlink( $logPath );
921 }
922
923 $options = $this->get_all_options();
924 $options['logs_path'] = null;
925 $this->update_options( $options );
926 }
927
928 function get_logs_path() {
929 $uploads_dir = wp_upload_dir();
930 $uploads_dir_path = trailingslashit( $uploads_dir['basedir'] );
931
932 $path = $this->get_option( 'logs_path' );
933
934 if ( $path && file_exists( $path ) ) {
935 // make sure the path is legal (within the uploads directory with the MWCODE_PREFIX and log extension)
936 if ( strpos( $path, $uploads_dir_path ) !== 0 || strpos( $path, MWCODE_PREFIX ) === false || substr( $path, -4 ) !== '.log' ) {
937 $path = null;
938 } else {
939 return $path;
940 }
941 }
942
943 if ( !$path ) {
944 $path = $uploads_dir_path . MWCODE_PREFIX . "_" . $this->random_ascii_chars() . ".log";
945 if ( !file_exists( $path ) ) {
946 touch( $path );
947 }
948 $options = $this->get_all_options();
949 $options['logs_path'] = $path;
950 $this->update_options( $options );
951 }
952
953 return $path;
954 }
955
956 function log( $data = null ) {
957 if ( !$this->get_option( 'server_debug_mode', false ) ) { return false; }
958 $log_file_path = $this->get_logs_path();
959 $fh = @fopen( $log_file_path, 'a' );
960 if ( !$fh ) { return false; }
961 $date = date( "Y-m-d H:i:s" );
962 if ( is_null( $data ) ) {
963 fwrite( $fh, "\n" );
964 }
965 else {
966 fwrite( $fh, "$date: {$data}\n" );
967 //$this->log( "[MWCODE] $data" );
968 }
969 fclose( $fh );
970 return true;
971 }
972
973 private function random_ascii_chars( $length = 8 ) {
974 $characters = array_merge( range( 'A', 'Z' ), range( 'a', 'z' ), range( '0', '9' ) );
975 $characters_length = count( $characters );
976 $random_string = '';
977
978 for ( $i = 0; $i < $length; $i++ ) {
979 $random_string .= $characters[rand(0, $characters_length - 1)];
980 }
981
982 return $random_string;
983 }
984
985 #endregion
986
987 #region Helpers
988
989 /**
990 * Check if the request is from a white-listed REST route.
991 *
992 * @return bool
993 */
994 public static function is_white_listed_rest() {
995 $options = get_option( 'mwcode_snippet_vault_options', array() );
996
997 // Early return if bypass is enabled
998 if ( !empty( $options['bypass_rest_security'] ) ) {
999 return true;
1000 }
1001
1002 // Early return for admin requests
1003 if ( is_admin() ) {
1004 return apply_filters( 'mwcode_rest_authorized', true, null );
1005 }
1006
1007 // Get the requested route
1008 $requested_route = self::get_requested_rest_route();
1009 if ( !$requested_route ) {
1010 return apply_filters( 'mwcode_rest_authorized', false, null );
1011 }
1012
1013 // Check against whitelist
1014 $white_listed = apply_filters( 'mwcode_rest_whitelist', array(
1015 'mwai/v1',
1016 'mwai-ui/v1',
1017 'media-file-renamer/v1',
1018 'media-cleaner/v1',
1019 'wplr/v1',
1020 'code-engine/v1',
1021 'wp/v2',
1022 'meow-gallery/v1',
1023 'mcp/v1',
1024 ));
1025
1026 $authorized = self::is_route_whitelisted( $requested_route, $white_listed );
1027
1028 // Log if debug mode is enabled
1029 if ( !empty( $options['server_debug_mode'] ) ) {
1030 self::log_route_status( $requested_route, $authorized );
1031 }
1032
1033 return apply_filters( 'mwcode_rest_authorized', $authorized, $requested_route );
1034 }
1035
1036 /**
1037 * Extract the REST route from the request URI.
1038 *
1039 * @return string|null
1040 */
1041 public static function get_requested_rest_route() {
1042 if ( !isset( $_SERVER['REQUEST_URI'] ) ) {
1043 return null;
1044 }
1045
1046 $route_parts = explode( '/wp-json/', $_SERVER['REQUEST_URI'] );
1047
1048 if ( isset( $route_parts[1] ) ) {
1049 return trim( $route_parts[1], '/' );
1050 }
1051
1052 return null;
1053 }
1054
1055 /**
1056 * Check if a route is in the whitelist.
1057 *
1058 * @param string $route The route to check
1059 * @param array $white_listed The whitelist array
1060 * @return bool
1061 */
1062 private static function is_route_whitelisted( $route, $white_listed ) {
1063 foreach ( $white_listed as $white_listed_route ) {
1064 if ( strpos( $route, $white_listed_route ) === 0 ) {
1065 return true;
1066 }
1067 }
1068 return false;
1069 }
1070
1071 /**
1072 * Log the route authorization status.
1073 *
1074 * @param string $route The route being checked
1075 * @param bool $authorized Whether the route is authorized
1076 */
1077 private static function log_route_status( $route, $authorized ) {
1078 global $mwcode_core;
1079
1080 $message = $authorized
1081 ? "�
1082 REST route authorized: " . $route
1083 : " REST route rejected (not whitelisted): " . $route;
1084
1085 if ( isset( $mwcode_core ) ) {
1086 $mwcode_core->log( $message );
1087 } else {
1088 error_log( "[Code Engine] " . $message );
1089 }
1090 }
1091
1092 #endregion
1093 }
1094
1095 ?>