create-prisma-php-app 4.4.0-beta → 4.4.0

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