create-prisma-php-app 4.2.2-beta → 4.2.2

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
139
+ {
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;
154
+ }
155
+ }
156
+
157
+ private static function validateCsrfToken(): void
158
158
  {
159
- $hmacSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
160
- if ($hmacSecret === '') {
161
- throw new RuntimeException("FUNCTION_CALL_SECRET is not set");
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']);
162
165
  }
163
166
 
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
- }
167
+ if (!hash_equals($cookieToken, $headerToken)) {
168
+ self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
173
169
  }
174
170
 
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');
171
+ $parts = explode('.', $cookieToken);
172
+ if (count($parts) !== 2) {
173
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
174
+ }
182
175
 
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
- );
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
+ }
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
+ }
312
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,78 @@ final class Bootstrap extends RuntimeException
728
822
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
729
823
  }
730
824
 
825
+ private static function validateAccess(Exposed $attribute): bool
826
+ {
827
+ if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
828
+ $auth = Auth::getInstance();
829
+
830
+ if (!$auth->isAuthenticated()) {
831
+ return false;
832
+ }
833
+
834
+ if (!empty($attribute->allowedRoles)) {
835
+ $payload = $auth->getPayload();
836
+ $currentRole = null;
837
+
838
+ if (is_scalar($payload)) {
839
+ $currentRole = $payload;
840
+ } else {
841
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
842
+
843
+ if (is_object($payload)) {
844
+ $currentRole = $payload->$roleKey ?? null;
845
+ } elseif (is_array($payload)) {
846
+ $currentRole = $payload[$roleKey] ?? null;
847
+ }
848
+ }
849
+
850
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
851
+ return false;
852
+ }
853
+ }
854
+ }
855
+
856
+ return true;
857
+ }
858
+
859
+ private static function isFunctionAllowed(string $fn): bool
860
+ {
861
+ try {
862
+ $ref = new ReflectionFunction($fn);
863
+ $attrs = $ref->getAttributes(Exposed::class);
864
+
865
+ if (empty($attrs)) {
866
+ return false;
867
+ }
868
+
869
+ return self::validateAccess($attrs[0]->newInstance());
870
+ } catch (Throwable) {
871
+ return false;
872
+ }
873
+ }
874
+
875
+ private static function isMethodAllowed(string $class, string $method): bool
876
+ {
877
+ try {
878
+ $ref = new ReflectionMethod($class, $method);
879
+ $attrs = $ref->getAttributes(Exposed::class);
880
+
881
+ if (empty($attrs)) {
882
+ return false;
883
+ }
884
+
885
+ return self::validateAccess($attrs[0]->newInstance());
886
+ } catch (Throwable) {
887
+ return false;
888
+ }
889
+ }
890
+
731
891
  private static function dispatchFunction(string $fn, mixed $args)
732
892
  {
893
+ if (!self::isFunctionAllowed($fn)) {
894
+ return ['success' => false, 'error' => 'Function not callable from client'];
895
+ }
896
+
733
897
  if (function_exists($fn) && is_callable($fn)) {
734
898
  try {
735
899
  $res = call_user_func($fn, $args);
@@ -750,6 +914,17 @@ final class Bootstrap extends RuntimeException
750
914
 
751
915
  private static function dispatchMethod(string $call, mixed $args)
752
916
  {
917
+ if (!self::isMethodAllowed(
918
+ strpos($call, '->') !== false
919
+ ? explode('->', $call, 2)[0]
920
+ : explode('::', $call, 2)[0],
921
+ strpos($call, '->') !== false
922
+ ? explode('->', $call, 2)[1]
923
+ : explode('::', $call, 2)[1]
924
+ )) {
925
+ return ['success' => false, 'error' => 'Method not callable from client'];
926
+ }
927
+
753
928
  if (strpos($call, '->') !== false) {
754
929
  list($requested, $method) = explode('->', $call, 2);
755
930
  $isStatic = false;
@@ -989,40 +1164,32 @@ try {
989
1164
  }
990
1165
 
991
1166
  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
- }
1167
+ ob_start();
1168
+ require_once Bootstrap::$contentToInclude;
1169
+ MainLayout::$children = ob_get_clean();
997
1170
 
998
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
999
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1000
- continue;
1001
- }
1171
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1172
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1002
1173
 
1003
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1004
- Bootstrap::$isChildContentIncluded = true;
1005
- }
1174
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1175
+ if (!Bootstrap::containsChildren($layoutPath)) {
1176
+ Bootstrap::$isChildContentIncluded = true;
1177
+ }
1006
1178
 
1007
- ob_start();
1008
- require_once $layoutPath;
1009
- MainLayout::$childLayoutChildren = ob_get_clean();
1179
+ ob_start();
1180
+ require_once $layoutPath;
1181
+ MainLayout::$children = ob_get_clean();
1182
+ }
1010
1183
  }
1011
1184
  } else {
1012
1185
  ob_start();
1013
1186
  require_once APP_PATH . '/not-found.php';
1014
- MainLayout::$childLayoutChildren = ob_get_clean();
1187
+ MainLayout::$children = ob_get_clean();
1015
1188
 
1016
1189
  http_response_code(404);
1017
1190
  CacheHandler::$isCacheable = false;
1018
1191
  }
1019
1192
 
1020
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1021
- ob_start();
1022
- require_once Bootstrap::$contentToInclude;
1023
- MainLayout::$childLayoutChildren = ob_get_clean();
1024
- }
1025
-
1026
1193
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1027
1194
  if (!Bootstrap::$secondRequestC69CD) {
1028
1195
  Bootstrap::createUpdateRequestData();
@@ -1034,7 +1201,7 @@ try {
1034
1201
  if (file_exists($file)) {
1035
1202
  ob_start();
1036
1203
  require_once $file;
1037
- MainLayout::$childLayoutChildren .= ob_get_clean();
1204
+ MainLayout::$children .= ob_get_clean();
1038
1205
  }
1039
1206
  }
1040
1207
  }
@@ -1046,57 +1213,45 @@ try {
1046
1213
  }
1047
1214
 
1048
1215
  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']));
1216
+ $shouldCache = CacheHandler::$isCacheable === true
1217
+ || (CacheHandler::$isCacheable === null && $_ENV['CACHE_ENABLED'] === 'true');
1218
+
1219
+ if ($shouldCache) {
1220
+ CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL'] ?? 600));
1051
1221
  }
1052
1222
  }
1053
1223
 
1054
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1224
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1055
1225
 
1056
1226
  ob_start();
1057
- require_once APP_PATH . '/layout.php';
1227
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1228
+ require_once Bootstrap::$parentLayoutPath;
1229
+ } else {
1230
+ echo MainLayout::$children;
1231
+ }
1232
+
1058
1233
  MainLayout::$html = ob_get_clean();
1059
1234
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1060
1235
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1061
1236
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1062
1237
 
1063
1238
  if (
1064
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1239
+ http_response_code() === 200
1240
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1241
+ && $shouldCache
1242
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1065
1243
  ) {
1066
1244
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1067
1245
  }
1068
1246
 
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
1247
  echo MainLayout::$html;
1088
1248
  } else {
1089
1249
  $layoutPath = Bootstrap::$isContentIncluded
1090
1250
  ? Bootstrap::$parentLayoutPath
1091
1251
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1092
1252
 
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
- }
1253
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1254
+ $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
1255
 
1101
1256
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1102
1257