create-prisma-php-app 4.2.0-beta → 4.2.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.
@@ -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['pp_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('pp_csrf', $token, [
147
+ 'expires' => time() + 3600,
148
+ 'path' => '/',
149
+ 'secure' => true,
150
+ 'httponly' => false,
151
+ 'samesite' => 'Lax',
152
+ ]);
153
+ $_COOKIE['pp_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['pp_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,60 +258,191 @@ final class Bootstrap extends RuntimeException
270
258
  }
271
259
  }
272
260
 
273
- $currentPath = $baseDir;
274
- $getGroupFolder = self::getGroupFolder($groupFolder);
275
- $modifiedPathname = $pathname;
276
- if (!empty($getGroupFolder)) {
277
- $modifiedPathname = trim($getGroupFolder, "/src/app/");
278
- }
261
+ $layoutsToInclude = self::collectLayouts($pathname, $groupFolder, $dynamicRoute ?? null);
262
+ } else {
263
+ $includePath = $baseDir . self::getFilePrecedence();
264
+ $layoutsToInclude = self::collectRootLayouts();
265
+ }
279
266
 
280
- foreach (explode('/', $modifiedPathname) as $segment) {
281
- if (empty($segment)) {
282
- continue;
267
+ return [
268
+ 'path' => $includePath,
269
+ 'layouts' => $layoutsToInclude,
270
+ 'pathname' => $pathname,
271
+ 'uri' => $requestUri
272
+ ];
273
+ }
274
+
275
+ private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
276
+ {
277
+ $layoutsToInclude = [];
278
+ $baseDir = APP_PATH;
279
+
280
+ $rootLayout = $baseDir . '/layout.php';
281
+ if (self::fileExistsCached($rootLayout)) {
282
+ $layoutsToInclude[] = $rootLayout;
283
+ }
284
+
285
+ $groupName = null;
286
+ $groupParentPath = '';
287
+ $pathAfterGroup = '';
288
+
289
+ if ($groupFolder) {
290
+ $normalizedGroupFolder = str_replace('\\', '/', $groupFolder);
291
+
292
+ if (preg_match('#^\.?/src/app/(.+)/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
293
+ $groupParentPath = $matches[1];
294
+ $groupName = $matches[2];
295
+ $pathAfterGroup = dirname($matches[3]);
296
+ if ($pathAfterGroup === '.') {
297
+ $pathAfterGroup = '';
283
298
  }
299
+ } elseif (preg_match('#^\.?/src/app/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
300
+ $groupName = $matches[1];
301
+ $pathAfterGroup = dirname($matches[2]);
302
+ if ($pathAfterGroup === '.') {
303
+ $pathAfterGroup = '';
304
+ }
305
+ }
306
+ }
307
+
308
+ if ($groupName && $groupParentPath) {
309
+ $currentPath = $baseDir;
310
+ foreach (explode('/', $groupParentPath) as $segment) {
311
+ if (empty($segment)) continue;
284
312
 
285
313
  $currentPath .= '/' . $segment;
286
314
  $potentialLayoutPath = $currentPath . '/layout.php';
315
+
287
316
  if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
288
317
  $layoutsToInclude[] = $potentialLayoutPath;
289
318
  }
290
319
  }
291
320
 
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') {
299
- continue;
321
+ $groupLayoutPath = $baseDir . '/' . $groupParentPath . "/($groupName)/layout.php";
322
+ if (self::fileExistsCached($groupLayoutPath)) {
323
+ $layoutsToInclude[] = $groupLayoutPath;
324
+ }
325
+
326
+ if (!empty($pathAfterGroup)) {
327
+ $currentPath = $baseDir . '/' . $groupParentPath . "/($groupName)";
328
+ foreach (explode('/', $pathAfterGroup) as $segment) {
329
+ if (empty($segment)) continue;
330
+
331
+ $currentPath .= '/' . $segment;
332
+ $potentialLayoutPath = $currentPath . '/layout.php';
333
+
334
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
335
+ $layoutsToInclude[] = $potentialLayoutPath;
300
336
  }
337
+ }
338
+ }
339
+ } elseif ($groupName && !$groupParentPath) {
340
+ $groupLayoutPath = $baseDir . "/($groupName)/layout.php";
341
+ if (self::fileExistsCached($groupLayoutPath)) {
342
+ $layoutsToInclude[] = $groupLayoutPath;
343
+ }
344
+
345
+ if (!empty($pathAfterGroup)) {
346
+ $currentPath = $baseDir . "/($groupName)";
347
+ foreach (explode('/', $pathAfterGroup) as $segment) {
348
+ if (empty($segment)) continue;
301
349
 
302
- $currentDynamicPath .= '/' . $segment;
303
- $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
304
- if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
305
- $layoutsToInclude[] = $potentialDynamicRoute;
350
+ $currentPath .= '/' . $segment;
351
+ $potentialLayoutPath = $currentPath . '/layout.php';
352
+
353
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
354
+ $layoutsToInclude[] = $potentialLayoutPath;
306
355
  }
307
356
  }
308
357
  }
358
+ } else {
359
+ $currentPath = $baseDir;
360
+ foreach (explode('/', $pathname) as $segment) {
361
+ if (empty($segment)) continue;
309
362
 
310
- if (empty($layoutsToInclude)) {
311
- $layoutsToInclude[] = $baseDir . '/layout.php';
363
+ $currentPath .= '/' . $segment;
364
+ $potentialLayoutPath = $currentPath . '/layout.php';
365
+
366
+ if ($potentialLayoutPath === $rootLayout) {
367
+ continue;
368
+ }
369
+
370
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
371
+ $layoutsToInclude[] = $potentialLayoutPath;
372
+ }
312
373
  }
374
+ }
375
+
376
+ if (isset($dynamicRoute) && !empty($dynamicRoute)) {
377
+ $currentDynamicPath = $baseDir;
378
+ foreach (explode('/', $dynamicRoute) as $segment) {
379
+ if (empty($segment) || $segment === 'src' || $segment === 'app') {
380
+ continue;
381
+ }
382
+
383
+ $currentDynamicPath .= '/' . $segment;
384
+ $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
385
+ if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
386
+ $layoutsToInclude[] = $potentialDynamicRoute;
387
+ }
388
+ }
389
+ }
390
+
391
+ if (empty($layoutsToInclude)) {
392
+ $layoutsToInclude = self::findFirstGroupLayout();
393
+ }
394
+
395
+ return $layoutsToInclude;
396
+ }
397
+
398
+ private static function collectRootLayouts(): array
399
+ {
400
+ $layoutsToInclude = [];
401
+ $baseDir = APP_PATH;
402
+ $rootLayout = $baseDir . '/layout.php';
403
+
404
+ if (self::fileExistsCached($rootLayout)) {
405
+ $layoutsToInclude[] = $rootLayout;
313
406
  } else {
314
- $includePath = $baseDir . self::getFilePrecedence();
407
+ $layoutsToInclude = self::findFirstGroupLayout();
408
+
409
+ if (empty($layoutsToInclude)) {
410
+ return [];
411
+ }
315
412
  }
316
413
 
317
- return [
318
- 'path' => $includePath,
319
- 'layouts' => $layoutsToInclude,
320
- 'pathname' => $pathname,
321
- 'uri' => $requestUri
322
- ];
414
+ return $layoutsToInclude;
415
+ }
416
+
417
+ private static function findFirstGroupLayout(): array
418
+ {
419
+ $baseDir = APP_PATH;
420
+ $layoutsToInclude = [];
421
+
422
+ if (is_dir($baseDir)) {
423
+ $items = scandir($baseDir);
424
+ foreach ($items as $item) {
425
+ if ($item === '.' || $item === '..') {
426
+ continue;
427
+ }
428
+
429
+ if (preg_match('/^\([^)]+\)$/', $item)) {
430
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
431
+ if (self::fileExistsCached($groupLayoutPath)) {
432
+ $layoutsToInclude[] = $groupLayoutPath;
433
+ break;
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ return $layoutsToInclude;
323
440
  }
324
441
 
325
442
  private static function getFilePrecedence(): ?string
326
443
  {
444
+ $baseDir = APP_PATH;
445
+
327
446
  foreach (PrismaPHPSettings::$routeFiles as $route) {
328
447
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
329
448
  continue;
@@ -335,6 +454,27 @@ final class Bootstrap extends RuntimeException
335
454
  return '/index.php';
336
455
  }
337
456
  }
457
+
458
+ if (is_dir($baseDir)) {
459
+ $items = scandir($baseDir);
460
+ foreach ($items as $item) {
461
+ if ($item === '.' || $item === '..') {
462
+ continue;
463
+ }
464
+
465
+ if (preg_match('/^\([^)]+\)$/', $item)) {
466
+ $groupDir = $baseDir . '/' . $item;
467
+
468
+ if (file_exists($groupDir . '/route.php')) {
469
+ return '/' . $item . '/route.php';
470
+ }
471
+ if (file_exists($groupDir . '/index.php')) {
472
+ return '/' . $item . '/index.php';
473
+ }
474
+ }
475
+ }
476
+ }
477
+
338
478
  return null;
339
479
  }
340
480
 
@@ -424,17 +564,22 @@ final class Bootstrap extends RuntimeException
424
564
  }
425
565
  }
426
566
  } elseif (strpos($normalizedRoute, '[...') !== false) {
427
- if (count($pathnameSegments) <= $expectedSegmentCount) {
567
+ $cleanedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
568
+ $cleanedRoute = preg_replace('/\/+/', '/', $cleanedRoute);
569
+ $staticPart = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedRoute);
570
+ $staticSegments = array_filter(explode('/', $staticPart));
571
+ $minRequiredSegments = count($staticSegments);
572
+
573
+ if (count($pathnameSegments) < $minRequiredSegments) {
428
574
  continue;
429
575
  }
430
576
 
431
- $cleanedNormalizedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
432
- $cleanedNormalizedRoute = preg_replace('/\/+/', '/', $cleanedNormalizedRoute);
433
- $dynamicSegmentRoute = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedNormalizedRoute);
577
+ $cleanedNormalizedRoute = $cleanedRoute;
578
+ $dynamicSegmentRoute = $staticPart;
434
579
 
435
580
  if (strpos("/src/app/$normalizedPathname", $dynamicSegmentRoute) === 0) {
436
581
  $trimmedPathname = str_replace($dynamicSegmentRoute, '', "/src/app/$normalizedPathname");
437
- $pathnameParts = explode('/', trim($trimmedPathname, '/'));
582
+ $pathnameParts = $trimmedPathname === '' ? [] : explode('/', trim($trimmedPathname, '/'));
438
583
 
439
584
  if (preg_match('/\[\.\.\.(.*?)\]/', $normalizedRoute, $matches)) {
440
585
  $dynamicParam = $matches[1];
@@ -451,18 +596,14 @@ final class Bootstrap extends RuntimeException
451
596
 
452
597
  if (strpos($normalizedRoute, 'index.php') !== false) {
453
598
  $segmentMatch = "[...$dynamicParam]";
454
- $index = array_search($segmentMatch, $filteredRouteSegments);
455
599
 
456
- if ($index !== false && isset($pathnameSegments[$index])) {
457
- $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
458
- $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
600
+ $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
601
+ $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
602
+ $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
459
603
 
460
- $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
461
-
462
- if ($expectedPathname === $dynamicRoutePathnameDirname) {
463
- $pathnameMatch = $normalizedRoute;
464
- break;
465
- }
604
+ if ($expectedPathname === $dynamicRoutePathnameDirname) {
605
+ $pathnameMatch = $normalizedRoute;
606
+ break;
466
607
  }
467
608
  }
468
609
  }
@@ -483,6 +624,7 @@ final class Bootstrap extends RuntimeException
483
624
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
484
625
  continue;
485
626
  }
627
+
486
628
  $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
487
629
  $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
488
630
 
@@ -494,22 +636,26 @@ final class Bootstrap extends RuntimeException
494
636
  }
495
637
  }
496
638
 
497
- return $bestMatch;
498
- }
639
+ if (!$bestMatch) {
640
+ foreach (PrismaPHPSettings::$routeFiles as $route) {
641
+ if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
642
+ continue;
643
+ }
499
644
 
500
- private static function getGroupFolder($pathname): string
501
- {
502
- $lastSlashPos = strrpos($pathname, '/');
503
- if ($lastSlashPos === false) {
504
- return "";
505
- }
645
+ $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
506
646
 
507
- $pathWithoutFile = substr($pathname, 0, $lastSlashPos);
508
- if (preg_match('/\(([^)]+)\)[^()]*$/', $pathWithoutFile, $matches)) {
509
- return $pathWithoutFile;
647
+ if (preg_match('/\/\(([^)]+)\)\//', $normalizedRoute, $matches)) {
648
+ $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
649
+
650
+ if ($cleanedRoute === $routeFile || $cleanedRoute === $indexFile) {
651
+ $bestMatch = $normalizedRoute;
652
+ break;
653
+ }
654
+ }
655
+ }
510
656
  }
511
657
 
512
- return "";
658
+ return $bestMatch;
513
659
  }
514
660
 
515
661
  private static function singleDynamicRoute($pathnameSegments, $routeSegments)
@@ -578,7 +724,7 @@ final class Bootstrap extends RuntimeException
578
724
  }
579
725
  }
580
726
 
581
- public static function containsChildLayoutChildren($filePath): bool
727
+ public static function containsChildren($filePath): bool
582
728
  {
583
729
  if (!self::fileExistsCached($filePath)) {
584
730
  return false;
@@ -589,52 +735,51 @@ final class Bootstrap extends RuntimeException
589
735
  return false;
590
736
  }
591
737
 
592
- $pattern = '/\<\?=\s*MainLayout::\$childLayoutChildren\s*;?\s*\?>|echo\s*MainLayout::\$childLayoutChildren\s*;?/';
738
+ $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
593
739
  return (bool) preg_match($pattern, $fileContent);
594
740
  }
595
741
 
596
- private static function containsChildren($filePath): bool
742
+ private static function convertToArrayObject($data)
597
743
  {
598
- if (!self::fileExistsCached($filePath)) {
599
- return false;
744
+ if (!is_array($data)) {
745
+ return $data;
600
746
  }
601
747
 
602
- $fileContent = @file_get_contents($filePath);
603
- if ($fileContent === false) {
604
- return false;
748
+ if (empty($data)) {
749
+ return $data;
605
750
  }
606
751
 
607
- $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
608
- return (bool) preg_match($pattern, $fileContent);
609
- }
752
+ $isAssoc = array_keys($data) !== range(0, count($data) - 1);
610
753
 
611
- private static function convertToArrayObject($data)
612
- {
613
- return is_array($data) ? (object) $data : $data;
754
+ if ($isAssoc) {
755
+ $obj = new stdClass();
756
+ foreach ($data as $key => $value) {
757
+ $obj->$key = self::convertToArrayObject($value);
758
+ }
759
+ return $obj;
760
+ } else {
761
+ return array_map([self::class, 'convertToArrayObject'], $data);
762
+ }
614
763
  }
615
764
 
616
765
  public static function wireCallback(): void
617
766
  {
618
- $data = self::getRequestData();
767
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
619
768
 
620
- if (empty($data['callback'])) {
621
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
769
+ if (empty($callbackName)) {
770
+ self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
622
771
  }
623
772
 
624
- try {
625
- $aesKey = self::getAesKeyFromJwt();
626
- } catch (RuntimeException $e) {
627
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
628
- }
773
+ self::validateCsrfToken();
629
774
 
630
- try {
631
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
632
- } catch (RuntimeException $e) {
633
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
775
+ if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
776
+ self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
634
777
  }
635
778
 
779
+ $data = self::getRequestData();
636
780
  $args = self::convertToArrayObject($data);
637
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
781
+
782
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
638
783
  ? self::dispatchMethod($callbackName, $args)
639
784
  : self::dispatchFunction($callbackName, $args);
640
785
 
@@ -644,63 +789,12 @@ final class Bootstrap extends RuntimeException
644
789
  exit;
645
790
  }
646
791
 
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
792
  private static function jsonExit(array $payload): void
671
793
  {
672
794
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
673
795
  exit;
674
796
  }
675
797
 
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
798
  private static function getRequestData(): array
705
799
  {
706
800
  if (!empty($_FILES)) {
@@ -728,8 +822,34 @@ final class Bootstrap extends RuntimeException
728
822
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
729
823
  }
730
824
 
825
+ private static function isFunctionAllowed(string $fn): bool
826
+ {
827
+ try {
828
+ $ref = new ReflectionFunction($fn);
829
+ $attrs = $ref->getAttributes(Exposed::class);
830
+ return !empty($attrs);
831
+ } catch (Throwable) {
832
+ return false;
833
+ }
834
+ }
835
+
836
+ private static function isMethodAllowed(string $class, string $method): bool
837
+ {
838
+ try {
839
+ $ref = new ReflectionMethod($class, $method);
840
+ $attrs = $ref->getAttributes(Exposed::class);
841
+ return !empty($attrs);
842
+ } catch (Throwable) {
843
+ return false;
844
+ }
845
+ }
846
+
731
847
  private static function dispatchFunction(string $fn, mixed $args)
732
848
  {
849
+ if (!self::isFunctionAllowed($fn)) {
850
+ return ['success' => false, 'error' => 'Function not callable from client'];
851
+ }
852
+
733
853
  if (function_exists($fn) && is_callable($fn)) {
734
854
  try {
735
855
  $res = call_user_func($fn, $args);
@@ -750,6 +870,17 @@ final class Bootstrap extends RuntimeException
750
870
 
751
871
  private static function dispatchMethod(string $call, mixed $args)
752
872
  {
873
+ if (!self::isMethodAllowed(
874
+ strpos($call, '->') !== false
875
+ ? explode('->', $call, 2)[0]
876
+ : explode('::', $call, 2)[0],
877
+ strpos($call, '->') !== false
878
+ ? explode('->', $call, 2)[1]
879
+ : explode('::', $call, 2)[1]
880
+ )) {
881
+ return ['success' => false, 'error' => 'Method not callable from client'];
882
+ }
883
+
753
884
  if (strpos($call, '->') !== false) {
754
885
  list($requested, $method) = explode('->', $call, 2);
755
886
  $isStatic = false;
@@ -989,40 +1120,32 @@ try {
989
1120
  }
990
1121
 
991
1122
  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
- }
1123
+ ob_start();
1124
+ require_once Bootstrap::$contentToInclude;
1125
+ MainLayout::$children = ob_get_clean();
997
1126
 
998
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
999
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1000
- continue;
1001
- }
1127
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1128
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1002
1129
 
1003
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1004
- Bootstrap::$isChildContentIncluded = true;
1005
- }
1130
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1131
+ if (!Bootstrap::containsChildren($layoutPath)) {
1132
+ Bootstrap::$isChildContentIncluded = true;
1133
+ }
1006
1134
 
1007
- ob_start();
1008
- require_once $layoutPath;
1009
- MainLayout::$childLayoutChildren = ob_get_clean();
1135
+ ob_start();
1136
+ require_once $layoutPath;
1137
+ MainLayout::$children = ob_get_clean();
1138
+ }
1010
1139
  }
1011
1140
  } else {
1012
1141
  ob_start();
1013
1142
  require_once APP_PATH . '/not-found.php';
1014
- MainLayout::$childLayoutChildren = ob_get_clean();
1143
+ MainLayout::$children = ob_get_clean();
1015
1144
 
1016
1145
  http_response_code(404);
1017
1146
  CacheHandler::$isCacheable = false;
1018
1147
  }
1019
1148
 
1020
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1021
- ob_start();
1022
- require_once Bootstrap::$contentToInclude;
1023
- MainLayout::$childLayoutChildren = ob_get_clean();
1024
- }
1025
-
1026
1149
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1027
1150
  if (!Bootstrap::$secondRequestC69CD) {
1028
1151
  Bootstrap::createUpdateRequestData();
@@ -1034,7 +1157,7 @@ try {
1034
1157
  if (file_exists($file)) {
1035
1158
  ob_start();
1036
1159
  require_once $file;
1037
- MainLayout::$childLayoutChildren .= ob_get_clean();
1160
+ MainLayout::$children .= ob_get_clean();
1038
1161
  }
1039
1162
  }
1040
1163
  }
@@ -1046,57 +1169,45 @@ try {
1046
1169
  }
1047
1170
 
1048
1171
  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']));
1172
+ $shouldCache = CacheHandler::$isCacheable === true
1173
+ || (CacheHandler::$isCacheable === null && $_ENV['CACHE_ENABLED'] === 'true');
1174
+
1175
+ if ($shouldCache) {
1176
+ CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL'] ?? 600));
1051
1177
  }
1052
1178
  }
1053
1179
 
1054
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1180
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1055
1181
 
1056
1182
  ob_start();
1057
- require_once APP_PATH . '/layout.php';
1183
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1184
+ require_once Bootstrap::$parentLayoutPath;
1185
+ } else {
1186
+ echo MainLayout::$children;
1187
+ }
1188
+
1058
1189
  MainLayout::$html = ob_get_clean();
1059
1190
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1060
1191
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1061
1192
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1062
1193
 
1063
1194
  if (
1064
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1195
+ http_response_code() === 200
1196
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1197
+ && $shouldCache
1198
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1065
1199
  ) {
1066
1200
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1067
1201
  }
1068
1202
 
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
1203
  echo MainLayout::$html;
1088
1204
  } else {
1089
1205
  $layoutPath = Bootstrap::$isContentIncluded
1090
1206
  ? Bootstrap::$parentLayoutPath
1091
1207
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1092
1208
 
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
- }
1209
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1210
+ $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
1211
 
1101
1212
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1102
1213