PluginProbe
FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager / trunk
FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager vtrunk
10.56 1.2.1 10 10.1 10.2 10.3 10.31 10.32 10.33 10.34 10.50 10.51 10.52 10.53 10.55 9.0 9.0.1 9.4 trunk 1.0.0 1.1 1.2
easy-code-manager / app / Services / CodeRunner.php

CodeRunner.php in FluentSnippets – High-Performance Code Snippets, Header & Footer Code, Custom CSS & PHP Code Manager trunk, at app/Services/CodeRunner.php

417 lines 16.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSnippets\App\Services;
4
5 class CodeRunner
6 {
7
8 private $storageDir = '';
9 private $storageUrl = '';
10
11 /**
12 * File names of the snippets currently mid-execution, innermost last.
13 *
14 * A fatal error does not unwind the stack, so a snippet that fatals never reaches
15 * its pop and stays on this list. Whatever is left here when the request dies is
16 * the snippet that was running — which is how the shutdown handler can blame the
17 * right snippet for a fatal raised deep inside WordPress or another plugin, where
18 * $error['file'] points at core rather than at the snippet.
19 *
20 * A stack rather than a single value because a snippet can fire a hook that runs
21 * another snippet; the innermost one is the culprit.
22 */
23 private static $runningSnippets = [];
24
25 /**
26 * The snippet that was executing when the request died, or '' if none was.
27 *
28 * @return string
29 */
30 public static function getRunningSnippet()
31 {
32 if (!self::$runningSnippets) {
33 return '';
34 }
35
36 return end(self::$runningSnippets);
37 }
38
39 public static function pushRunningSnippet($fileName)
40 {
41 self::$runningSnippets[] = $fileName;
42 }
43
44 public static function popRunningSnippet()
45 {
46 array_pop(self::$runningSnippets);
47 }
48
49 public function __construct()
50 {
51 $this->storageDir = \FluentSnippets\App\Helpers\Helper::getStorageDir();
52 $this->storageUrl = \FluentSnippets\App\Helpers\Helper::getStorageUrl();
53 }
54
55 public function runSnippets()
56 {
57 if (!is_file($this->storageDir . '/index.php')) {
58 return;
59 }
60
61 $config = include $this->storageDir . '/index.php';
62
63 if (empty($config) || empty($config['published']) || !is_array($config['published'])) {
64 return; // No config or published scripts exist exists
65 }
66
67 if (isset($config['meta']['force_disabled']) && $config['meta']['force_disabled'] == 'yes') {
68 return; // this forcefully disabled via URL
69 }
70
71 $errorFiles = $this->get($config, 'error_files', []);
72
73 $snippets = $config['published'];
74
75 if (!$snippets) {
76 return;
77 }
78
79 $storageDir = $this->storageDir;
80
81 $hasInvalidFiles = false;
82
83 $conditionalClass = new FluentSnippetCondition();
84
85 $filterMaps = [
86 'before_content' => [
87 'hook' => 'the_content',
88 'insert' => 'before',
89 'is_single' => true
90 ],
91 'after_content' => [
92 'hook' => 'the_content',
93 'insert' => 'after',
94 'is_single' => true
95 ],
96 ];
97
98 foreach ($snippets as $fileName => $snippet) {
99 if (isset($_REQUEST['fluent_saving_snippet_name'])) {
100 if ($_REQUEST['fluent_saving_snippet_name'] === $fileName && current_user_can('manage_options')) {
101 continue;
102 }
103 }
104
105 if ($errorFiles && isset($errorFiles[$fileName])) {
106 // There has an error. Skip this
107 continue;
108 }
109
110 $file = $storageDir . '/' . sanitize_file_name($fileName);
111 if (!is_file($file)) {
112 $hasInvalidFiles = true;
113 continue;
114 }
115
116 $type = $this->get($snippet, 'type');
117
118 switch ($type) {
119 case 'PHP':
120 $conditionSettings = $snippet['condition'];
121 $hookName = 'wp';
122
123 if (empty($conditionSettings) || empty($conditionSettings['status']) || $conditionSettings['status'] != 'yes' || empty($conditionSettings['items'])) {
124 $hookName = 'setup_theme';
125 }
126
127 add_action($hookName, function () use ($file, $fileName, $snippet, $conditionalClass) {
128 if (!$conditionalClass->evaluate($snippet['condition'])) {
129 return;
130 }
131
132 $runAt = $this->get($snippet, 'run_at', 'all');
133 if ($runAt == 'backend') {
134 if (is_admin()) {
135 $this->runSnippetFile($file, $fileName);
136 }
137 return;
138 }
139
140 $this->runSnippetFile($file, $fileName);
141 }, $this->get($snippet, 'priority', 10));
142
143 break;
144 case 'js':
145 $runAt = $this->get($snippet, 'run_at', 'wp_footer');
146 if (in_array($runAt, ['wp_head', 'wp_footer', 'admin_head', 'admin_footer'])) {
147
148 $loadUrl = '';
149 $isFooter = false;
150
151 if ($this->get($snippet, 'load_as_file') == 'yes') {
152 $cachedFile = str_replace('.php', '.js', $fileName);
153 $loadUrl = $this->getCachedFileUrl($cachedFile);
154 if ($loadUrl) {
155 $isFooter = ($runAt == 'wp_footer' || $runAt == 'admin_footer');
156 $runAt = ($runAt == 'admin_head' || $runAt == 'admin_footer') ? 'admin_enqueue_scripts' : 'wp_enqueue_scripts';
157 }
158 }
159
160 add_action($runAt, function () use ($file, $snippet, $conditionalClass, $loadUrl, $isFooter) {
161 if (!$conditionalClass->evaluate($snippet['condition'])) {
162 return;
163 }
164
165 if ($loadUrl) {
166 $snippetScriptName = str_replace('.php', '', $snippet['file_name']);
167 wp_enqueue_script('fluent_snippet_' . $snippetScriptName, $loadUrl, [], strtotime($snippet['updated_at']), $isFooter);
168 } else {
169 $code = $this->parseBlock(file_get_contents($file), true);
170 ?>
171 <script><?php echo $this->escCssJs($code); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped ?></script>
172 <?php
173 }
174 }, $this->get($snippet, 'priority', 10));
175 }
176 break;
177 case 'css':
178 $runAt = $this->get($snippet, 'run_at', 'wp_head');
179
180 $isBlockStyle = $this->get($snippet, 'load_in_block_editor', '') === 'yes';
181 if ($isBlockStyle) {
182 add_filter('block_editor_settings_all', function ($settings) use ($snippet, $file) {
183 $code = $this->parseBlock(file_get_contents($file), true);
184 if ($code) {
185 $settings['styles'][] = array(
186 'css' => $code,
187 '__unstableType' => 'plugin',
188 'source' => 'easy_code_manager'
189 );
190 }
191 return $settings;
192 }, $this->get($snippet, 'priority', 10));
193 }
194
195 // 'everywhere' was misspelled 'everywehere' here, so the CSS type's
196 // "Both Backend and Frontend" option never matched and always fell
197 // through to wp_head — which does not fire in the admin, so that
198 // option silently behaved as frontend-only.
199 if (($runAt == 'everywhere' && is_admin()) || $runAt == 'admin_head') {
200 $runAt = 'admin_head';
201 } else {
202 $runAt = 'wp_head';
203 }
204
205 $isAdminCss = ($runAt == 'admin_head');
206
207 $loadUrl = '';
208 if ($this->get($snippet, 'load_as_file') == 'yes') {
209 $cachedFile = str_replace('.php', '.css', $fileName);
210 $loadUrl = $this->getCachedFileUrl($cachedFile);
211 if ($loadUrl) {
212 $runAt = ($runAt == 'admin_head') ? 'admin_enqueue_scripts' : 'wp_enqueue_scripts';
213 }
214 }
215
216 if ($isAdminCss) {
217 add_action('enqueue_block_editor_assets', function () use ($file, $snippet, $conditionalClass, $loadUrl) {
218 if (!$conditionalClass->evaluate($snippet['condition'])) {
219 return;
220 }
221
222 $code = $this->parseBlock(file_get_contents($file), true);
223 wp_add_inline_style('wp-edit-blocks', $code);
224 });
225 }
226
227 add_action($runAt, function () use ($file, $snippet, $conditionalClass, $loadUrl) {
228 if (!$conditionalClass->evaluate($snippet['condition'])) {
229 return;
230 }
231
232 if ($loadUrl) {
233 $snippetScriptName = str_replace('.php', '', $snippet['file_name']);
234 wp_enqueue_style('fluent_snippet_' . $snippetScriptName, $loadUrl, [], strtotime($snippet['updated_at']));
235 } else {
236 $code = $this->parseBlock(file_get_contents($file), true);
237 ?>
238 <style><?php echo $this->escCssJs($code); // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped ?></style>
239 <?php
240 }
241
242 }, $this->get($snippet, 'priority', 10));
243
244 break;
245 case 'php_content':
246 $runAt = $snippet['run_at'];
247 if (in_array($runAt, ['wp_footer', 'wp_head', 'wp_body_open'])) {
248 add_action($runAt, function () use ($file, $fileName, $snippet, $conditionalClass) {
249 if (!$conditionalClass->evaluate($snippet['condition'])) {
250 return;
251 }
252 $this->runSnippetFile($file, $fileName);
253 }, $snippet['priority']);
254 }
255 if (isset($filterMaps[$runAt])) {
256 $filter = $filterMaps[$runAt];
257 add_filter($filter['hook'], function ($content) use ($file, $fileName, $snippet, $conditionalClass, $filter) {
258 if (!empty($filter['is_single'])) {
259 if (!is_singular() || !in_the_loop() || !is_main_query()) {
260 return $content;
261 }
262 }
263
264 if (!$conditionalClass->evaluate($snippet['condition'])) {
265 return $content;
266 }
267
268 ob_start();
269 $this->runSnippetFile($file, $fileName);
270 $result = ob_get_clean();
271 if ($result) {
272 if ($filter['insert'] == 'before') {
273 return $result . $content;
274 }
275
276 return $content . $result;
277 }
278 return $content;
279 }, $this->get($snippet, 'priority', 10));
280 }
281
282 // Was falling through into an empty default. Harmless while default
283 // is empty, a trap for whoever adds the next case (M12).
284 break;
285 default:
286 break;
287 }
288 }
289
290 if ($hasInvalidFiles) {
291 do_action('fluent_snippets/rebuild_index', false, true);
292 }
293
294 do_action('fluent_snippets/after_run_snippets');
295 }
296
297
298 /**
299 * Run a snippet file with the running-snippet stack maintained around it.
300 *
301 * The pop is in a finally deliberately. A fatal error (E_ERROR and friends) does
302 * not unwind, so finally does NOT run and the snippet stays on the stack to be
303 * blamed — which is the case this whole mechanism exists for. An exception does
304 * unwind, so the pop runs and the stack cannot be left dirty by a throw that
305 * something upstream catches. That trade costs attribution for a snippet whose
306 * uncaught exception is thrown inside core rather than in the snippet itself; a
307 * wrongly quarantined working snippet is the worse outcome of the two.
308 */
309 private function runSnippetFile($file, $fileName)
310 {
311 self::pushRunningSnippet($fileName);
312
313 try {
314 require_once $file;
315 } finally {
316 self::popRunningSnippet();
317 }
318 }
319
320 private function get($array, $key, $default = null)
321 {
322 if (isset($array[$key])) {
323 return $array[$key];
324 }
325
326 return $default;
327 }
328
329 private function parseBlock($fileContent, $codeOnly = false)
330 {
331 // get content from // <Internal Doc Start> to // <Internal Doc End>
332 $fileContent = explode('// <Internal Doc Start>', $fileContent);
333
334 if (count($fileContent) < 2) {
335 if ($codeOnly) {
336 return '';
337 }
338 return [null, null];
339 }
340
341 $fileContent = explode('// <Internal Doc End> ?>' . PHP_EOL, $fileContent[1]);
342 $docBlock = $fileContent[0];
343 $code = $fileContent[1];
344
345 if ($codeOnly) {
346 return $code;
347 }
348
349 $docBlock = explode('*', $docBlock);
350 // Explode by : and get the key and value
351 $docBlockArray = [
352 'name' => '',
353 'status' => '',
354 'tags' => '',
355 'description' => '',
356 'type' => '',
357 'run_at' => '',
358 'group' => ''
359 ];
360
361 foreach ($docBlock as $key => $value) {
362 $value = trim($value);
363 $arr = explode(':', $value);
364 if (count($arr) < 2) {
365 continue;
366 }
367
368 // get the first item from the array and remove it from $arr
369 $key = array_shift($arr);
370 $key = trim(str_replace('@', '', $key));
371 if (!$key) {
372 continue;
373 }
374 $docBlockArray[$key] = trim(implode(':', $arr));
375 }
376
377 return [$docBlockArray, $code];
378 }
379
380
381 /**
382 * Make snippet code safe to print inside an inline <script> or <style> block.
383 *
384 * Kept byte-identical to Helper::escCssJs().
385 *
386 * Inside those two elements the HTML parser looks for nothing but the closing tag —
387 * an *opening* `<script>` is ordinary text. Stripping opening tags was therefore
388 * never needed for correctness, and it silently corrupted legitimate code such as
389 * `document.write('<script src="..."></script>')`. That half is gone.
390 *
391 * The closing tag is now escaped rather than deleted. `<\/script` is identical to
392 * `</script` everywhere it can legally appear in JS or CSS — string literals, regex
393 * literals, comments — so the code keeps working *and* the block cannot be
394 * terminated early. Deleting it changed behaviour; escaping preserves it.
395 *
396 * Case-insensitive and whitespace-tolerant because `</script >`, `</script\n>` and
397 * `</SCRIPT>` are all terminators as far as an HTML parser is concerned (L1).
398 */
399 private function escCssJs($code)
400 {
401 return preg_replace_callback('#</(\s*)(script|style)#i', function ($matches) {
402 return '<\\/' . $matches[1] . $matches[2];
403 }, $code);
404 }
405
406 private function getCachedFileUrl($fileName)
407 {
408 $file = $this->storageDir . '/cached/' . $fileName;
409
410 if (!file_exists($file)) {
411 return false;
412 }
413
414 return $this->storageUrl . '/cached/' . $fileName;
415 }
416 }
417