create-prisma-php-app 4.4.6-beta → 4.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,10 @@ require_once __DIR__ . '/settings/paths.php';
8
8
  use Dotenv\Dotenv;
9
9
  use Lib\Middleware\CorsMiddleware;
10
10
 
11
- Dotenv::createImmutable(DOCUMENT_PATH)->load();
11
+ if (file_exists(DOCUMENT_PATH . '/.env')) {
12
+ Dotenv::createImmutable(DOCUMENT_PATH)->safeLoad();
13
+ }
14
+
12
15
  CorsMiddleware::handle();
13
16
 
14
17
  if (session_status() === PHP_SESSION_NONE) {
@@ -24,9 +27,11 @@ use PP\MainLayout;
24
27
  use PP\PHPX\TemplateCompiler;
25
28
  use PP\CacheHandler;
26
29
  use PP\ErrorHandler;
27
- use Firebase\JWT\JWT;
28
- use Firebase\JWT\Key;
29
- use PP\PartialRenderer;
30
+ use PP\Attributes\Exposed;
31
+ use PP\Attributes\ExposedRegistry;
32
+ use PP\Streaming\SSE;
33
+ use PP\Security\RateLimiter;
34
+ use PP\Env;
30
35
 
31
36
  final class Bootstrap extends RuntimeException
32
37
  {
@@ -40,8 +45,6 @@ final class Bootstrap extends RuntimeException
40
45
  public static bool $isContentVariableIncluded = false;
41
46
  public static bool $secondRequestC69CD = false;
42
47
  public static array $requestFilesData = [];
43
- public static array $partialSelectors = [];
44
- public static bool $isPartialRequest = false;
45
48
 
46
49
  private string $context;
47
50
 
@@ -61,7 +64,7 @@ final class Bootstrap extends RuntimeException
61
64
 
62
65
  public static function run(): void
63
66
  {
64
- date_default_timezone_set($_ENV['APP_TIMEZONE'] ?? 'UTC');
67
+ date_default_timezone_set(Env::string('APP_TIMEZONE', 'UTC'));
65
68
 
66
69
  PrismaPHPSettings::init();
67
70
  Request::init();
@@ -78,7 +81,7 @@ final class Bootstrap extends RuntimeException
78
81
  'samesite' => 'Lax',
79
82
  ]);
80
83
 
81
- self::functionCallNameEncrypt();
84
+ self::setCsrfCookie();
82
85
 
83
86
  self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
84
87
 
@@ -106,24 +109,20 @@ final class Bootstrap extends RuntimeException
106
109
  self::authenticateUserToken();
107
110
 
108
111
  self::$requestFilePath = APP_PATH . Request::$pathname;
109
- self::$parentLayoutPath = APP_PATH . '/layout.php';
110
112
 
111
- self::$isParentLayout = !empty(self::$layoutsToInclude)
112
- && strpos(self::$layoutsToInclude[0], 'src/app/layout.php') !== false;
113
+ if (!empty(self::$layoutsToInclude)) {
114
+ self::$parentLayoutPath = self::$layoutsToInclude[0];
115
+ self::$isParentLayout = true;
116
+ } else {
117
+ self::$parentLayoutPath = APP_PATH . '/layout.php';
118
+ self::$isParentLayout = false;
119
+ }
113
120
 
114
121
  self::$isContentVariableIncluded = self::containsChildren(self::$parentLayoutPath);
115
122
  if (!self::$isContentVariableIncluded) {
116
123
  self::$isContentIncluded = true;
117
124
  }
118
125
 
119
- self::$isPartialRequest =
120
- !empty(Request::$data['ppSync71163'])
121
- && !empty(Request::$data['selectors'])
122
- && self::$secondRequestC69CD;
123
-
124
- if (self::$isPartialRequest) {
125
- self::$partialSelectors = (array)Request::$data['selectors'];
126
- }
127
126
  self::$requestFilesData = PrismaPHPSettings::$includeFiles;
128
127
 
129
128
  ErrorHandler::checkFatalError();
@@ -131,66 +130,77 @@ final class Bootstrap extends RuntimeException
131
130
 
132
131
  private static function isLocalStoreCallback(): void
133
132
  {
134
- $data = self::getRequestData();
135
-
136
- if (empty($data['callback'])) {
137
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
133
+ if (empty($_SERVER['HTTP_X_PP_FUNCTION'])) {
134
+ return;
138
135
  }
139
136
 
140
- try {
141
- $aesKey = self::getAesKeyFromJwt();
142
- } catch (RuntimeException $e) {
143
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
144
- }
145
-
146
- try {
147
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
148
- } catch (RuntimeException $e) {
149
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
150
- }
137
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'];
151
138
 
152
139
  if ($callbackName === PrismaPHPSettings::$localStoreKey) {
140
+ self::validateCsrfToken();
153
141
  self::jsonExit(['success' => true, 'response' => 'localStorage updated']);
154
142
  }
155
143
  }
156
144
 
157
- private static function functionCallNameEncrypt(): void
145
+ private static function setCsrfCookie(): void
158
146
  {
159
- $hmacSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
160
- if ($hmacSecret === '') {
161
- throw new RuntimeException("FUNCTION_CALL_SECRET is not set");
162
- }
147
+ $secret = Env::string('FUNCTION_CALL_SECRET', 'pp_default_insecure_secret');
148
+ $shouldRegenerate = true;
163
149
 
164
- $existing = $_COOKIE['pp_function_call_jwt'] ?? null;
165
- if ($existing) {
166
- try {
167
- $decoded = JWT::decode($existing, new Key($hmacSecret, 'HS256'));
168
- if (isset($decoded->exp) && $decoded->exp > time() + 15) {
169
- return;
150
+ if (isset($_COOKIE['prisma_php_csrf'])) {
151
+ $parts = explode('.', $_COOKIE['prisma_php_csrf']);
152
+ if (count($parts) === 2) {
153
+ [$nonce, $signature] = $parts;
154
+ $expectedSignature = hash_hmac('sha256', $nonce, $secret);
155
+
156
+ if (hash_equals($expectedSignature, $signature)) {
157
+ $shouldRegenerate = false;
170
158
  }
171
- } catch (Throwable) {
172
159
  }
173
160
  }
174
161
 
175
- $aesKey = random_bytes(32);
176
- $payload = [
177
- 'k' => base64_encode($aesKey),
178
- 'exp' => time() + 3600,
179
- 'iat' => time(),
180
- ];
181
- $jwt = JWT::encode($payload, $hmacSecret, 'HS256');
162
+ if ($shouldRegenerate) {
163
+ $nonce = bin2hex(random_bytes(16));
164
+ $signature = hash_hmac('sha256', $nonce, $secret);
165
+ $token = $nonce . '.' . $signature;
182
166
 
183
- setcookie(
184
- 'pp_function_call_jwt',
185
- $jwt,
186
- [
187
- 'expires' => $payload['exp'],
167
+ setcookie('prisma_php_csrf', $token, [
168
+ 'expires' => time() + 3600,
188
169
  'path' => '/',
189
170
  'secure' => true,
190
171
  'httponly' => false,
191
- 'samesite' => 'Strict',
192
- ]
193
- );
172
+ 'samesite' => 'Lax',
173
+ ]);
174
+
175
+ $_COOKIE['prisma_php_csrf'] = $token;
176
+ }
177
+ }
178
+
179
+ private static function validateCsrfToken(): void
180
+ {
181
+ $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
182
+ $cookieToken = $_COOKIE['prisma_php_csrf'] ?? '';
183
+ $secret = Env::string('FUNCTION_CALL_SECRET', '');
184
+
185
+ if (empty($headerToken) || empty($cookieToken)) {
186
+ self::jsonExit(['success' => false, 'error' => 'CSRF token missing']);
187
+ }
188
+
189
+ if (!hash_equals($cookieToken, $headerToken)) {
190
+ self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
191
+ }
192
+
193
+ $parts = explode('.', $cookieToken);
194
+ if (count($parts) !== 2) {
195
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
196
+ }
197
+
198
+ [$nonce, $signature] = $parts;
199
+ $expectedSignature = hash_hmac('sha256', $nonce, $secret);
200
+
201
+ if (!hash_equals($expectedSignature, $signature)) {
202
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
203
+ }
194
204
  }
195
205
 
196
206
  private static function fileExistsCached(string $path): bool
@@ -212,12 +222,11 @@ final class Bootstrap extends RuntimeException
212
222
 
213
223
  private static function determineContentToInclude(): array
214
224
  {
215
- $requestUri = $_SERVER['REQUEST_URI'];
216
- $requestUri = empty($_SERVER['SCRIPT_URL']) ? trim(self::uriExtractor($requestUri)) : trim($requestUri);
225
+ $requestUri = $_SERVER['REQUEST_URI'] ?? '/';
226
+ $requestUri = trim(self::uriExtractor($requestUri));
217
227
 
218
228
  $scriptUrl = explode('?', $requestUri, 2)[0];
219
- $pathname = $_SERVER['SCRIPT_URL'] ?? $scriptUrl;
220
- $pathname = trim($pathname, '/');
229
+ $pathname = trim($scriptUrl, '/');
221
230
  $baseDir = APP_PATH;
222
231
  $includePath = '';
223
232
  $layoutsToInclude = [];
@@ -270,79 +279,252 @@ final class Bootstrap extends RuntimeException
270
279
  }
271
280
  }
272
281
 
282
+ $layoutsToInclude = self::collectLayouts($pathname, $groupFolder, $dynamicRoute ?? null);
283
+ } else {
284
+ $contentData = self::getFilePrecedence();
285
+ $includePath = $baseDir . $contentData['file'];
286
+ $layoutsToInclude = self::collectRootLayouts($contentData['file']);
287
+ }
288
+
289
+ return [
290
+ 'path' => $includePath,
291
+ 'layouts' => $layoutsToInclude,
292
+ 'pathname' => $pathname,
293
+ 'uri' => $requestUri
294
+ ];
295
+ }
296
+
297
+ private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
298
+ {
299
+ $layoutsToInclude = [];
300
+ $baseDir = APP_PATH;
301
+
302
+ $rootLayout = $baseDir . '/layout.php';
303
+ if (self::fileExistsCached($rootLayout)) {
304
+ $layoutsToInclude[] = $rootLayout;
305
+ }
306
+
307
+ $groupName = null;
308
+ $groupParentPath = '';
309
+ $pathAfterGroup = '';
310
+
311
+ if ($groupFolder) {
312
+ $normalizedGroupFolder = str_replace('\\', '/', $groupFolder);
313
+
314
+ if (preg_match('#^\.?/src/app/(.+)/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
315
+ $groupParentPath = $matches[1];
316
+ $groupName = $matches[2];
317
+ $pathAfterGroup = dirname($matches[3]);
318
+ if ($pathAfterGroup === '.') {
319
+ $pathAfterGroup = '';
320
+ }
321
+ } elseif (preg_match('#^\.?/src/app/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
322
+ $groupName = $matches[1];
323
+ $pathAfterGroup = dirname($matches[2]);
324
+ if ($pathAfterGroup === '.') {
325
+ $pathAfterGroup = '';
326
+ }
327
+ }
328
+ }
329
+
330
+ if ($groupName && $groupParentPath) {
273
331
  $currentPath = $baseDir;
274
- $getGroupFolder = self::getGroupFolder($groupFolder);
275
- $modifiedPathname = $pathname;
276
- if (!empty($getGroupFolder)) {
277
- $modifiedPathname = trim($getGroupFolder, "/src/app/");
332
+ foreach (explode('/', $groupParentPath) as $segment) {
333
+ if (empty($segment)) continue;
334
+
335
+ $currentPath .= '/' . $segment;
336
+ $potentialLayoutPath = $currentPath . '/layout.php';
337
+
338
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
339
+ $layoutsToInclude[] = $potentialLayoutPath;
340
+ }
278
341
  }
279
342
 
280
- foreach (explode('/', $modifiedPathname) as $segment) {
281
- if (empty($segment)) {
282
- continue;
343
+ $groupLayoutPath = $baseDir . '/' . $groupParentPath . "/($groupName)/layout.php";
344
+ if (self::fileExistsCached($groupLayoutPath)) {
345
+ $layoutsToInclude[] = $groupLayoutPath;
346
+ }
347
+
348
+ if (!empty($pathAfterGroup)) {
349
+ $currentPath = $baseDir . '/' . $groupParentPath . "/($groupName)";
350
+ foreach (explode('/', $pathAfterGroup) as $segment) {
351
+ if (empty($segment)) continue;
352
+
353
+ $currentPath .= '/' . $segment;
354
+ $potentialLayoutPath = $currentPath . '/layout.php';
355
+
356
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
357
+ $layoutsToInclude[] = $potentialLayoutPath;
358
+ }
359
+ }
360
+ }
361
+ } elseif ($groupName && !$groupParentPath) {
362
+ $groupLayoutPath = $baseDir . "/($groupName)/layout.php";
363
+ if (self::fileExistsCached($groupLayoutPath)) {
364
+ $layoutsToInclude[] = $groupLayoutPath;
365
+ }
366
+
367
+ if (!empty($pathAfterGroup)) {
368
+ $currentPath = $baseDir . "/($groupName)";
369
+ foreach (explode('/', $pathAfterGroup) as $segment) {
370
+ if (empty($segment)) continue;
371
+
372
+ $currentPath .= '/' . $segment;
373
+ $potentialLayoutPath = $currentPath . '/layout.php';
374
+
375
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
376
+ $layoutsToInclude[] = $potentialLayoutPath;
377
+ }
283
378
  }
379
+ }
380
+ } else {
381
+ $currentPath = $baseDir;
382
+ foreach (explode('/', $pathname) as $segment) {
383
+ if (empty($segment)) continue;
284
384
 
285
385
  $currentPath .= '/' . $segment;
286
386
  $potentialLayoutPath = $currentPath . '/layout.php';
387
+
388
+ if ($potentialLayoutPath === $rootLayout) {
389
+ continue;
390
+ }
391
+
287
392
  if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
288
393
  $layoutsToInclude[] = $potentialLayoutPath;
289
394
  }
290
395
  }
396
+ }
291
397
 
292
- if (isset($dynamicRoute) && !empty($dynamicRoute)) {
293
- $currentDynamicPath = $baseDir;
294
- foreach (explode('/', $dynamicRoute) as $segment) {
295
- if (empty($segment)) {
296
- continue;
297
- }
298
- if ($segment === 'src' || $segment === 'app') {
398
+ if (isset($dynamicRoute) && !empty($dynamicRoute)) {
399
+ $currentDynamicPath = $baseDir;
400
+ foreach (explode('/', $dynamicRoute) as $segment) {
401
+ if (empty($segment) || $segment === 'src' || $segment === 'app') {
402
+ continue;
403
+ }
404
+
405
+ $currentDynamicPath .= '/' . $segment;
406
+ $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
407
+ if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
408
+ $layoutsToInclude[] = $potentialDynamicRoute;
409
+ }
410
+ }
411
+ }
412
+
413
+ if (empty($layoutsToInclude)) {
414
+ $layoutsToInclude = self::findFirstGroupLayout();
415
+ }
416
+
417
+ return $layoutsToInclude;
418
+ }
419
+
420
+ private static function collectRootLayouts(?string $matchedContentFile = null): array
421
+ {
422
+ $layoutsToInclude = [];
423
+ $baseDir = APP_PATH;
424
+ $rootLayout = $baseDir . '/layout.php';
425
+
426
+ if (self::fileExistsCached($rootLayout)) {
427
+ $layoutsToInclude[] = $rootLayout;
428
+ } else {
429
+ $layoutsToInclude = self::findFirstGroupLayout($matchedContentFile);
430
+
431
+ if (empty($layoutsToInclude)) {
432
+ return [];
433
+ }
434
+ }
435
+
436
+ return $layoutsToInclude;
437
+ }
438
+
439
+ private static function findFirstGroupLayout(?string $matchedContentFile = null): array
440
+ {
441
+ $baseDir = APP_PATH;
442
+ $layoutsToInclude = [];
443
+
444
+ if (is_dir($baseDir)) {
445
+ $items = scandir($baseDir);
446
+
447
+ if ($matchedContentFile) {
448
+ foreach ($items as $item) {
449
+ if ($item === '.' || $item === '..') {
299
450
  continue;
300
451
  }
301
452
 
302
- $currentDynamicPath .= '/' . $segment;
303
- $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
304
- if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
305
- $layoutsToInclude[] = $potentialDynamicRoute;
453
+ if (preg_match('/^\([^)]+\)$/', $item)) {
454
+ if (strpos($matchedContentFile, "/$item/") === 0) {
455
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
456
+ if (self::fileExistsCached($groupLayoutPath)) {
457
+ $layoutsToInclude[] = $groupLayoutPath;
458
+ return $layoutsToInclude;
459
+ }
460
+ }
306
461
  }
307
462
  }
308
463
  }
309
464
 
310
- if (empty($layoutsToInclude)) {
311
- $layoutsToInclude[] = $baseDir . '/layout.php';
465
+ foreach ($items as $item) {
466
+ if ($item === '.' || $item === '..') {
467
+ continue;
468
+ }
469
+
470
+ if (preg_match('/^\([^)]+\)$/', $item)) {
471
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
472
+ if (self::fileExistsCached($groupLayoutPath)) {
473
+ $layoutsToInclude[] = $groupLayoutPath;
474
+ break;
475
+ }
476
+ }
312
477
  }
313
- } else {
314
- $includePath = $baseDir . self::getFilePrecedence();
315
478
  }
316
479
 
317
- return [
318
- 'path' => $includePath,
319
- 'layouts' => $layoutsToInclude,
320
- 'pathname' => $pathname,
321
- 'uri' => $requestUri
322
- ];
480
+ return $layoutsToInclude;
323
481
  }
324
482
 
325
- private static function getFilePrecedence(): ?string
483
+ private static function getFilePrecedence(): array
326
484
  {
485
+ $baseDir = APP_PATH;
486
+ $result = ['file' => null];
487
+
327
488
  foreach (PrismaPHPSettings::$routeFiles as $route) {
328
489
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
329
490
  continue;
330
491
  }
331
492
  if (preg_match('/^\.\/src\/app\/route\.php$/', $route)) {
332
- return '/route.php';
493
+ return ['file' => '/route.php'];
333
494
  }
334
495
  if (preg_match('/^\.\/src\/app\/index\.php$/', $route)) {
335
- return '/index.php';
496
+ return ['file' => '/index.php'];
497
+ }
498
+ }
499
+
500
+ if (is_dir($baseDir)) {
501
+ $items = scandir($baseDir);
502
+ foreach ($items as $item) {
503
+ if ($item === '.' || $item === '..') {
504
+ continue;
505
+ }
506
+
507
+ if (preg_match('/^\([^)]+\)$/', $item)) {
508
+ $groupDir = $baseDir . '/' . $item;
509
+
510
+ if (file_exists($groupDir . '/route.php')) {
511
+ return ['file' => '/' . $item . '/route.php'];
512
+ }
513
+ if (file_exists($groupDir . '/index.php')) {
514
+ return ['file' => '/' . $item . '/index.php'];
515
+ }
516
+ }
336
517
  }
337
518
  }
338
- return null;
519
+
520
+ return $result;
339
521
  }
340
522
 
341
523
  private static function uriExtractor(string $scriptUrl): string
342
524
  {
343
525
  $projectName = PrismaPHPSettings::$option->projectName ?? '';
344
526
  if (empty($projectName)) {
345
- return "/";
527
+ return $scriptUrl;
346
528
  }
347
529
 
348
530
  $escapedIdentifier = preg_quote($projectName, '/');
@@ -350,7 +532,7 @@ final class Bootstrap extends RuntimeException
350
532
  return rtrim(ltrim($matches[1], '/'), '/');
351
533
  }
352
534
 
353
- return "/";
535
+ return $scriptUrl;
354
536
  }
355
537
 
356
538
  private static function findGroupFolder(string $pathname): string
@@ -484,6 +666,7 @@ final class Bootstrap extends RuntimeException
484
666
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
485
667
  continue;
486
668
  }
669
+
487
670
  $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
488
671
  $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
489
672
 
@@ -495,22 +678,26 @@ final class Bootstrap extends RuntimeException
495
678
  }
496
679
  }
497
680
 
498
- return $bestMatch;
499
- }
681
+ if (!$bestMatch) {
682
+ foreach (PrismaPHPSettings::$routeFiles as $route) {
683
+ if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
684
+ continue;
685
+ }
500
686
 
501
- private static function getGroupFolder($pathname): string
502
- {
503
- $lastSlashPos = strrpos($pathname, '/');
504
- if ($lastSlashPos === false) {
505
- return "";
506
- }
687
+ $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
688
+
689
+ if (preg_match('/\/\(([^)]+)\)\//', $normalizedRoute, $matches)) {
690
+ $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
507
691
 
508
- $pathWithoutFile = substr($pathname, 0, $lastSlashPos);
509
- if (preg_match('/\(([^)]+)\)[^()]*$/', $pathWithoutFile, $matches)) {
510
- return $pathWithoutFile;
692
+ if ($cleanedRoute === $routeFile || $cleanedRoute === $indexFile) {
693
+ $bestMatch = $normalizedRoute;
694
+ break;
695
+ }
696
+ }
697
+ }
511
698
  }
512
699
 
513
- return "";
700
+ return $bestMatch;
514
701
  }
515
702
 
516
703
  private static function singleDynamicRoute($pathnameSegments, $routeSegments)
@@ -530,7 +717,7 @@ final class Bootstrap extends RuntimeException
530
717
 
531
718
  private static function checkForDuplicateRoutes(): void
532
719
  {
533
- if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'production') {
720
+ if (Env::string('APP_ENV', 'production') === 'production') {
534
721
  return;
535
722
  }
536
723
 
@@ -579,7 +766,7 @@ final class Bootstrap extends RuntimeException
579
766
  }
580
767
  }
581
768
 
582
- public static function containsChildLayoutChildren($filePath): bool
769
+ public static function containsChildren($filePath): bool
583
770
  {
584
771
  if (!self::fileExistsCached($filePath)) {
585
772
  return false;
@@ -590,118 +777,76 @@ final class Bootstrap extends RuntimeException
590
777
  return false;
591
778
  }
592
779
 
593
- $pattern = '/\<\?=\s*MainLayout::\$childLayoutChildren\s*;?\s*\?>|echo\s*MainLayout::\$childLayoutChildren\s*;?/';
780
+ $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
594
781
  return (bool) preg_match($pattern, $fileContent);
595
782
  }
596
783
 
597
- private static function containsChildren($filePath): bool
784
+ private static function convertToArrayObject($data)
598
785
  {
599
- if (!self::fileExistsCached($filePath)) {
600
- return false;
786
+ if (!is_array($data)) {
787
+ return $data;
601
788
  }
602
789
 
603
- $fileContent = @file_get_contents($filePath);
604
- if ($fileContent === false) {
605
- return false;
790
+ if (empty($data)) {
791
+ return $data;
606
792
  }
607
793
 
608
- $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
609
- return (bool) preg_match($pattern, $fileContent);
610
- }
794
+ $isAssoc = array_keys($data) !== range(0, count($data) - 1);
611
795
 
612
- private static function convertToArrayObject($data)
613
- {
614
- return is_array($data) ? (object) $data : $data;
796
+ if ($isAssoc) {
797
+ $obj = new stdClass();
798
+ foreach ($data as $key => $value) {
799
+ $obj->$key = self::convertToArrayObject($value);
800
+ }
801
+ return $obj;
802
+ } else {
803
+ return array_map([self::class, 'convertToArrayObject'], $data);
804
+ }
615
805
  }
616
806
 
617
807
  public static function wireCallback(): void
618
808
  {
619
- $data = self::getRequestData();
809
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
620
810
 
621
- if (empty($data['callback'])) {
622
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
811
+ if (empty($callbackName)) {
812
+ self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
623
813
  }
624
814
 
625
- try {
626
- $aesKey = self::getAesKeyFromJwt();
627
- } catch (RuntimeException $e) {
628
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
629
- }
815
+ self::validateCsrfToken();
630
816
 
631
- try {
632
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
633
- } catch (RuntimeException $e) {
634
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
817
+ if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
818
+ self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
635
819
  }
636
820
 
821
+ $data = self::getRequestData();
637
822
  $args = self::convertToArrayObject($data);
638
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
823
+
824
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
639
825
  ? self::dispatchMethod($callbackName, $args)
640
826
  : self::dispatchFunction($callbackName, $args);
641
827
 
642
- if ($out !== null) {
643
- self::jsonExit($out);
644
- }
645
- exit;
646
- }
647
-
648
- private static function getAesKeyFromJwt(): string
649
- {
650
- $token = $_COOKIE['pp_function_call_jwt'] ?? null;
651
- $jwtSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? null;
652
-
653
- if (!$token || !$jwtSecret) {
654
- throw new RuntimeException('Missing session key or secret');
828
+ if ($out instanceof SSE) {
829
+ $out->send();
830
+ exit;
655
831
  }
656
832
 
657
- try {
658
- $decoded = JWT::decode($token, new Key($jwtSecret, 'HS256'));
659
- } catch (Throwable) {
660
- throw new RuntimeException('Invalid session key');
833
+ if ($out instanceof Generator) {
834
+ (new SSE($out))->send();
835
+ exit;
661
836
  }
662
837
 
663
- $aesKey = base64_decode($decoded->k, true);
664
- if ($aesKey === false || strlen($aesKey) !== 32) {
665
- throw new RuntimeException('Bad key length');
838
+ if ($out !== null) {
839
+ self::jsonExit($out);
666
840
  }
667
-
668
- return $aesKey;
841
+ exit;
669
842
  }
670
843
 
671
- private static function jsonExit(array $payload): void
844
+ private static function jsonExit(mixed $payload): void
672
845
  {
673
846
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
674
847
  exit;
675
848
  }
676
849
 
677
- private static function decryptCallback(string $encrypted, string $aesKey): string
678
- {
679
- $parts = explode(':', $encrypted, 2);
680
- if (count($parts) !== 2) {
681
- throw new RuntimeException('Malformed callback payload');
682
- }
683
- [$ivB64, $ctB64] = $parts;
684
-
685
- $iv = base64_decode($ivB64, true);
686
- $ct = base64_decode($ctB64, true);
687
-
688
- if ($iv === false || strlen($iv) !== 16 || $ct === false) {
689
- throw new RuntimeException('Invalid callback payload');
690
- }
691
-
692
- $plain = openssl_decrypt($ct, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
693
- if ($plain === false) {
694
- throw new RuntimeException('Decryption failed');
695
- }
696
-
697
- $callback = preg_replace('/[^a-zA-Z0-9_:\->]/', '', $plain);
698
- if ($callback === '' || $callback[0] === '_') {
699
- throw new RuntimeException('Invalid callback');
700
- }
701
-
702
- return $callback;
703
- }
704
-
705
850
  private static function getRequestData(): array
706
851
  {
707
852
  if (!empty($_FILES)) {
@@ -729,17 +874,140 @@ final class Bootstrap extends RuntimeException
729
874
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
730
875
  }
731
876
 
877
+ private static function validateAccess(Exposed $attribute): bool
878
+ {
879
+ if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
880
+ $auth = Auth::getInstance();
881
+
882
+ if (!$auth->isAuthenticated()) {
883
+ return false;
884
+ }
885
+
886
+ if (!empty($attribute->allowedRoles)) {
887
+ $payload = $auth->getPayload();
888
+ $currentRole = null;
889
+
890
+ if (is_scalar($payload)) {
891
+ $currentRole = $payload;
892
+ } else {
893
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
894
+
895
+ if (is_object($payload)) {
896
+ $currentRole = $payload->$roleKey ?? null;
897
+ } elseif (is_array($payload)) {
898
+ $currentRole = $payload[$roleKey] ?? null;
899
+ }
900
+ }
901
+
902
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
903
+ return false;
904
+ }
905
+ }
906
+ }
907
+
908
+ return true;
909
+ }
910
+
911
+ private static function isFunctionAllowed(string $fn): bool
912
+ {
913
+ try {
914
+ $ref = new ReflectionFunction($fn);
915
+ $attrs = $ref->getAttributes(Exposed::class);
916
+
917
+ if (empty($attrs)) {
918
+ return false;
919
+ }
920
+
921
+ return self::validateAccess($attrs[0]->newInstance());
922
+ } catch (Throwable) {
923
+ return false;
924
+ }
925
+ }
926
+
927
+ private static function isMethodAllowed(string $class, string $method): bool
928
+ {
929
+ try {
930
+ $ref = new ReflectionMethod($class, $method);
931
+ $attrs = $ref->getAttributes(Exposed::class);
932
+
933
+ if (empty($attrs)) {
934
+ return false;
935
+ }
936
+
937
+ return self::validateAccess($attrs[0]->newInstance());
938
+ } catch (Throwable) {
939
+ return false;
940
+ }
941
+ }
942
+
943
+ private static function getExposedAttribute(string $classOrFn, ?string $method = null): ?Exposed
944
+ {
945
+ try {
946
+ if ($method) {
947
+ $ref = new ReflectionMethod($classOrFn, $method);
948
+ } else {
949
+ $ref = new ReflectionFunction($classOrFn);
950
+ }
951
+
952
+ $attrs = $ref->getAttributes(Exposed::class);
953
+ return !empty($attrs) ? $attrs[0]->newInstance() : null;
954
+ } catch (Throwable) {
955
+ return null;
956
+ }
957
+ }
958
+
959
+ private static function enforceRateLimit(Exposed $attribute, string $identifier): void
960
+ {
961
+ $limits = $attribute->limits;
962
+
963
+ if (empty($limits)) {
964
+ if ($attribute->requiresAuth) {
965
+ $limits = Env::string('RATE_LIMIT_AUTH', '60/minute');
966
+ } else {
967
+ $limits = Env::string('RATE_LIMIT_RPC', '60/minute');
968
+ }
969
+ }
970
+
971
+ if ($limits) {
972
+ RateLimiter::verify($identifier, $limits);
973
+ }
974
+ }
975
+
732
976
  private static function dispatchFunction(string $fn, mixed $args)
733
977
  {
978
+ if (!function_exists($fn)) {
979
+ $resolved = ExposedRegistry::resolveFunction($fn);
980
+ if ($resolved) {
981
+ $fn = $resolved;
982
+ }
983
+ }
984
+
985
+ if (!self::isFunctionAllowed($fn)) {
986
+ return ['success' => false, 'error' => 'Function not callable from client'];
987
+ }
988
+
989
+ $attribute = self::getExposedAttribute($fn);
990
+ if (!self::validateAccess($attribute)) {
991
+ return ['success' => false, 'error' => 'Permission denied'];
992
+ }
993
+
734
994
  if (function_exists($fn) && is_callable($fn)) {
735
995
  try {
996
+ self::enforceRateLimit($attribute, "fn:$fn");
997
+
736
998
  $res = call_user_func($fn, $args);
737
- if ($res !== null) {
738
- return ['success' => true, 'error' => null, 'response' => $res];
999
+
1000
+ if ($res instanceof Generator || $res instanceof SSE) {
1001
+ return $res;
739
1002
  }
1003
+
740
1004
  return $res;
741
1005
  } catch (Throwable $e) {
742
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
1006
+ if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1007
+ return ['success' => false, 'error' => $e->getMessage()];
1008
+ }
1009
+
1010
+ if (Env::string('SHOW_ERRORS', 'false') === 'false') {
743
1011
  return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
744
1012
  } else {
745
1013
  return ['success' => false, 'error' => "Function error: {$e->getMessage()}"];
@@ -752,14 +1020,15 @@ final class Bootstrap extends RuntimeException
752
1020
  private static function dispatchMethod(string $call, mixed $args)
753
1021
  {
754
1022
  if (strpos($call, '->') !== false) {
755
- list($requested, $method) = explode('->', $call, 2);
1023
+ [$requested, $method] = explode('->', $call, 2);
756
1024
  $isStatic = false;
757
1025
  } else {
758
- list($requested, $method) = explode('::', $call, 2);
1026
+ [$requested, $method] = explode('::', $call, 2);
759
1027
  $isStatic = true;
760
1028
  }
761
1029
 
762
1030
  $class = $requested;
1031
+
763
1032
  if (!class_exists($class)) {
764
1033
  if ($import = self::resolveClassImport($requested)) {
765
1034
  require_once $import['file'];
@@ -767,47 +1036,52 @@ final class Bootstrap extends RuntimeException
767
1036
  }
768
1037
  }
769
1038
 
770
- if (!$isStatic) {
771
- if (!class_exists($class)) {
772
- return ['success' => false, 'error' => "Class '$requested' not found"];
773
- }
774
- $instance = new $class();
775
- if (!is_callable([$instance, $method])) {
776
- return ['success' => false, 'error' => "Method '$method' not callable on $class"];
777
- }
778
- try {
1039
+ if (!class_exists($class)) {
1040
+ return ['success' => false, 'error' => "Class '$requested' not found"];
1041
+ }
1042
+
1043
+ if (!self::isMethodAllowed($class, $method)) {
1044
+ return ['success' => false, 'error' => 'Method not callable from client'];
1045
+ }
1046
+
1047
+ $attribute = self::getExposedAttribute($class, $method);
1048
+ if (!$attribute) {
1049
+ return ['success' => false, 'error' => 'Method not callable from client'];
1050
+ }
1051
+
1052
+ if (!self::validateAccess($attribute)) {
1053
+ return ['success' => false, 'error' => 'Permission denied'];
1054
+ }
1055
+
1056
+ try {
1057
+ self::enforceRateLimit($attribute, "method:$class::$method");
1058
+
1059
+ $res = null;
1060
+ if (!$isStatic) {
1061
+ $instance = new $class();
1062
+ if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
779
1063
  $res = call_user_func([$instance, $method], $args);
780
- if ($res !== null) {
781
- return ['success' => true, 'error' => null, 'response' => $res];
782
- }
1064
+ } else {
1065
+ if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
1066
+ $res = call_user_func([$class, $method], $args);
1067
+ }
1068
+
1069
+ if ($res instanceof Generator || $res instanceof SSE) {
783
1070
  return $res;
784
- } catch (Throwable $e) {
785
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
786
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
787
- } else {
788
- return ['success' => false, 'error' => "Instance call error: {$e->getMessage()}"];
789
- }
790
1071
  }
791
- } else {
792
- if (!class_exists($class) || !is_callable([$class, $method])) {
793
- return ['success' => false, 'error' => "Static method '$requested::$method' invalid"];
1072
+
1073
+ return $res;
1074
+ } catch (Throwable $e) {
1075
+ if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1076
+ return ['success' => false, 'error' => $e->getMessage()];
794
1077
  }
795
- try {
796
- $res = call_user_func([$class, $method], $args);
797
- if ($res !== null) {
798
- return ['success' => true, 'error' => null, 'response' => $res];
799
- }
800
- return $res;
801
- } catch (Throwable $e) {
802
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
803
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
804
- } else {
805
- return ['success' => false, 'error' => "Static call error: {$e->getMessage()}"];
806
- }
1078
+
1079
+ if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1080
+ return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1081
+ } else {
1082
+ return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
807
1083
  }
808
1084
  }
809
-
810
- return ['success' => false, 'error' => 'Invalid callback'];
811
1085
  }
812
1086
 
813
1087
  private static function resolveClassImport(string $simpleClassKey): ?array
@@ -950,6 +1224,22 @@ final class Bootstrap extends RuntimeException
950
1224
 
951
1225
  return Request::$isAjax || Request::$isXFileRequest || Request::$fileToInclude === 'route.php';
952
1226
  }
1227
+
1228
+ public static function applyRootLayoutId(string $html): string
1229
+ {
1230
+ $rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
1231
+ $rootLayoutId = !empty($rootLayoutPath) ? md5($rootLayoutPath) : 'default-root';
1232
+
1233
+ header('X-PP-Root-Layout: ' . $rootLayoutId);
1234
+
1235
+ $rootLayoutMeta = '<meta name="pp-root-layout" content="' . $rootLayoutId . '">';
1236
+
1237
+ if (strpos($html, '<head>') !== false) {
1238
+ return preg_replace('/<head>/', "<head>\n $rootLayoutMeta", $html, 1);
1239
+ }
1240
+
1241
+ return $rootLayoutMeta . $html;
1242
+ }
953
1243
  }
954
1244
 
955
1245
  Bootstrap::run();
@@ -990,40 +1280,32 @@ try {
990
1280
  }
991
1281
 
992
1282
  if (!empty(Bootstrap::$contentToInclude) && !empty(Request::$fileToInclude)) {
993
- if (!Bootstrap::$isParentLayout) {
994
- ob_start();
995
- require_once Bootstrap::$contentToInclude;
996
- MainLayout::$childLayoutChildren = ob_get_clean();
997
- }
1283
+ ob_start();
1284
+ require_once Bootstrap::$contentToInclude;
1285
+ MainLayout::$children = ob_get_clean();
998
1286
 
999
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
1000
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1001
- continue;
1002
- }
1287
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1288
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1003
1289
 
1004
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1005
- Bootstrap::$isChildContentIncluded = true;
1006
- }
1290
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1291
+ if (!Bootstrap::containsChildren($layoutPath)) {
1292
+ Bootstrap::$isChildContentIncluded = true;
1293
+ }
1007
1294
 
1008
- ob_start();
1009
- require_once $layoutPath;
1010
- MainLayout::$childLayoutChildren = ob_get_clean();
1295
+ ob_start();
1296
+ require_once $layoutPath;
1297
+ MainLayout::$children = ob_get_clean();
1298
+ }
1011
1299
  }
1012
1300
  } else {
1013
1301
  ob_start();
1014
1302
  require_once APP_PATH . '/not-found.php';
1015
- MainLayout::$childLayoutChildren = ob_get_clean();
1303
+ MainLayout::$children = ob_get_clean();
1016
1304
 
1017
1305
  http_response_code(404);
1018
1306
  CacheHandler::$isCacheable = false;
1019
1307
  }
1020
1308
 
1021
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1022
- ob_start();
1023
- require_once Bootstrap::$contentToInclude;
1024
- MainLayout::$childLayoutChildren = ob_get_clean();
1025
- }
1026
-
1027
1309
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1028
1310
  if (!Bootstrap::$secondRequestC69CD) {
1029
1311
  Bootstrap::createUpdateRequestData();
@@ -1035,69 +1317,63 @@ try {
1035
1317
  if (file_exists($file)) {
1036
1318
  ob_start();
1037
1319
  require_once $file;
1038
- MainLayout::$childLayoutChildren .= ob_get_clean();
1320
+ MainLayout::$children .= ob_get_clean();
1039
1321
  }
1040
1322
  }
1041
1323
  }
1042
1324
  }
1043
1325
 
1044
1326
  if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1045
- ob_end_clean();
1327
+ while (ob_get_level() > 0) {
1328
+ ob_end_clean();
1329
+ }
1046
1330
  Bootstrap::wireCallback();
1047
1331
  }
1048
1332
 
1049
1333
  if ((!Request::$isWire && !Bootstrap::$secondRequestC69CD) && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1050
- if ($_ENV['CACHE_ENABLED'] === 'true') {
1051
- CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL']));
1334
+ $cacheEnabled = (Env::string('CACHE_ENABLED', 'false') === 'true');
1335
+
1336
+ $shouldCache = CacheHandler::$isCacheable === true
1337
+ || (CacheHandler::$isCacheable === null && $cacheEnabled);
1338
+
1339
+ if ($shouldCache) {
1340
+ CacheHandler::serveCache(Request::$decodedUri, intval(Env::string('CACHE_TTL', '600')));
1052
1341
  }
1053
1342
  }
1054
1343
 
1055
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1344
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1056
1345
 
1057
1346
  ob_start();
1058
- require_once APP_PATH . '/layout.php';
1347
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1348
+ require_once Bootstrap::$parentLayoutPath;
1349
+ } else {
1350
+ echo MainLayout::$children;
1351
+ }
1352
+
1059
1353
  MainLayout::$html = ob_get_clean();
1060
1354
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1061
1355
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1356
+ MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1357
+
1062
1358
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1063
1359
 
1064
1360
  if (
1065
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1361
+ http_response_code() === 200
1362
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1363
+ && $shouldCache
1364
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1066
1365
  ) {
1067
1366
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1068
1367
  }
1069
1368
 
1070
- if (Bootstrap::$isPartialRequest) {
1071
- $parts = PartialRenderer::extract(
1072
- MainLayout::$html,
1073
- Bootstrap::$partialSelectors
1074
- );
1075
-
1076
- if (count($parts) === 1) {
1077
- echo reset($parts);
1078
- } else {
1079
- header('Content-Type: application/json');
1080
- echo json_encode(
1081
- ['success' => true, 'fragments' => $parts],
1082
- JSON_UNESCAPED_UNICODE
1083
- );
1084
- }
1085
- exit;
1086
- }
1087
-
1088
1369
  echo MainLayout::$html;
1089
1370
  } else {
1090
1371
  $layoutPath = Bootstrap::$isContentIncluded
1091
1372
  ? Bootstrap::$parentLayoutPath
1092
1373
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1093
1374
 
1094
- $message = "The layout file does not contain &lt;?php echo MainLayout::\$childLayoutChildren; ?&gt; or &lt;?= MainLayout::\$childLayoutChildren ?&gt;\n<strong>$layoutPath</strong>";
1095
- $htmlMessage = "<div class='error'>The layout file does not contain &lt;?php echo MainLayout::\$childLayoutChildren; ?&gt; or &lt;?= MainLayout::\$childLayoutChildren ?&gt;<br><strong>$layoutPath</strong></div>";
1096
-
1097
- if (Bootstrap::$isContentIncluded) {
1098
- $message = "The parent layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; Or &lt;?= MainLayout::\$children ?&gt;<br><strong>$layoutPath</strong>";
1099
- $htmlMessage = "<div class='error'>The parent layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; Or &lt;?= MainLayout::\$children ?&gt;<br><strong>$layoutPath</strong></div>";
1100
- }
1375
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1376
+ $htmlMessage = "<div class='error'>The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;<br><strong>$layoutPath</strong></div>";
1101
1377
 
1102
1378
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1103
1379