create-prisma-php-app 4.4.4-beta → 4.4.4

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
@@ -424,17 +606,22 @@ final class Bootstrap extends RuntimeException
424
606
  }
425
607
  }
426
608
  } elseif (strpos($normalizedRoute, '[...') !== false) {
427
- if (count($pathnameSegments) <= $expectedSegmentCount) {
609
+ $cleanedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
610
+ $cleanedRoute = preg_replace('/\/+/', '/', $cleanedRoute);
611
+ $staticPart = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedRoute);
612
+ $staticSegments = array_filter(explode('/', $staticPart));
613
+ $minRequiredSegments = count($staticSegments);
614
+
615
+ if (count($pathnameSegments) < $minRequiredSegments) {
428
616
  continue;
429
617
  }
430
618
 
431
- $cleanedNormalizedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
432
- $cleanedNormalizedRoute = preg_replace('/\/+/', '/', $cleanedNormalizedRoute);
433
- $dynamicSegmentRoute = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedNormalizedRoute);
619
+ $cleanedNormalizedRoute = $cleanedRoute;
620
+ $dynamicSegmentRoute = $staticPart;
434
621
 
435
622
  if (strpos("/src/app/$normalizedPathname", $dynamicSegmentRoute) === 0) {
436
623
  $trimmedPathname = str_replace($dynamicSegmentRoute, '', "/src/app/$normalizedPathname");
437
- $pathnameParts = explode('/', trim($trimmedPathname, '/'));
624
+ $pathnameParts = $trimmedPathname === '' ? [] : explode('/', trim($trimmedPathname, '/'));
438
625
 
439
626
  if (preg_match('/\[\.\.\.(.*?)\]/', $normalizedRoute, $matches)) {
440
627
  $dynamicParam = $matches[1];
@@ -451,18 +638,14 @@ final class Bootstrap extends RuntimeException
451
638
 
452
639
  if (strpos($normalizedRoute, 'index.php') !== false) {
453
640
  $segmentMatch = "[...$dynamicParam]";
454
- $index = array_search($segmentMatch, $filteredRouteSegments);
455
-
456
- if ($index !== false && isset($pathnameSegments[$index])) {
457
- $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
458
- $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
459
641
 
460
- $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
642
+ $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
643
+ $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
644
+ $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
461
645
 
462
- if ($expectedPathname === $dynamicRoutePathnameDirname) {
463
- $pathnameMatch = $normalizedRoute;
464
- break;
465
- }
646
+ if ($expectedPathname === $dynamicRoutePathnameDirname) {
647
+ $pathnameMatch = $normalizedRoute;
648
+ break;
466
649
  }
467
650
  }
468
651
  }
@@ -483,6 +666,7 @@ final class Bootstrap extends RuntimeException
483
666
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
484
667
  continue;
485
668
  }
669
+
486
670
  $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
487
671
  $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
488
672
 
@@ -494,22 +678,26 @@ final class Bootstrap extends RuntimeException
494
678
  }
495
679
  }
496
680
 
497
- return $bestMatch;
498
- }
681
+ if (!$bestMatch) {
682
+ foreach (PrismaPHPSettings::$routeFiles as $route) {
683
+ if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
684
+ continue;
685
+ }
499
686
 
500
- private static function getGroupFolder($pathname): string
501
- {
502
- $lastSlashPos = strrpos($pathname, '/');
503
- if ($lastSlashPos === false) {
504
- return "";
505
- }
687
+ $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
506
688
 
507
- $pathWithoutFile = substr($pathname, 0, $lastSlashPos);
508
- if (preg_match('/\(([^)]+)\)[^()]*$/', $pathWithoutFile, $matches)) {
509
- return $pathWithoutFile;
689
+ if (preg_match('/\/\(([^)]+)\)\//', $normalizedRoute, $matches)) {
690
+ $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
691
+
692
+ if ($cleanedRoute === $routeFile || $cleanedRoute === $indexFile) {
693
+ $bestMatch = $normalizedRoute;
694
+ break;
695
+ }
696
+ }
697
+ }
510
698
  }
511
699
 
512
- return "";
700
+ return $bestMatch;
513
701
  }
514
702
 
515
703
  private static function singleDynamicRoute($pathnameSegments, $routeSegments)
@@ -529,7 +717,7 @@ final class Bootstrap extends RuntimeException
529
717
 
530
718
  private static function checkForDuplicateRoutes(): void
531
719
  {
532
- if (isset($_ENV['APP_ENV']) && $_ENV['APP_ENV'] === 'production') {
720
+ if (Env::string('APP_ENV', 'production') === 'production') {
533
721
  return;
534
722
  }
535
723
 
@@ -578,7 +766,7 @@ final class Bootstrap extends RuntimeException
578
766
  }
579
767
  }
580
768
 
581
- public static function containsChildLayoutChildren($filePath): bool
769
+ public static function containsChildren($filePath): bool
582
770
  {
583
771
  if (!self::fileExistsCached($filePath)) {
584
772
  return false;
@@ -589,118 +777,76 @@ final class Bootstrap extends RuntimeException
589
777
  return false;
590
778
  }
591
779
 
592
- $pattern = '/\<\?=\s*MainLayout::\$childLayoutChildren\s*;?\s*\?>|echo\s*MainLayout::\$childLayoutChildren\s*;?/';
780
+ $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
593
781
  return (bool) preg_match($pattern, $fileContent);
594
782
  }
595
783
 
596
- private static function containsChildren($filePath): bool
784
+ private static function convertToArrayObject($data)
597
785
  {
598
- if (!self::fileExistsCached($filePath)) {
599
- return false;
786
+ if (!is_array($data)) {
787
+ return $data;
600
788
  }
601
789
 
602
- $fileContent = @file_get_contents($filePath);
603
- if ($fileContent === false) {
604
- return false;
790
+ if (empty($data)) {
791
+ return $data;
605
792
  }
606
793
 
607
- $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
608
- return (bool) preg_match($pattern, $fileContent);
609
- }
794
+ $isAssoc = array_keys($data) !== range(0, count($data) - 1);
610
795
 
611
- private static function convertToArrayObject($data)
612
- {
613
- 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
+ }
614
805
  }
615
806
 
616
807
  public static function wireCallback(): void
617
808
  {
618
- $data = self::getRequestData();
809
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
619
810
 
620
- if (empty($data['callback'])) {
621
- 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]);
622
813
  }
623
814
 
624
- try {
625
- $aesKey = self::getAesKeyFromJwt();
626
- } catch (RuntimeException $e) {
627
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
628
- }
815
+ self::validateCsrfToken();
629
816
 
630
- try {
631
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
632
- } catch (RuntimeException $e) {
633
- 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']);
634
819
  }
635
820
 
821
+ $data = self::getRequestData();
636
822
  $args = self::convertToArrayObject($data);
637
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
823
+
824
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
638
825
  ? self::dispatchMethod($callbackName, $args)
639
826
  : self::dispatchFunction($callbackName, $args);
640
827
 
641
- if ($out !== null) {
642
- self::jsonExit($out);
643
- }
644
- exit;
645
- }
646
-
647
- private static function getAesKeyFromJwt(): string
648
- {
649
- $token = $_COOKIE['pp_function_call_jwt'] ?? null;
650
- $jwtSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? null;
651
-
652
- if (!$token || !$jwtSecret) {
653
- throw new RuntimeException('Missing session key or secret');
828
+ if ($out instanceof SSE) {
829
+ $out->send();
830
+ exit;
654
831
  }
655
832
 
656
- try {
657
- $decoded = JWT::decode($token, new Key($jwtSecret, 'HS256'));
658
- } catch (Throwable) {
659
- throw new RuntimeException('Invalid session key');
833
+ if ($out instanceof Generator) {
834
+ (new SSE($out))->send();
835
+ exit;
660
836
  }
661
837
 
662
- $aesKey = base64_decode($decoded->k, true);
663
- if ($aesKey === false || strlen($aesKey) !== 32) {
664
- throw new RuntimeException('Bad key length');
838
+ if ($out !== null) {
839
+ self::jsonExit($out);
665
840
  }
666
-
667
- return $aesKey;
841
+ exit;
668
842
  }
669
843
 
670
- private static function jsonExit(array $payload): void
844
+ private static function jsonExit(mixed $payload): void
671
845
  {
672
846
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
673
847
  exit;
674
848
  }
675
849
 
676
- private static function decryptCallback(string $encrypted, string $aesKey): string
677
- {
678
- $parts = explode(':', $encrypted, 2);
679
- if (count($parts) !== 2) {
680
- throw new RuntimeException('Malformed callback payload');
681
- }
682
- [$ivB64, $ctB64] = $parts;
683
-
684
- $iv = base64_decode($ivB64, true);
685
- $ct = base64_decode($ctB64, true);
686
-
687
- if ($iv === false || strlen($iv) !== 16 || $ct === false) {
688
- throw new RuntimeException('Invalid callback payload');
689
- }
690
-
691
- $plain = openssl_decrypt($ct, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
692
- if ($plain === false) {
693
- throw new RuntimeException('Decryption failed');
694
- }
695
-
696
- $callback = preg_replace('/[^a-zA-Z0-9_:\->]/', '', $plain);
697
- if ($callback === '' || $callback[0] === '_') {
698
- throw new RuntimeException('Invalid callback');
699
- }
700
-
701
- return $callback;
702
- }
703
-
704
850
  private static function getRequestData(): array
705
851
  {
706
852
  if (!empty($_FILES)) {
@@ -728,17 +874,140 @@ final class Bootstrap extends RuntimeException
728
874
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
729
875
  }
730
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
+
731
976
  private static function dispatchFunction(string $fn, mixed $args)
732
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
+
733
994
  if (function_exists($fn) && is_callable($fn)) {
734
995
  try {
996
+ self::enforceRateLimit($attribute, "fn:$fn");
997
+
735
998
  $res = call_user_func($fn, $args);
736
- if ($res !== null) {
737
- return ['success' => true, 'error' => null, 'response' => $res];
999
+
1000
+ if ($res instanceof Generator || $res instanceof SSE) {
1001
+ return $res;
738
1002
  }
1003
+
739
1004
  return $res;
740
1005
  } catch (Throwable $e) {
741
- 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') {
742
1011
  return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
743
1012
  } else {
744
1013
  return ['success' => false, 'error' => "Function error: {$e->getMessage()}"];
@@ -750,6 +1019,17 @@ final class Bootstrap extends RuntimeException
750
1019
 
751
1020
  private static function dispatchMethod(string $call, mixed $args)
752
1021
  {
1022
+ if (!self::isMethodAllowed(
1023
+ strpos($call, '->') !== false
1024
+ ? explode('->', $call, 2)[0]
1025
+ : explode('::', $call, 2)[0],
1026
+ strpos($call, '->') !== false
1027
+ ? explode('->', $call, 2)[1]
1028
+ : explode('::', $call, 2)[1]
1029
+ )) {
1030
+ return ['success' => false, 'error' => 'Method not callable from client'];
1031
+ }
1032
+
753
1033
  if (strpos($call, '->') !== false) {
754
1034
  list($requested, $method) = explode('->', $call, 2);
755
1035
  $isStatic = false;
@@ -766,47 +1046,48 @@ final class Bootstrap extends RuntimeException
766
1046
  }
767
1047
  }
768
1048
 
769
- if (!$isStatic) {
770
- if (!class_exists($class)) {
771
- return ['success' => false, 'error' => "Class '$requested' not found"];
772
- }
773
- $instance = new $class();
774
- if (!is_callable([$instance, $method])) {
775
- return ['success' => false, 'error' => "Method '$method' not callable on $class"];
776
- }
777
- try {
1049
+ if (!class_exists($class)) {
1050
+ return ['success' => false, 'error' => "Class '$requested' not found"];
1051
+ }
1052
+ $attribute = self::getExposedAttribute($class, $method);
1053
+
1054
+ if (!$attribute) {
1055
+ return ['success' => false, 'error' => 'Method not callable from client'];
1056
+ }
1057
+
1058
+ if (!self::validateAccess($attribute)) {
1059
+ return ['success' => false, 'error' => 'Permission denied'];
1060
+ }
1061
+
1062
+ try {
1063
+ self::enforceRateLimit($attribute, "method:$class::$method");
1064
+
1065
+ $res = null;
1066
+ if (!$isStatic) {
1067
+ $instance = new $class();
1068
+ if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
778
1069
  $res = call_user_func([$instance, $method], $args);
779
- if ($res !== null) {
780
- return ['success' => true, 'error' => null, 'response' => $res];
781
- }
1070
+ } else {
1071
+ if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
1072
+ $res = call_user_func([$class, $method], $args);
1073
+ }
1074
+
1075
+ if ($res instanceof Generator || $res instanceof SSE) {
782
1076
  return $res;
783
- } catch (Throwable $e) {
784
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
785
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
786
- } else {
787
- return ['success' => false, 'error' => "Instance call error: {$e->getMessage()}"];
788
- }
789
1077
  }
790
- } else {
791
- if (!class_exists($class) || !is_callable([$class, $method])) {
792
- return ['success' => false, 'error' => "Static method '$requested::$method' invalid"];
1078
+
1079
+ return $res;
1080
+ } catch (Throwable $e) {
1081
+ if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1082
+ return ['success' => false, 'error' => $e->getMessage()];
793
1083
  }
794
- try {
795
- $res = call_user_func([$class, $method], $args);
796
- if ($res !== null) {
797
- return ['success' => true, 'error' => null, 'response' => $res];
798
- }
799
- return $res;
800
- } catch (Throwable $e) {
801
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
802
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
803
- } else {
804
- return ['success' => false, 'error' => "Static call error: {$e->getMessage()}"];
805
- }
1084
+
1085
+ if (Env::string('SHOW_ERRORS', 'false') === 'false') {
1086
+ return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1087
+ } else {
1088
+ return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
806
1089
  }
807
1090
  }
808
-
809
- return ['success' => false, 'error' => 'Invalid callback'];
810
1091
  }
811
1092
 
812
1093
  private static function resolveClassImport(string $simpleClassKey): ?array
@@ -949,6 +1230,22 @@ final class Bootstrap extends RuntimeException
949
1230
 
950
1231
  return Request::$isAjax || Request::$isXFileRequest || Request::$fileToInclude === 'route.php';
951
1232
  }
1233
+
1234
+ public static function applyRootLayoutId(string $html): string
1235
+ {
1236
+ $rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
1237
+ $rootLayoutId = !empty($rootLayoutPath) ? md5($rootLayoutPath) : 'default-root';
1238
+
1239
+ header('X-PP-Root-Layout: ' . $rootLayoutId);
1240
+
1241
+ $rootLayoutMeta = '<meta name="pp-root-layout" content="' . $rootLayoutId . '">';
1242
+
1243
+ if (strpos($html, '<head>') !== false) {
1244
+ return preg_replace('/<head>/', "<head>\n $rootLayoutMeta", $html, 1);
1245
+ }
1246
+
1247
+ return $rootLayoutMeta . $html;
1248
+ }
952
1249
  }
953
1250
 
954
1251
  Bootstrap::run();
@@ -989,40 +1286,32 @@ try {
989
1286
  }
990
1287
 
991
1288
  if (!empty(Bootstrap::$contentToInclude) && !empty(Request::$fileToInclude)) {
992
- if (!Bootstrap::$isParentLayout) {
993
- ob_start();
994
- require_once Bootstrap::$contentToInclude;
995
- MainLayout::$childLayoutChildren = ob_get_clean();
996
- }
1289
+ ob_start();
1290
+ require_once Bootstrap::$contentToInclude;
1291
+ MainLayout::$children = ob_get_clean();
997
1292
 
998
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
999
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1000
- continue;
1001
- }
1293
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1294
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1002
1295
 
1003
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1004
- Bootstrap::$isChildContentIncluded = true;
1005
- }
1296
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1297
+ if (!Bootstrap::containsChildren($layoutPath)) {
1298
+ Bootstrap::$isChildContentIncluded = true;
1299
+ }
1006
1300
 
1007
- ob_start();
1008
- require_once $layoutPath;
1009
- MainLayout::$childLayoutChildren = ob_get_clean();
1301
+ ob_start();
1302
+ require_once $layoutPath;
1303
+ MainLayout::$children = ob_get_clean();
1304
+ }
1010
1305
  }
1011
1306
  } else {
1012
1307
  ob_start();
1013
1308
  require_once APP_PATH . '/not-found.php';
1014
- MainLayout::$childLayoutChildren = ob_get_clean();
1309
+ MainLayout::$children = ob_get_clean();
1015
1310
 
1016
1311
  http_response_code(404);
1017
1312
  CacheHandler::$isCacheable = false;
1018
1313
  }
1019
1314
 
1020
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1021
- ob_start();
1022
- require_once Bootstrap::$contentToInclude;
1023
- MainLayout::$childLayoutChildren = ob_get_clean();
1024
- }
1025
-
1026
1315
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1027
1316
  if (!Bootstrap::$secondRequestC69CD) {
1028
1317
  Bootstrap::createUpdateRequestData();
@@ -1034,69 +1323,63 @@ try {
1034
1323
  if (file_exists($file)) {
1035
1324
  ob_start();
1036
1325
  require_once $file;
1037
- MainLayout::$childLayoutChildren .= ob_get_clean();
1326
+ MainLayout::$children .= ob_get_clean();
1038
1327
  }
1039
1328
  }
1040
1329
  }
1041
1330
  }
1042
1331
 
1043
1332
  if (Request::$isWire && !Bootstrap::$secondRequestC69CD) {
1044
- ob_end_clean();
1333
+ while (ob_get_level() > 0) {
1334
+ ob_end_clean();
1335
+ }
1045
1336
  Bootstrap::wireCallback();
1046
1337
  }
1047
1338
 
1048
1339
  if ((!Request::$isWire && !Bootstrap::$secondRequestC69CD) && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
1049
- if ($_ENV['CACHE_ENABLED'] === 'true') {
1050
- CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL']));
1340
+ $cacheEnabled = (Env::string('CACHE_ENABLED', 'false') === 'true');
1341
+
1342
+ $shouldCache = CacheHandler::$isCacheable === true
1343
+ || (CacheHandler::$isCacheable === null && $cacheEnabled);
1344
+
1345
+ if ($shouldCache) {
1346
+ CacheHandler::serveCache(Request::$decodedUri, intval(Env::string('CACHE_TTL', '600')));
1051
1347
  }
1052
1348
  }
1053
1349
 
1054
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1350
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1055
1351
 
1056
1352
  ob_start();
1057
- require_once APP_PATH . '/layout.php';
1353
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1354
+ require_once Bootstrap::$parentLayoutPath;
1355
+ } else {
1356
+ echo MainLayout::$children;
1357
+ }
1358
+
1058
1359
  MainLayout::$html = ob_get_clean();
1059
1360
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1060
1361
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1362
+ MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1363
+
1061
1364
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1062
1365
 
1063
1366
  if (
1064
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1367
+ http_response_code() === 200
1368
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1369
+ && $shouldCache
1370
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1065
1371
  ) {
1066
1372
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1067
1373
  }
1068
1374
 
1069
- if (Bootstrap::$isPartialRequest) {
1070
- $parts = PartialRenderer::extract(
1071
- MainLayout::$html,
1072
- Bootstrap::$partialSelectors
1073
- );
1074
-
1075
- if (count($parts) === 1) {
1076
- echo reset($parts);
1077
- } else {
1078
- header('Content-Type: application/json');
1079
- echo json_encode(
1080
- ['success' => true, 'fragments' => $parts],
1081
- JSON_UNESCAPED_UNICODE
1082
- );
1083
- }
1084
- exit;
1085
- }
1086
-
1087
1375
  echo MainLayout::$html;
1088
1376
  } else {
1089
1377
  $layoutPath = Bootstrap::$isContentIncluded
1090
1378
  ? Bootstrap::$parentLayoutPath
1091
1379
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1092
1380
 
1093
- $message = "The layout file does not contain &lt;?php echo MainLayout::\$childLayoutChildren; ?&gt; or &lt;?= MainLayout::\$childLayoutChildren ?&gt;\n<strong>$layoutPath</strong>";
1094
- $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>";
1095
-
1096
- if (Bootstrap::$isContentIncluded) {
1097
- $message = "The parent layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; Or &lt;?= MainLayout::\$children ?&gt;<br><strong>$layoutPath</strong>";
1098
- $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>";
1099
- }
1381
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1382
+ $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>";
1100
1383
 
1101
1384
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1102
1385