create-prisma-php-app 4.2.4-beta → 4.2.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.
@@ -24,9 +24,7 @@ use PP\MainLayout;
24
24
  use PP\PHPX\TemplateCompiler;
25
25
  use PP\CacheHandler;
26
26
  use PP\ErrorHandler;
27
- use Firebase\JWT\JWT;
28
- use Firebase\JWT\Key;
29
- use PP\PartialRenderer;
27
+ use PP\Attributes\Exposed;
30
28
 
31
29
  final class Bootstrap extends RuntimeException
32
30
  {
@@ -40,8 +38,6 @@ final class Bootstrap extends RuntimeException
40
38
  public static bool $isContentVariableIncluded = false;
41
39
  public static bool $secondRequestC69CD = false;
42
40
  public static array $requestFilesData = [];
43
- public static array $partialSelectors = [];
44
- public static bool $isPartialRequest = false;
45
41
 
46
42
  private string $context;
47
43
 
@@ -78,7 +74,7 @@ final class Bootstrap extends RuntimeException
78
74
  'samesite' => 'Lax',
79
75
  ]);
80
76
 
81
- self::functionCallNameEncrypt();
77
+ self::setCsrfCookie();
82
78
 
83
79
  self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
84
80
 
@@ -106,24 +102,20 @@ final class Bootstrap extends RuntimeException
106
102
  self::authenticateUserToken();
107
103
 
108
104
  self::$requestFilePath = APP_PATH . Request::$pathname;
109
- self::$parentLayoutPath = APP_PATH . '/layout.php';
110
105
 
111
- self::$isParentLayout = !empty(self::$layoutsToInclude)
112
- && strpos(self::$layoutsToInclude[0], 'src/app/layout.php') !== false;
106
+ if (!empty(self::$layoutsToInclude)) {
107
+ self::$parentLayoutPath = self::$layoutsToInclude[0];
108
+ self::$isParentLayout = true;
109
+ } else {
110
+ self::$parentLayoutPath = APP_PATH . '/layout.php';
111
+ self::$isParentLayout = false;
112
+ }
113
113
 
114
114
  self::$isContentVariableIncluded = self::containsChildren(self::$parentLayoutPath);
115
115
  if (!self::$isContentVariableIncluded) {
116
116
  self::$isContentIncluded = true;
117
117
  }
118
118
 
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
119
  self::$requestFilesData = PrismaPHPSettings::$includeFiles;
128
120
 
129
121
  ErrorHandler::checkFatalError();
@@ -131,66 +123,62 @@ final class Bootstrap extends RuntimeException
131
123
 
132
124
  private static function isLocalStoreCallback(): void
133
125
  {
134
- $data = self::getRequestData();
135
-
136
- if (empty($data['callback'])) {
137
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
138
- }
139
-
140
- try {
141
- $aesKey = self::getAesKeyFromJwt();
142
- } catch (RuntimeException $e) {
143
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
126
+ if (empty($_SERVER['HTTP_X_PP_FUNCTION'])) {
127
+ return;
144
128
  }
145
129
 
146
- try {
147
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
148
- } catch (RuntimeException $e) {
149
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
150
- }
130
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'];
151
131
 
152
132
  if ($callbackName === PrismaPHPSettings::$localStoreKey) {
133
+ self::validateCsrfToken();
153
134
  self::jsonExit(['success' => true, 'response' => 'localStorage updated']);
154
135
  }
155
136
  }
156
137
 
157
- private static function functionCallNameEncrypt(): void
138
+ private static function setCsrfCookie(): void
158
139
  {
159
- $hmacSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
160
- if ($hmacSecret === '') {
161
- throw new RuntimeException("FUNCTION_CALL_SECRET is not set");
140
+ if (!isset($_COOKIE['prisma_php_csrf'])) {
141
+ $secret = $_ENV['FUNCTION_CALL_SECRET'] ?? 'pp_default_insecure_secret';
142
+ $nonce = bin2hex(random_bytes(16));
143
+ $signature = hash_hmac('sha256', $nonce, $secret);
144
+ $token = $nonce . '.' . $signature;
145
+
146
+ setcookie('prisma_php_csrf', $token, [
147
+ 'expires' => time() + 3600,
148
+ 'path' => '/',
149
+ 'secure' => true,
150
+ 'httponly' => false,
151
+ 'samesite' => 'Lax',
152
+ ]);
153
+ $_COOKIE['prisma_php_csrf'] = $token;
162
154
  }
155
+ }
163
156
 
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;
170
- }
171
- } catch (Throwable) {
172
- }
157
+ private static function validateCsrfToken(): void
158
+ {
159
+ $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
160
+ $cookieToken = $_COOKIE['prisma_php_csrf'] ?? '';
161
+ $secret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
162
+
163
+ if (empty($headerToken) || empty($cookieToken)) {
164
+ self::jsonExit(['success' => false, 'error' => 'CSRF token missing']);
173
165
  }
174
166
 
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');
167
+ if (!hash_equals($cookieToken, $headerToken)) {
168
+ self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
169
+ }
182
170
 
183
- setcookie(
184
- 'pp_function_call_jwt',
185
- $jwt,
186
- [
187
- 'expires' => $payload['exp'],
188
- 'path' => '/',
189
- 'secure' => true,
190
- 'httponly' => false,
191
- 'samesite' => 'Strict',
192
- ]
193
- );
171
+ $parts = explode('.', $cookieToken);
172
+ if (count($parts) !== 2) {
173
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
174
+ }
175
+
176
+ [$nonce, $signature] = $parts;
177
+ $expectedSignature = hash_hmac('sha256', $nonce, $secret);
178
+
179
+ if (!hash_equals($expectedSignature, $signature)) {
180
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
181
+ }
194
182
  }
195
183
 
196
184
  private static function fileExistsCached(string $path): bool
@@ -270,72 +258,245 @@ final class Bootstrap extends RuntimeException
270
258
  }
271
259
  }
272
260
 
261
+ $layoutsToInclude = self::collectLayouts($pathname, $groupFolder, $dynamicRoute ?? null);
262
+ } else {
263
+ $contentData = self::getFilePrecedence();
264
+ $includePath = $baseDir . $contentData['file'];
265
+ $layoutsToInclude = self::collectRootLayouts($contentData['file']);
266
+ }
267
+
268
+ return [
269
+ 'path' => $includePath,
270
+ 'layouts' => $layoutsToInclude,
271
+ 'pathname' => $pathname,
272
+ 'uri' => $requestUri
273
+ ];
274
+ }
275
+
276
+ private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
277
+ {
278
+ $layoutsToInclude = [];
279
+ $baseDir = APP_PATH;
280
+
281
+ $rootLayout = $baseDir . '/layout.php';
282
+ if (self::fileExistsCached($rootLayout)) {
283
+ $layoutsToInclude[] = $rootLayout;
284
+ }
285
+
286
+ $groupName = null;
287
+ $groupParentPath = '';
288
+ $pathAfterGroup = '';
289
+
290
+ if ($groupFolder) {
291
+ $normalizedGroupFolder = str_replace('\\', '/', $groupFolder);
292
+
293
+ if (preg_match('#^\.?/src/app/(.+)/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
294
+ $groupParentPath = $matches[1];
295
+ $groupName = $matches[2];
296
+ $pathAfterGroup = dirname($matches[3]);
297
+ if ($pathAfterGroup === '.') {
298
+ $pathAfterGroup = '';
299
+ }
300
+ } elseif (preg_match('#^\.?/src/app/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
301
+ $groupName = $matches[1];
302
+ $pathAfterGroup = dirname($matches[2]);
303
+ if ($pathAfterGroup === '.') {
304
+ $pathAfterGroup = '';
305
+ }
306
+ }
307
+ }
308
+
309
+ if ($groupName && $groupParentPath) {
273
310
  $currentPath = $baseDir;
274
- $getGroupFolder = self::getGroupFolder($groupFolder);
275
- $modifiedPathname = $pathname;
276
- if (!empty($getGroupFolder)) {
277
- $modifiedPathname = trim($getGroupFolder, "/src/app/");
311
+ foreach (explode('/', $groupParentPath) as $segment) {
312
+ if (empty($segment)) continue;
313
+
314
+ $currentPath .= '/' . $segment;
315
+ $potentialLayoutPath = $currentPath . '/layout.php';
316
+
317
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
318
+ $layoutsToInclude[] = $potentialLayoutPath;
319
+ }
278
320
  }
279
321
 
280
- foreach (explode('/', $modifiedPathname) as $segment) {
281
- if (empty($segment)) {
282
- continue;
322
+ $groupLayoutPath = $baseDir . '/' . $groupParentPath . "/($groupName)/layout.php";
323
+ if (self::fileExistsCached($groupLayoutPath)) {
324
+ $layoutsToInclude[] = $groupLayoutPath;
325
+ }
326
+
327
+ if (!empty($pathAfterGroup)) {
328
+ $currentPath = $baseDir . '/' . $groupParentPath . "/($groupName)";
329
+ foreach (explode('/', $pathAfterGroup) as $segment) {
330
+ if (empty($segment)) continue;
331
+
332
+ $currentPath .= '/' . $segment;
333
+ $potentialLayoutPath = $currentPath . '/layout.php';
334
+
335
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
336
+ $layoutsToInclude[] = $potentialLayoutPath;
337
+ }
338
+ }
339
+ }
340
+ } elseif ($groupName && !$groupParentPath) {
341
+ $groupLayoutPath = $baseDir . "/($groupName)/layout.php";
342
+ if (self::fileExistsCached($groupLayoutPath)) {
343
+ $layoutsToInclude[] = $groupLayoutPath;
344
+ }
345
+
346
+ if (!empty($pathAfterGroup)) {
347
+ $currentPath = $baseDir . "/($groupName)";
348
+ foreach (explode('/', $pathAfterGroup) as $segment) {
349
+ if (empty($segment)) continue;
350
+
351
+ $currentPath .= '/' . $segment;
352
+ $potentialLayoutPath = $currentPath . '/layout.php';
353
+
354
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
355
+ $layoutsToInclude[] = $potentialLayoutPath;
356
+ }
283
357
  }
358
+ }
359
+ } else {
360
+ $currentPath = $baseDir;
361
+ foreach (explode('/', $pathname) as $segment) {
362
+ if (empty($segment)) continue;
284
363
 
285
364
  $currentPath .= '/' . $segment;
286
365
  $potentialLayoutPath = $currentPath . '/layout.php';
366
+
367
+ if ($potentialLayoutPath === $rootLayout) {
368
+ continue;
369
+ }
370
+
287
371
  if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
288
372
  $layoutsToInclude[] = $potentialLayoutPath;
289
373
  }
290
374
  }
375
+ }
291
376
 
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') {
377
+ if (isset($dynamicRoute) && !empty($dynamicRoute)) {
378
+ $currentDynamicPath = $baseDir;
379
+ foreach (explode('/', $dynamicRoute) as $segment) {
380
+ if (empty($segment) || $segment === 'src' || $segment === 'app') {
381
+ continue;
382
+ }
383
+
384
+ $currentDynamicPath .= '/' . $segment;
385
+ $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
386
+ if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
387
+ $layoutsToInclude[] = $potentialDynamicRoute;
388
+ }
389
+ }
390
+ }
391
+
392
+ if (empty($layoutsToInclude)) {
393
+ $layoutsToInclude = self::findFirstGroupLayout();
394
+ }
395
+
396
+ return $layoutsToInclude;
397
+ }
398
+
399
+ private static function collectRootLayouts(?string $matchedContentFile = null): array
400
+ {
401
+ $layoutsToInclude = [];
402
+ $baseDir = APP_PATH;
403
+ $rootLayout = $baseDir . '/layout.php';
404
+
405
+ if (self::fileExistsCached($rootLayout)) {
406
+ $layoutsToInclude[] = $rootLayout;
407
+ } else {
408
+ $layoutsToInclude = self::findFirstGroupLayout($matchedContentFile);
409
+
410
+ if (empty($layoutsToInclude)) {
411
+ return [];
412
+ }
413
+ }
414
+
415
+ return $layoutsToInclude;
416
+ }
417
+
418
+ private static function findFirstGroupLayout(?string $matchedContentFile = null): array
419
+ {
420
+ $baseDir = APP_PATH;
421
+ $layoutsToInclude = [];
422
+
423
+ if (is_dir($baseDir)) {
424
+ $items = scandir($baseDir);
425
+
426
+ if ($matchedContentFile) {
427
+ foreach ($items as $item) {
428
+ if ($item === '.' || $item === '..') {
299
429
  continue;
300
430
  }
301
431
 
302
- $currentDynamicPath .= '/' . $segment;
303
- $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
304
- if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
305
- $layoutsToInclude[] = $potentialDynamicRoute;
432
+ if (preg_match('/^\([^)]+\)$/', $item)) {
433
+ if (strpos($matchedContentFile, "/$item/") === 0) {
434
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
435
+ if (self::fileExistsCached($groupLayoutPath)) {
436
+ $layoutsToInclude[] = $groupLayoutPath;
437
+ return $layoutsToInclude;
438
+ }
439
+ }
306
440
  }
307
441
  }
308
442
  }
309
443
 
310
- if (empty($layoutsToInclude)) {
311
- $layoutsToInclude[] = $baseDir . '/layout.php';
444
+ foreach ($items as $item) {
445
+ if ($item === '.' || $item === '..') {
446
+ continue;
447
+ }
448
+
449
+ if (preg_match('/^\([^)]+\)$/', $item)) {
450
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
451
+ if (self::fileExistsCached($groupLayoutPath)) {
452
+ $layoutsToInclude[] = $groupLayoutPath;
453
+ break;
454
+ }
455
+ }
312
456
  }
313
- } else {
314
- $includePath = $baseDir . self::getFilePrecedence();
315
457
  }
316
458
 
317
- return [
318
- 'path' => $includePath,
319
- 'layouts' => $layoutsToInclude,
320
- 'pathname' => $pathname,
321
- 'uri' => $requestUri
322
- ];
459
+ return $layoutsToInclude;
323
460
  }
324
461
 
325
- private static function getFilePrecedence(): ?string
462
+ private static function getFilePrecedence(): array
326
463
  {
464
+ $baseDir = APP_PATH;
465
+ $result = ['file' => null];
466
+
327
467
  foreach (PrismaPHPSettings::$routeFiles as $route) {
328
468
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
329
469
  continue;
330
470
  }
331
471
  if (preg_match('/^\.\/src\/app\/route\.php$/', $route)) {
332
- return '/route.php';
472
+ return ['file' => '/route.php'];
333
473
  }
334
474
  if (preg_match('/^\.\/src\/app\/index\.php$/', $route)) {
335
- return '/index.php';
475
+ return ['file' => '/index.php'];
476
+ }
477
+ }
478
+
479
+ if (is_dir($baseDir)) {
480
+ $items = scandir($baseDir);
481
+ foreach ($items as $item) {
482
+ if ($item === '.' || $item === '..') {
483
+ continue;
484
+ }
485
+
486
+ if (preg_match('/^\([^)]+\)$/', $item)) {
487
+ $groupDir = $baseDir . '/' . $item;
488
+
489
+ if (file_exists($groupDir . '/route.php')) {
490
+ return ['file' => '/' . $item . '/route.php'];
491
+ }
492
+ if (file_exists($groupDir . '/index.php')) {
493
+ return ['file' => '/' . $item . '/index.php'];
494
+ }
495
+ }
336
496
  }
337
497
  }
338
- return null;
498
+
499
+ return $result;
339
500
  }
340
501
 
341
502
  private static function uriExtractor(string $scriptUrl): string
@@ -424,17 +585,22 @@ final class Bootstrap extends RuntimeException
424
585
  }
425
586
  }
426
587
  } elseif (strpos($normalizedRoute, '[...') !== false) {
427
- if (count($pathnameSegments) <= $expectedSegmentCount) {
588
+ $cleanedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
589
+ $cleanedRoute = preg_replace('/\/+/', '/', $cleanedRoute);
590
+ $staticPart = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedRoute);
591
+ $staticSegments = array_filter(explode('/', $staticPart));
592
+ $minRequiredSegments = count($staticSegments);
593
+
594
+ if (count($pathnameSegments) < $minRequiredSegments) {
428
595
  continue;
429
596
  }
430
597
 
431
- $cleanedNormalizedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
432
- $cleanedNormalizedRoute = preg_replace('/\/+/', '/', $cleanedNormalizedRoute);
433
- $dynamicSegmentRoute = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedNormalizedRoute);
598
+ $cleanedNormalizedRoute = $cleanedRoute;
599
+ $dynamicSegmentRoute = $staticPart;
434
600
 
435
601
  if (strpos("/src/app/$normalizedPathname", $dynamicSegmentRoute) === 0) {
436
602
  $trimmedPathname = str_replace($dynamicSegmentRoute, '', "/src/app/$normalizedPathname");
437
- $pathnameParts = explode('/', trim($trimmedPathname, '/'));
603
+ $pathnameParts = $trimmedPathname === '' ? [] : explode('/', trim($trimmedPathname, '/'));
438
604
 
439
605
  if (preg_match('/\[\.\.\.(.*?)\]/', $normalizedRoute, $matches)) {
440
606
  $dynamicParam = $matches[1];
@@ -451,18 +617,14 @@ final class Bootstrap extends RuntimeException
451
617
 
452
618
  if (strpos($normalizedRoute, 'index.php') !== false) {
453
619
  $segmentMatch = "[...$dynamicParam]";
454
- $index = array_search($segmentMatch, $filteredRouteSegments);
455
620
 
456
- if ($index !== false && isset($pathnameSegments[$index])) {
457
- $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
458
- $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
621
+ $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
622
+ $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
623
+ $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
459
624
 
460
- $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
461
-
462
- if ($expectedPathname === $dynamicRoutePathnameDirname) {
463
- $pathnameMatch = $normalizedRoute;
464
- break;
465
- }
625
+ if ($expectedPathname === $dynamicRoutePathnameDirname) {
626
+ $pathnameMatch = $normalizedRoute;
627
+ break;
466
628
  }
467
629
  }
468
630
  }
@@ -483,6 +645,7 @@ final class Bootstrap extends RuntimeException
483
645
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
484
646
  continue;
485
647
  }
648
+
486
649
  $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
487
650
  $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
488
651
 
@@ -494,22 +657,26 @@ final class Bootstrap extends RuntimeException
494
657
  }
495
658
  }
496
659
 
497
- return $bestMatch;
498
- }
660
+ if (!$bestMatch) {
661
+ foreach (PrismaPHPSettings::$routeFiles as $route) {
662
+ if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
663
+ continue;
664
+ }
499
665
 
500
- private static function getGroupFolder($pathname): string
501
- {
502
- $lastSlashPos = strrpos($pathname, '/');
503
- if ($lastSlashPos === false) {
504
- return "";
505
- }
666
+ $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
506
667
 
507
- $pathWithoutFile = substr($pathname, 0, $lastSlashPos);
508
- if (preg_match('/\(([^)]+)\)[^()]*$/', $pathWithoutFile, $matches)) {
509
- return $pathWithoutFile;
668
+ if (preg_match('/\/\(([^)]+)\)\//', $normalizedRoute, $matches)) {
669
+ $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
670
+
671
+ if ($cleanedRoute === $routeFile || $cleanedRoute === $indexFile) {
672
+ $bestMatch = $normalizedRoute;
673
+ break;
674
+ }
675
+ }
676
+ }
510
677
  }
511
678
 
512
- return "";
679
+ return $bestMatch;
513
680
  }
514
681
 
515
682
  private static function singleDynamicRoute($pathnameSegments, $routeSegments)
@@ -578,7 +745,7 @@ final class Bootstrap extends RuntimeException
578
745
  }
579
746
  }
580
747
 
581
- public static function containsChildLayoutChildren($filePath): bool
748
+ public static function containsChildren($filePath): bool
582
749
  {
583
750
  if (!self::fileExistsCached($filePath)) {
584
751
  return false;
@@ -589,52 +756,51 @@ final class Bootstrap extends RuntimeException
589
756
  return false;
590
757
  }
591
758
 
592
- $pattern = '/\<\?=\s*MainLayout::\$childLayoutChildren\s*;?\s*\?>|echo\s*MainLayout::\$childLayoutChildren\s*;?/';
759
+ $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
593
760
  return (bool) preg_match($pattern, $fileContent);
594
761
  }
595
762
 
596
- private static function containsChildren($filePath): bool
763
+ private static function convertToArrayObject($data)
597
764
  {
598
- if (!self::fileExistsCached($filePath)) {
599
- return false;
765
+ if (!is_array($data)) {
766
+ return $data;
600
767
  }
601
768
 
602
- $fileContent = @file_get_contents($filePath);
603
- if ($fileContent === false) {
604
- return false;
769
+ if (empty($data)) {
770
+ return $data;
605
771
  }
606
772
 
607
- $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
608
- return (bool) preg_match($pattern, $fileContent);
609
- }
773
+ $isAssoc = array_keys($data) !== range(0, count($data) - 1);
610
774
 
611
- private static function convertToArrayObject($data)
612
- {
613
- return is_array($data) ? (object) $data : $data;
775
+ if ($isAssoc) {
776
+ $obj = new stdClass();
777
+ foreach ($data as $key => $value) {
778
+ $obj->$key = self::convertToArrayObject($value);
779
+ }
780
+ return $obj;
781
+ } else {
782
+ return array_map([self::class, 'convertToArrayObject'], $data);
783
+ }
614
784
  }
615
785
 
616
786
  public static function wireCallback(): void
617
787
  {
618
- $data = self::getRequestData();
788
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
619
789
 
620
- if (empty($data['callback'])) {
621
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
790
+ if (empty($callbackName)) {
791
+ self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
622
792
  }
623
793
 
624
- try {
625
- $aesKey = self::getAesKeyFromJwt();
626
- } catch (RuntimeException $e) {
627
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
628
- }
794
+ self::validateCsrfToken();
629
795
 
630
- try {
631
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
632
- } catch (RuntimeException $e) {
633
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
796
+ if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
797
+ self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
634
798
  }
635
799
 
800
+ $data = self::getRequestData();
636
801
  $args = self::convertToArrayObject($data);
637
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
802
+
803
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
638
804
  ? self::dispatchMethod($callbackName, $args)
639
805
  : self::dispatchFunction($callbackName, $args);
640
806
 
@@ -644,63 +810,12 @@ final class Bootstrap extends RuntimeException
644
810
  exit;
645
811
  }
646
812
 
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');
654
- }
655
-
656
- try {
657
- $decoded = JWT::decode($token, new Key($jwtSecret, 'HS256'));
658
- } catch (Throwable) {
659
- throw new RuntimeException('Invalid session key');
660
- }
661
-
662
- $aesKey = base64_decode($decoded->k, true);
663
- if ($aesKey === false || strlen($aesKey) !== 32) {
664
- throw new RuntimeException('Bad key length');
665
- }
666
-
667
- return $aesKey;
668
- }
669
-
670
813
  private static function jsonExit(array $payload): void
671
814
  {
672
815
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
673
816
  exit;
674
817
  }
675
818
 
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
819
  private static function getRequestData(): array
705
820
  {
706
821
  if (!empty($_FILES)) {
@@ -728,8 +843,78 @@ final class Bootstrap extends RuntimeException
728
843
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
729
844
  }
730
845
 
846
+ private static function validateAccess(Exposed $attribute): bool
847
+ {
848
+ if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
849
+ $auth = Auth::getInstance();
850
+
851
+ if (!$auth->isAuthenticated()) {
852
+ return false;
853
+ }
854
+
855
+ if (!empty($attribute->allowedRoles)) {
856
+ $payload = $auth->getPayload();
857
+ $currentRole = null;
858
+
859
+ if (is_scalar($payload)) {
860
+ $currentRole = $payload;
861
+ } else {
862
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
863
+
864
+ if (is_object($payload)) {
865
+ $currentRole = $payload->$roleKey ?? null;
866
+ } elseif (is_array($payload)) {
867
+ $currentRole = $payload[$roleKey] ?? null;
868
+ }
869
+ }
870
+
871
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
872
+ return false;
873
+ }
874
+ }
875
+ }
876
+
877
+ return true;
878
+ }
879
+
880
+ private static function isFunctionAllowed(string $fn): bool
881
+ {
882
+ try {
883
+ $ref = new ReflectionFunction($fn);
884
+ $attrs = $ref->getAttributes(Exposed::class);
885
+
886
+ if (empty($attrs)) {
887
+ return false;
888
+ }
889
+
890
+ return self::validateAccess($attrs[0]->newInstance());
891
+ } catch (Throwable) {
892
+ return false;
893
+ }
894
+ }
895
+
896
+ private static function isMethodAllowed(string $class, string $method): bool
897
+ {
898
+ try {
899
+ $ref = new ReflectionMethod($class, $method);
900
+ $attrs = $ref->getAttributes(Exposed::class);
901
+
902
+ if (empty($attrs)) {
903
+ return false;
904
+ }
905
+
906
+ return self::validateAccess($attrs[0]->newInstance());
907
+ } catch (Throwable) {
908
+ return false;
909
+ }
910
+ }
911
+
731
912
  private static function dispatchFunction(string $fn, mixed $args)
732
913
  {
914
+ if (!self::isFunctionAllowed($fn)) {
915
+ return ['success' => false, 'error' => 'Function not callable from client'];
916
+ }
917
+
733
918
  if (function_exists($fn) && is_callable($fn)) {
734
919
  try {
735
920
  $res = call_user_func($fn, $args);
@@ -750,6 +935,17 @@ final class Bootstrap extends RuntimeException
750
935
 
751
936
  private static function dispatchMethod(string $call, mixed $args)
752
937
  {
938
+ if (!self::isMethodAllowed(
939
+ strpos($call, '->') !== false
940
+ ? explode('->', $call, 2)[0]
941
+ : explode('::', $call, 2)[0],
942
+ strpos($call, '->') !== false
943
+ ? explode('->', $call, 2)[1]
944
+ : explode('::', $call, 2)[1]
945
+ )) {
946
+ return ['success' => false, 'error' => 'Method not callable from client'];
947
+ }
948
+
753
949
  if (strpos($call, '->') !== false) {
754
950
  list($requested, $method) = explode('->', $call, 2);
755
951
  $isStatic = false;
@@ -949,6 +1145,22 @@ final class Bootstrap extends RuntimeException
949
1145
 
950
1146
  return Request::$isAjax || Request::$isXFileRequest || Request::$fileToInclude === 'route.php';
951
1147
  }
1148
+
1149
+ public static function applyRootLayoutId(string $html): string
1150
+ {
1151
+ $rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
1152
+ $rootLayoutId = !empty($rootLayoutPath) ? md5($rootLayoutPath) : 'default-root';
1153
+
1154
+ header('X-PP-Root-Layout: ' . $rootLayoutId);
1155
+
1156
+ $rootLayoutMeta = '<meta name="pp-root-layout" content="' . $rootLayoutId . '">';
1157
+
1158
+ if (strpos($html, '<head>') !== false) {
1159
+ return preg_replace('/<head>/', "<head>\n $rootLayoutMeta", $html, 1);
1160
+ }
1161
+
1162
+ return $rootLayoutMeta . $html;
1163
+ }
952
1164
  }
953
1165
 
954
1166
  Bootstrap::run();
@@ -989,40 +1201,32 @@ try {
989
1201
  }
990
1202
 
991
1203
  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
- }
1204
+ ob_start();
1205
+ require_once Bootstrap::$contentToInclude;
1206
+ MainLayout::$children = ob_get_clean();
997
1207
 
998
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
999
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1000
- continue;
1001
- }
1208
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1209
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1002
1210
 
1003
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1004
- Bootstrap::$isChildContentIncluded = true;
1005
- }
1211
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1212
+ if (!Bootstrap::containsChildren($layoutPath)) {
1213
+ Bootstrap::$isChildContentIncluded = true;
1214
+ }
1006
1215
 
1007
- ob_start();
1008
- require_once $layoutPath;
1009
- MainLayout::$childLayoutChildren = ob_get_clean();
1216
+ ob_start();
1217
+ require_once $layoutPath;
1218
+ MainLayout::$children = ob_get_clean();
1219
+ }
1010
1220
  }
1011
1221
  } else {
1012
1222
  ob_start();
1013
1223
  require_once APP_PATH . '/not-found.php';
1014
- MainLayout::$childLayoutChildren = ob_get_clean();
1224
+ MainLayout::$children = ob_get_clean();
1015
1225
 
1016
1226
  http_response_code(404);
1017
1227
  CacheHandler::$isCacheable = false;
1018
1228
  }
1019
1229
 
1020
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1021
- ob_start();
1022
- require_once Bootstrap::$contentToInclude;
1023
- MainLayout::$childLayoutChildren = ob_get_clean();
1024
- }
1025
-
1026
1230
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1027
1231
  if (!Bootstrap::$secondRequestC69CD) {
1028
1232
  Bootstrap::createUpdateRequestData();
@@ -1034,7 +1238,7 @@ try {
1034
1238
  if (file_exists($file)) {
1035
1239
  ob_start();
1036
1240
  require_once $file;
1037
- MainLayout::$childLayoutChildren .= ob_get_clean();
1241
+ MainLayout::$children .= ob_get_clean();
1038
1242
  }
1039
1243
  }
1040
1244
  }
@@ -1046,57 +1250,47 @@ try {
1046
1250
  }
1047
1251
 
1048
1252
  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']));
1253
+ $shouldCache = CacheHandler::$isCacheable === true
1254
+ || (CacheHandler::$isCacheable === null && $_ENV['CACHE_ENABLED'] === 'true');
1255
+
1256
+ if ($shouldCache) {
1257
+ CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL'] ?? 600));
1051
1258
  }
1052
1259
  }
1053
1260
 
1054
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1261
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1055
1262
 
1056
1263
  ob_start();
1057
- require_once APP_PATH . '/layout.php';
1264
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1265
+ require_once Bootstrap::$parentLayoutPath;
1266
+ } else {
1267
+ echo MainLayout::$children;
1268
+ }
1269
+
1058
1270
  MainLayout::$html = ob_get_clean();
1059
1271
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1060
1272
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1273
+ MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1274
+
1061
1275
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1062
1276
 
1063
1277
  if (
1064
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1278
+ http_response_code() === 200
1279
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1280
+ && $shouldCache
1281
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1065
1282
  ) {
1066
1283
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1067
1284
  }
1068
1285
 
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
1286
  echo MainLayout::$html;
1088
1287
  } else {
1089
1288
  $layoutPath = Bootstrap::$isContentIncluded
1090
1289
  ? Bootstrap::$parentLayoutPath
1091
1290
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1092
1291
 
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
- }
1292
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1293
+ $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
1294
 
1101
1295
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1102
1296