create-prisma-php-app 4.3.0-beta → 4.3.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,9 @@ 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;
28
+ use PP\Streaming\SSE;
29
+ use PP\Security\RateLimiter;
30
30
 
31
31
  final class Bootstrap extends RuntimeException
32
32
  {
@@ -40,8 +40,6 @@ final class Bootstrap extends RuntimeException
40
40
  public static bool $isContentVariableIncluded = false;
41
41
  public static bool $secondRequestC69CD = false;
42
42
  public static array $requestFilesData = [];
43
- public static array $partialSelectors = [];
44
- public static bool $isPartialRequest = false;
45
43
 
46
44
  private string $context;
47
45
 
@@ -78,7 +76,7 @@ final class Bootstrap extends RuntimeException
78
76
  'samesite' => 'Lax',
79
77
  ]);
80
78
 
81
- self::functionCallNameEncrypt();
79
+ self::setCsrfCookie();
82
80
 
83
81
  self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
84
82
 
@@ -106,24 +104,20 @@ final class Bootstrap extends RuntimeException
106
104
  self::authenticateUserToken();
107
105
 
108
106
  self::$requestFilePath = APP_PATH . Request::$pathname;
109
- self::$parentLayoutPath = APP_PATH . '/layout.php';
110
107
 
111
- self::$isParentLayout = !empty(self::$layoutsToInclude)
112
- && strpos(self::$layoutsToInclude[0], 'src/app/layout.php') !== false;
108
+ if (!empty(self::$layoutsToInclude)) {
109
+ self::$parentLayoutPath = self::$layoutsToInclude[0];
110
+ self::$isParentLayout = true;
111
+ } else {
112
+ self::$parentLayoutPath = APP_PATH . '/layout.php';
113
+ self::$isParentLayout = false;
114
+ }
113
115
 
114
116
  self::$isContentVariableIncluded = self::containsChildren(self::$parentLayoutPath);
115
117
  if (!self::$isContentVariableIncluded) {
116
118
  self::$isContentIncluded = true;
117
119
  }
118
120
 
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
121
  self::$requestFilesData = PrismaPHPSettings::$includeFiles;
128
122
 
129
123
  ErrorHandler::checkFatalError();
@@ -131,66 +125,62 @@ final class Bootstrap extends RuntimeException
131
125
 
132
126
  private static function isLocalStoreCallback(): void
133
127
  {
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()]);
128
+ if (empty($_SERVER['HTTP_X_PP_FUNCTION'])) {
129
+ return;
144
130
  }
145
131
 
146
- try {
147
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
148
- } catch (RuntimeException $e) {
149
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
150
- }
132
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'];
151
133
 
152
134
  if ($callbackName === PrismaPHPSettings::$localStoreKey) {
135
+ self::validateCsrfToken();
153
136
  self::jsonExit(['success' => true, 'response' => 'localStorage updated']);
154
137
  }
155
138
  }
156
139
 
157
- private static function functionCallNameEncrypt(): void
140
+ private static function setCsrfCookie(): void
158
141
  {
159
- $hmacSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
160
- if ($hmacSecret === '') {
161
- throw new RuntimeException("FUNCTION_CALL_SECRET is not set");
142
+ if (!isset($_COOKIE['prisma_php_csrf'])) {
143
+ $secret = $_ENV['FUNCTION_CALL_SECRET'] ?? 'pp_default_insecure_secret';
144
+ $nonce = bin2hex(random_bytes(16));
145
+ $signature = hash_hmac('sha256', $nonce, $secret);
146
+ $token = $nonce . '.' . $signature;
147
+
148
+ setcookie('prisma_php_csrf', $token, [
149
+ 'expires' => time() + 3600,
150
+ 'path' => '/',
151
+ 'secure' => true,
152
+ 'httponly' => false,
153
+ 'samesite' => 'Lax',
154
+ ]);
155
+ $_COOKIE['prisma_php_csrf'] = $token;
162
156
  }
157
+ }
163
158
 
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
- }
159
+ private static function validateCsrfToken(): void
160
+ {
161
+ $headerToken = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
162
+ $cookieToken = $_COOKIE['prisma_php_csrf'] ?? '';
163
+ $secret = $_ENV['FUNCTION_CALL_SECRET'] ?? '';
164
+
165
+ if (empty($headerToken) || empty($cookieToken)) {
166
+ self::jsonExit(['success' => false, 'error' => 'CSRF token missing']);
173
167
  }
174
168
 
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');
169
+ if (!hash_equals($cookieToken, $headerToken)) {
170
+ self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
171
+ }
182
172
 
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
- );
173
+ $parts = explode('.', $cookieToken);
174
+ if (count($parts) !== 2) {
175
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token format']);
176
+ }
177
+
178
+ [$nonce, $signature] = $parts;
179
+ $expectedSignature = hash_hmac('sha256', $nonce, $secret);
180
+
181
+ if (!hash_equals($expectedSignature, $signature)) {
182
+ self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
183
+ }
194
184
  }
195
185
 
196
186
  private static function fileExistsCached(string $path): bool
@@ -270,72 +260,245 @@ final class Bootstrap extends RuntimeException
270
260
  }
271
261
  }
272
262
 
263
+ $layoutsToInclude = self::collectLayouts($pathname, $groupFolder, $dynamicRoute ?? null);
264
+ } else {
265
+ $contentData = self::getFilePrecedence();
266
+ $includePath = $baseDir . $contentData['file'];
267
+ $layoutsToInclude = self::collectRootLayouts($contentData['file']);
268
+ }
269
+
270
+ return [
271
+ 'path' => $includePath,
272
+ 'layouts' => $layoutsToInclude,
273
+ 'pathname' => $pathname,
274
+ 'uri' => $requestUri
275
+ ];
276
+ }
277
+
278
+ private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
279
+ {
280
+ $layoutsToInclude = [];
281
+ $baseDir = APP_PATH;
282
+
283
+ $rootLayout = $baseDir . '/layout.php';
284
+ if (self::fileExistsCached($rootLayout)) {
285
+ $layoutsToInclude[] = $rootLayout;
286
+ }
287
+
288
+ $groupName = null;
289
+ $groupParentPath = '';
290
+ $pathAfterGroup = '';
291
+
292
+ if ($groupFolder) {
293
+ $normalizedGroupFolder = str_replace('\\', '/', $groupFolder);
294
+
295
+ if (preg_match('#^\.?/src/app/(.+)/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
296
+ $groupParentPath = $matches[1];
297
+ $groupName = $matches[2];
298
+ $pathAfterGroup = dirname($matches[3]);
299
+ if ($pathAfterGroup === '.') {
300
+ $pathAfterGroup = '';
301
+ }
302
+ } elseif (preg_match('#^\.?/src/app/\(([^)]+)\)/(.+)$#', $normalizedGroupFolder, $matches)) {
303
+ $groupName = $matches[1];
304
+ $pathAfterGroup = dirname($matches[2]);
305
+ if ($pathAfterGroup === '.') {
306
+ $pathAfterGroup = '';
307
+ }
308
+ }
309
+ }
310
+
311
+ if ($groupName && $groupParentPath) {
273
312
  $currentPath = $baseDir;
274
- $getGroupFolder = self::getGroupFolder($groupFolder);
275
- $modifiedPathname = $pathname;
276
- if (!empty($getGroupFolder)) {
277
- $modifiedPathname = trim($getGroupFolder, "/src/app/");
313
+ foreach (explode('/', $groupParentPath) as $segment) {
314
+ if (empty($segment)) continue;
315
+
316
+ $currentPath .= '/' . $segment;
317
+ $potentialLayoutPath = $currentPath . '/layout.php';
318
+
319
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
320
+ $layoutsToInclude[] = $potentialLayoutPath;
321
+ }
278
322
  }
279
323
 
280
- foreach (explode('/', $modifiedPathname) as $segment) {
281
- if (empty($segment)) {
282
- continue;
324
+ $groupLayoutPath = $baseDir . '/' . $groupParentPath . "/($groupName)/layout.php";
325
+ if (self::fileExistsCached($groupLayoutPath)) {
326
+ $layoutsToInclude[] = $groupLayoutPath;
327
+ }
328
+
329
+ if (!empty($pathAfterGroup)) {
330
+ $currentPath = $baseDir . '/' . $groupParentPath . "/($groupName)";
331
+ foreach (explode('/', $pathAfterGroup) as $segment) {
332
+ if (empty($segment)) continue;
333
+
334
+ $currentPath .= '/' . $segment;
335
+ $potentialLayoutPath = $currentPath . '/layout.php';
336
+
337
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
338
+ $layoutsToInclude[] = $potentialLayoutPath;
339
+ }
340
+ }
341
+ }
342
+ } elseif ($groupName && !$groupParentPath) {
343
+ $groupLayoutPath = $baseDir . "/($groupName)/layout.php";
344
+ if (self::fileExistsCached($groupLayoutPath)) {
345
+ $layoutsToInclude[] = $groupLayoutPath;
346
+ }
347
+
348
+ if (!empty($pathAfterGroup)) {
349
+ $currentPath = $baseDir . "/($groupName)";
350
+ foreach (explode('/', $pathAfterGroup) as $segment) {
351
+ if (empty($segment)) continue;
352
+
353
+ $currentPath .= '/' . $segment;
354
+ $potentialLayoutPath = $currentPath . '/layout.php';
355
+
356
+ if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
357
+ $layoutsToInclude[] = $potentialLayoutPath;
358
+ }
283
359
  }
360
+ }
361
+ } else {
362
+ $currentPath = $baseDir;
363
+ foreach (explode('/', $pathname) as $segment) {
364
+ if (empty($segment)) continue;
284
365
 
285
366
  $currentPath .= '/' . $segment;
286
367
  $potentialLayoutPath = $currentPath . '/layout.php';
368
+
369
+ if ($potentialLayoutPath === $rootLayout) {
370
+ continue;
371
+ }
372
+
287
373
  if (self::fileExistsCached($potentialLayoutPath) && !in_array($potentialLayoutPath, $layoutsToInclude, true)) {
288
374
  $layoutsToInclude[] = $potentialLayoutPath;
289
375
  }
290
376
  }
377
+ }
291
378
 
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') {
379
+ if (isset($dynamicRoute) && !empty($dynamicRoute)) {
380
+ $currentDynamicPath = $baseDir;
381
+ foreach (explode('/', $dynamicRoute) as $segment) {
382
+ if (empty($segment) || $segment === 'src' || $segment === 'app') {
383
+ continue;
384
+ }
385
+
386
+ $currentDynamicPath .= '/' . $segment;
387
+ $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
388
+ if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
389
+ $layoutsToInclude[] = $potentialDynamicRoute;
390
+ }
391
+ }
392
+ }
393
+
394
+ if (empty($layoutsToInclude)) {
395
+ $layoutsToInclude = self::findFirstGroupLayout();
396
+ }
397
+
398
+ return $layoutsToInclude;
399
+ }
400
+
401
+ private static function collectRootLayouts(?string $matchedContentFile = null): array
402
+ {
403
+ $layoutsToInclude = [];
404
+ $baseDir = APP_PATH;
405
+ $rootLayout = $baseDir . '/layout.php';
406
+
407
+ if (self::fileExistsCached($rootLayout)) {
408
+ $layoutsToInclude[] = $rootLayout;
409
+ } else {
410
+ $layoutsToInclude = self::findFirstGroupLayout($matchedContentFile);
411
+
412
+ if (empty($layoutsToInclude)) {
413
+ return [];
414
+ }
415
+ }
416
+
417
+ return $layoutsToInclude;
418
+ }
419
+
420
+ private static function findFirstGroupLayout(?string $matchedContentFile = null): array
421
+ {
422
+ $baseDir = APP_PATH;
423
+ $layoutsToInclude = [];
424
+
425
+ if (is_dir($baseDir)) {
426
+ $items = scandir($baseDir);
427
+
428
+ if ($matchedContentFile) {
429
+ foreach ($items as $item) {
430
+ if ($item === '.' || $item === '..') {
299
431
  continue;
300
432
  }
301
433
 
302
- $currentDynamicPath .= '/' . $segment;
303
- $potentialDynamicRoute = $currentDynamicPath . '/layout.php';
304
- if (self::fileExistsCached($potentialDynamicRoute) && !in_array($potentialDynamicRoute, $layoutsToInclude, true)) {
305
- $layoutsToInclude[] = $potentialDynamicRoute;
434
+ if (preg_match('/^\([^)]+\)$/', $item)) {
435
+ if (strpos($matchedContentFile, "/$item/") === 0) {
436
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
437
+ if (self::fileExistsCached($groupLayoutPath)) {
438
+ $layoutsToInclude[] = $groupLayoutPath;
439
+ return $layoutsToInclude;
440
+ }
441
+ }
306
442
  }
307
443
  }
308
444
  }
309
445
 
310
- if (empty($layoutsToInclude)) {
311
- $layoutsToInclude[] = $baseDir . '/layout.php';
446
+ foreach ($items as $item) {
447
+ if ($item === '.' || $item === '..') {
448
+ continue;
449
+ }
450
+
451
+ if (preg_match('/^\([^)]+\)$/', $item)) {
452
+ $groupLayoutPath = $baseDir . '/' . $item . '/layout.php';
453
+ if (self::fileExistsCached($groupLayoutPath)) {
454
+ $layoutsToInclude[] = $groupLayoutPath;
455
+ break;
456
+ }
457
+ }
312
458
  }
313
- } else {
314
- $includePath = $baseDir . self::getFilePrecedence();
315
459
  }
316
460
 
317
- return [
318
- 'path' => $includePath,
319
- 'layouts' => $layoutsToInclude,
320
- 'pathname' => $pathname,
321
- 'uri' => $requestUri
322
- ];
461
+ return $layoutsToInclude;
323
462
  }
324
463
 
325
- private static function getFilePrecedence(): ?string
464
+ private static function getFilePrecedence(): array
326
465
  {
466
+ $baseDir = APP_PATH;
467
+ $result = ['file' => null];
468
+
327
469
  foreach (PrismaPHPSettings::$routeFiles as $route) {
328
470
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
329
471
  continue;
330
472
  }
331
473
  if (preg_match('/^\.\/src\/app\/route\.php$/', $route)) {
332
- return '/route.php';
474
+ return ['file' => '/route.php'];
333
475
  }
334
476
  if (preg_match('/^\.\/src\/app\/index\.php$/', $route)) {
335
- return '/index.php';
477
+ return ['file' => '/index.php'];
478
+ }
479
+ }
480
+
481
+ if (is_dir($baseDir)) {
482
+ $items = scandir($baseDir);
483
+ foreach ($items as $item) {
484
+ if ($item === '.' || $item === '..') {
485
+ continue;
486
+ }
487
+
488
+ if (preg_match('/^\([^)]+\)$/', $item)) {
489
+ $groupDir = $baseDir . '/' . $item;
490
+
491
+ if (file_exists($groupDir . '/route.php')) {
492
+ return ['file' => '/' . $item . '/route.php'];
493
+ }
494
+ if (file_exists($groupDir . '/index.php')) {
495
+ return ['file' => '/' . $item . '/index.php'];
496
+ }
497
+ }
336
498
  }
337
499
  }
338
- return null;
500
+
501
+ return $result;
339
502
  }
340
503
 
341
504
  private static function uriExtractor(string $scriptUrl): string
@@ -424,17 +587,22 @@ final class Bootstrap extends RuntimeException
424
587
  }
425
588
  }
426
589
  } elseif (strpos($normalizedRoute, '[...') !== false) {
427
- if (count($pathnameSegments) <= $expectedSegmentCount) {
590
+ $cleanedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
591
+ $cleanedRoute = preg_replace('/\/+/', '/', $cleanedRoute);
592
+ $staticPart = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedRoute);
593
+ $staticSegments = array_filter(explode('/', $staticPart));
594
+ $minRequiredSegments = count($staticSegments);
595
+
596
+ if (count($pathnameSegments) < $minRequiredSegments) {
428
597
  continue;
429
598
  }
430
599
 
431
- $cleanedNormalizedRoute = preg_replace('/\(.+\)/', '', $normalizedRoute);
432
- $cleanedNormalizedRoute = preg_replace('/\/+/', '/', $cleanedNormalizedRoute);
433
- $dynamicSegmentRoute = preg_replace('/\[\.\.\..*?\].*/', '', $cleanedNormalizedRoute);
600
+ $cleanedNormalizedRoute = $cleanedRoute;
601
+ $dynamicSegmentRoute = $staticPart;
434
602
 
435
603
  if (strpos("/src/app/$normalizedPathname", $dynamicSegmentRoute) === 0) {
436
604
  $trimmedPathname = str_replace($dynamicSegmentRoute, '', "/src/app/$normalizedPathname");
437
- $pathnameParts = explode('/', trim($trimmedPathname, '/'));
605
+ $pathnameParts = $trimmedPathname === '' ? [] : explode('/', trim($trimmedPathname, '/'));
438
606
 
439
607
  if (preg_match('/\[\.\.\.(.*?)\]/', $normalizedRoute, $matches)) {
440
608
  $dynamicParam = $matches[1];
@@ -451,18 +619,14 @@ final class Bootstrap extends RuntimeException
451
619
 
452
620
  if (strpos($normalizedRoute, 'index.php') !== false) {
453
621
  $segmentMatch = "[...$dynamicParam]";
454
- $index = array_search($segmentMatch, $filteredRouteSegments);
455
-
456
- if ($index !== false && isset($pathnameSegments[$index])) {
457
- $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
458
- $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
459
622
 
460
- $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
623
+ $dynamicRoutePathname = str_replace($segmentMatch, implode('/', $pathnameParts), $cleanedNormalizedRoute);
624
+ $dynamicRoutePathnameDirname = rtrim(dirname($dynamicRoutePathname), '/');
625
+ $expectedPathname = rtrim("/src/app/$normalizedPathname", '/');
461
626
 
462
- if ($expectedPathname === $dynamicRoutePathnameDirname) {
463
- $pathnameMatch = $normalizedRoute;
464
- break;
465
- }
627
+ if ($expectedPathname === $dynamicRoutePathnameDirname) {
628
+ $pathnameMatch = $normalizedRoute;
629
+ break;
466
630
  }
467
631
  }
468
632
  }
@@ -483,6 +647,7 @@ final class Bootstrap extends RuntimeException
483
647
  if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
484
648
  continue;
485
649
  }
650
+
486
651
  $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
487
652
  $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
488
653
 
@@ -494,22 +659,26 @@ final class Bootstrap extends RuntimeException
494
659
  }
495
660
  }
496
661
 
497
- return $bestMatch;
498
- }
662
+ if (!$bestMatch) {
663
+ foreach (PrismaPHPSettings::$routeFiles as $route) {
664
+ if (pathinfo($route, PATHINFO_EXTENSION) !== 'php') {
665
+ continue;
666
+ }
499
667
 
500
- private static function getGroupFolder($pathname): string
501
- {
502
- $lastSlashPos = strrpos($pathname, '/');
503
- if ($lastSlashPos === false) {
504
- return "";
505
- }
668
+ $normalizedRoute = trim(str_replace('\\', '/', $route), '.');
506
669
 
507
- $pathWithoutFile = substr($pathname, 0, $lastSlashPos);
508
- if (preg_match('/\(([^)]+)\)[^()]*$/', $pathWithoutFile, $matches)) {
509
- return $pathWithoutFile;
670
+ if (preg_match('/\/\(([^)]+)\)\//', $normalizedRoute, $matches)) {
671
+ $cleanedRoute = preg_replace('/\/\([^)]+\)/', '', $normalizedRoute);
672
+
673
+ if ($cleanedRoute === $routeFile || $cleanedRoute === $indexFile) {
674
+ $bestMatch = $normalizedRoute;
675
+ break;
676
+ }
677
+ }
678
+ }
510
679
  }
511
680
 
512
- return "";
681
+ return $bestMatch;
513
682
  }
514
683
 
515
684
  private static function singleDynamicRoute($pathnameSegments, $routeSegments)
@@ -578,7 +747,7 @@ final class Bootstrap extends RuntimeException
578
747
  }
579
748
  }
580
749
 
581
- public static function containsChildLayoutChildren($filePath): bool
750
+ public static function containsChildren($filePath): bool
582
751
  {
583
752
  if (!self::fileExistsCached($filePath)) {
584
753
  return false;
@@ -589,118 +758,76 @@ final class Bootstrap extends RuntimeException
589
758
  return false;
590
759
  }
591
760
 
592
- $pattern = '/\<\?=\s*MainLayout::\$childLayoutChildren\s*;?\s*\?>|echo\s*MainLayout::\$childLayoutChildren\s*;?/';
761
+ $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
593
762
  return (bool) preg_match($pattern, $fileContent);
594
763
  }
595
764
 
596
- private static function containsChildren($filePath): bool
765
+ private static function convertToArrayObject($data)
597
766
  {
598
- if (!self::fileExistsCached($filePath)) {
599
- return false;
767
+ if (!is_array($data)) {
768
+ return $data;
600
769
  }
601
770
 
602
- $fileContent = @file_get_contents($filePath);
603
- if ($fileContent === false) {
604
- return false;
771
+ if (empty($data)) {
772
+ return $data;
605
773
  }
606
774
 
607
- $pattern = '/\<\?=\s*MainLayout::\$children\s*;?\s*\?>|echo\s*MainLayout::\$children\s*;?/';
608
- return (bool) preg_match($pattern, $fileContent);
609
- }
775
+ $isAssoc = array_keys($data) !== range(0, count($data) - 1);
610
776
 
611
- private static function convertToArrayObject($data)
612
- {
613
- return is_array($data) ? (object) $data : $data;
777
+ if ($isAssoc) {
778
+ $obj = new stdClass();
779
+ foreach ($data as $key => $value) {
780
+ $obj->$key = self::convertToArrayObject($value);
781
+ }
782
+ return $obj;
783
+ } else {
784
+ return array_map([self::class, 'convertToArrayObject'], $data);
785
+ }
614
786
  }
615
787
 
616
788
  public static function wireCallback(): void
617
789
  {
618
- $data = self::getRequestData();
790
+ $callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
619
791
 
620
- if (empty($data['callback'])) {
621
- self::jsonExit(['success' => false, 'error' => 'Callback not provided', 'response' => null]);
792
+ if (empty($callbackName)) {
793
+ self::jsonExit(['success' => false, 'error' => 'Callback header not provided', 'response' => null]);
622
794
  }
623
795
 
624
- try {
625
- $aesKey = self::getAesKeyFromJwt();
626
- } catch (RuntimeException $e) {
627
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
628
- }
796
+ self::validateCsrfToken();
629
797
 
630
- try {
631
- $callbackName = self::decryptCallback($data['callback'], $aesKey);
632
- } catch (RuntimeException $e) {
633
- self::jsonExit(['success' => false, 'error' => $e->getMessage()]);
798
+ if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
799
+ self::jsonExit(['success' => false, 'error' => 'Invalid callback format']);
634
800
  }
635
801
 
802
+ $data = self::getRequestData();
636
803
  $args = self::convertToArrayObject($data);
637
- $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
804
+
805
+ $out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
638
806
  ? self::dispatchMethod($callbackName, $args)
639
807
  : self::dispatchFunction($callbackName, $args);
640
808
 
641
- if ($out !== null) {
642
- self::jsonExit($out);
643
- }
644
- exit;
645
- }
646
-
647
- private static function getAesKeyFromJwt(): string
648
- {
649
- $token = $_COOKIE['pp_function_call_jwt'] ?? null;
650
- $jwtSecret = $_ENV['FUNCTION_CALL_SECRET'] ?? null;
651
-
652
- if (!$token || !$jwtSecret) {
653
- throw new RuntimeException('Missing session key or secret');
809
+ if ($out instanceof SSE) {
810
+ $out->send();
811
+ exit;
654
812
  }
655
813
 
656
- try {
657
- $decoded = JWT::decode($token, new Key($jwtSecret, 'HS256'));
658
- } catch (Throwable) {
659
- throw new RuntimeException('Invalid session key');
814
+ if ($out instanceof Generator) {
815
+ (new SSE($out))->send();
816
+ exit;
660
817
  }
661
818
 
662
- $aesKey = base64_decode($decoded->k, true);
663
- if ($aesKey === false || strlen($aesKey) !== 32) {
664
- throw new RuntimeException('Bad key length');
819
+ if ($out !== null) {
820
+ self::jsonExit($out);
665
821
  }
666
-
667
- return $aesKey;
822
+ exit;
668
823
  }
669
824
 
670
- private static function jsonExit(array $payload): void
825
+ private static function jsonExit(mixed $payload): void
671
826
  {
672
827
  echo json_encode($payload, JSON_UNESCAPED_UNICODE);
673
828
  exit;
674
829
  }
675
830
 
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
831
  private static function getRequestData(): array
705
832
  {
706
833
  if (!empty($_FILES)) {
@@ -728,16 +855,132 @@ final class Bootstrap extends RuntimeException
728
855
  return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
729
856
  }
730
857
 
858
+ private static function validateAccess(Exposed $attribute): bool
859
+ {
860
+ if ($attribute->requiresAuth || !empty($attribute->allowedRoles)) {
861
+ $auth = Auth::getInstance();
862
+
863
+ if (!$auth->isAuthenticated()) {
864
+ return false;
865
+ }
866
+
867
+ if (!empty($attribute->allowedRoles)) {
868
+ $payload = $auth->getPayload();
869
+ $currentRole = null;
870
+
871
+ if (is_scalar($payload)) {
872
+ $currentRole = $payload;
873
+ } else {
874
+ $roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
875
+
876
+ if (is_object($payload)) {
877
+ $currentRole = $payload->$roleKey ?? null;
878
+ } elseif (is_array($payload)) {
879
+ $currentRole = $payload[$roleKey] ?? null;
880
+ }
881
+ }
882
+
883
+ if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
884
+ return false;
885
+ }
886
+ }
887
+ }
888
+
889
+ return true;
890
+ }
891
+
892
+ private static function isFunctionAllowed(string $fn): bool
893
+ {
894
+ try {
895
+ $ref = new ReflectionFunction($fn);
896
+ $attrs = $ref->getAttributes(Exposed::class);
897
+
898
+ if (empty($attrs)) {
899
+ return false;
900
+ }
901
+
902
+ return self::validateAccess($attrs[0]->newInstance());
903
+ } catch (Throwable) {
904
+ return false;
905
+ }
906
+ }
907
+
908
+ private static function isMethodAllowed(string $class, string $method): bool
909
+ {
910
+ try {
911
+ $ref = new ReflectionMethod($class, $method);
912
+ $attrs = $ref->getAttributes(Exposed::class);
913
+
914
+ if (empty($attrs)) {
915
+ return false;
916
+ }
917
+
918
+ return self::validateAccess($attrs[0]->newInstance());
919
+ } catch (Throwable) {
920
+ return false;
921
+ }
922
+ }
923
+
924
+ private static function getExposedAttribute(string $classOrFn, ?string $method = null): ?Exposed
925
+ {
926
+ try {
927
+ if ($method) {
928
+ $ref = new ReflectionMethod($classOrFn, $method);
929
+ } else {
930
+ $ref = new ReflectionFunction($classOrFn);
931
+ }
932
+
933
+ $attrs = $ref->getAttributes(Exposed::class);
934
+ return !empty($attrs) ? $attrs[0]->newInstance() : null;
935
+ } catch (Throwable) {
936
+ return null;
937
+ }
938
+ }
939
+
940
+ private static function enforceRateLimit(Exposed $attribute, string $identifier): void
941
+ {
942
+ $limits = $attribute->limits;
943
+
944
+ if (empty($limits)) {
945
+ if ($attribute->requiresAuth) {
946
+ $limits = $_ENV['RATE_LIMIT_AUTH'] ?? '10/minute';
947
+ } else {
948
+ $limits = $_ENV['RATE_LIMIT_RPC'] ?? '60/minute';
949
+ }
950
+ }
951
+
952
+ if ($limits) {
953
+ RateLimiter::verify($identifier, $limits);
954
+ }
955
+ }
956
+
731
957
  private static function dispatchFunction(string $fn, mixed $args)
732
958
  {
959
+ $attribute = self::getExposedAttribute($fn);
960
+ if (!$attribute) {
961
+ return ['success' => false, 'error' => 'Function not callable from client'];
962
+ }
963
+
964
+ if (!self::validateAccess($attribute)) {
965
+ return ['success' => false, 'error' => 'Permission denied'];
966
+ }
967
+
733
968
  if (function_exists($fn) && is_callable($fn)) {
734
969
  try {
970
+ self::enforceRateLimit($attribute, "fn:$fn");
971
+
735
972
  $res = call_user_func($fn, $args);
736
- if ($res !== null) {
737
- return ['success' => true, 'error' => null, 'response' => $res];
973
+
974
+ if ($res instanceof Generator || $res instanceof SSE) {
975
+ return $res;
738
976
  }
977
+
739
978
  return $res;
740
979
  } catch (Throwable $e) {
980
+ if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
981
+ return ['success' => false, 'error' => $e->getMessage()];
982
+ }
983
+
741
984
  if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
742
985
  return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
743
986
  } else {
@@ -750,6 +993,17 @@ final class Bootstrap extends RuntimeException
750
993
 
751
994
  private static function dispatchMethod(string $call, mixed $args)
752
995
  {
996
+ if (!self::isMethodAllowed(
997
+ strpos($call, '->') !== false
998
+ ? explode('->', $call, 2)[0]
999
+ : explode('::', $call, 2)[0],
1000
+ strpos($call, '->') !== false
1001
+ ? explode('->', $call, 2)[1]
1002
+ : explode('::', $call, 2)[1]
1003
+ )) {
1004
+ return ['success' => false, 'error' => 'Method not callable from client'];
1005
+ }
1006
+
753
1007
  if (strpos($call, '->') !== false) {
754
1008
  list($requested, $method) = explode('->', $call, 2);
755
1009
  $isStatic = false;
@@ -766,47 +1020,48 @@ final class Bootstrap extends RuntimeException
766
1020
  }
767
1021
  }
768
1022
 
769
- if (!$isStatic) {
770
- if (!class_exists($class)) {
771
- return ['success' => false, 'error' => "Class '$requested' not found"];
772
- }
773
- $instance = new $class();
774
- if (!is_callable([$instance, $method])) {
775
- return ['success' => false, 'error' => "Method '$method' not callable on $class"];
776
- }
777
- try {
1023
+ if (!class_exists($class)) {
1024
+ return ['success' => false, 'error' => "Class '$requested' not found"];
1025
+ }
1026
+ $attribute = self::getExposedAttribute($class, $method);
1027
+
1028
+ if (!$attribute) {
1029
+ return ['success' => false, 'error' => 'Method not callable from client'];
1030
+ }
1031
+
1032
+ if (!self::validateAccess($attribute)) {
1033
+ return ['success' => false, 'error' => 'Permission denied'];
1034
+ }
1035
+
1036
+ try {
1037
+ self::enforceRateLimit($attribute, "method:$class::$method");
1038
+
1039
+ $res = null;
1040
+ if (!$isStatic) {
1041
+ $instance = new $class();
1042
+ if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
778
1043
  $res = call_user_func([$instance, $method], $args);
779
- if ($res !== null) {
780
- return ['success' => true, 'error' => null, 'response' => $res];
781
- }
1044
+ } else {
1045
+ if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
1046
+ $res = call_user_func([$class, $method], $args);
1047
+ }
1048
+
1049
+ if ($res instanceof Generator || $res instanceof SSE) {
782
1050
  return $res;
783
- } catch (Throwable $e) {
784
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
785
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
786
- } else {
787
- return ['success' => false, 'error' => "Instance call error: {$e->getMessage()}"];
788
- }
789
1051
  }
790
- } else {
791
- if (!class_exists($class) || !is_callable([$class, $method])) {
792
- return ['success' => false, 'error' => "Static method '$requested::$method' invalid"];
1052
+
1053
+ return $res;
1054
+ } catch (Throwable $e) {
1055
+ if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
1056
+ return ['success' => false, 'error' => $e->getMessage()];
793
1057
  }
794
- try {
795
- $res = call_user_func([$class, $method], $args);
796
- if ($res !== null) {
797
- return ['success' => true, 'error' => null, 'response' => $res];
798
- }
799
- return $res;
800
- } catch (Throwable $e) {
801
- if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
802
- return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
803
- } else {
804
- return ['success' => false, 'error' => "Static call error: {$e->getMessage()}"];
805
- }
1058
+
1059
+ if (isset($_ENV['SHOW_ERRORS']) && $_ENV['SHOW_ERRORS'] === 'false') {
1060
+ return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
1061
+ } else {
1062
+ return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
806
1063
  }
807
1064
  }
808
-
809
- return ['success' => false, 'error' => 'Invalid callback'];
810
1065
  }
811
1066
 
812
1067
  private static function resolveClassImport(string $simpleClassKey): ?array
@@ -949,6 +1204,22 @@ final class Bootstrap extends RuntimeException
949
1204
 
950
1205
  return Request::$isAjax || Request::$isXFileRequest || Request::$fileToInclude === 'route.php';
951
1206
  }
1207
+
1208
+ public static function applyRootLayoutId(string $html): string
1209
+ {
1210
+ $rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
1211
+ $rootLayoutId = !empty($rootLayoutPath) ? md5($rootLayoutPath) : 'default-root';
1212
+
1213
+ header('X-PP-Root-Layout: ' . $rootLayoutId);
1214
+
1215
+ $rootLayoutMeta = '<meta name="pp-root-layout" content="' . $rootLayoutId . '">';
1216
+
1217
+ if (strpos($html, '<head>') !== false) {
1218
+ return preg_replace('/<head>/', "<head>\n $rootLayoutMeta", $html, 1);
1219
+ }
1220
+
1221
+ return $rootLayoutMeta . $html;
1222
+ }
952
1223
  }
953
1224
 
954
1225
  Bootstrap::run();
@@ -989,40 +1260,32 @@ try {
989
1260
  }
990
1261
 
991
1262
  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
- }
1263
+ ob_start();
1264
+ require_once Bootstrap::$contentToInclude;
1265
+ MainLayout::$children = ob_get_clean();
997
1266
 
998
- foreach (array_reverse(Bootstrap::$layoutsToInclude) as $layoutPath) {
999
- if (Bootstrap::$parentLayoutPath === $layoutPath) {
1000
- continue;
1001
- }
1267
+ if (count(Bootstrap::$layoutsToInclude) > 1) {
1268
+ $nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
1002
1269
 
1003
- if (!Bootstrap::containsChildLayoutChildren($layoutPath)) {
1004
- Bootstrap::$isChildContentIncluded = true;
1005
- }
1270
+ foreach (array_reverse($nestedLayouts) as $layoutPath) {
1271
+ if (!Bootstrap::containsChildren($layoutPath)) {
1272
+ Bootstrap::$isChildContentIncluded = true;
1273
+ }
1006
1274
 
1007
- ob_start();
1008
- require_once $layoutPath;
1009
- MainLayout::$childLayoutChildren = ob_get_clean();
1275
+ ob_start();
1276
+ require_once $layoutPath;
1277
+ MainLayout::$children = ob_get_clean();
1278
+ }
1010
1279
  }
1011
1280
  } else {
1012
1281
  ob_start();
1013
1282
  require_once APP_PATH . '/not-found.php';
1014
- MainLayout::$childLayoutChildren = ob_get_clean();
1283
+ MainLayout::$children = ob_get_clean();
1015
1284
 
1016
1285
  http_response_code(404);
1017
1286
  CacheHandler::$isCacheable = false;
1018
1287
  }
1019
1288
 
1020
- if (Bootstrap::$isParentLayout && !empty(Bootstrap::$contentToInclude)) {
1021
- ob_start();
1022
- require_once Bootstrap::$contentToInclude;
1023
- MainLayout::$childLayoutChildren = ob_get_clean();
1024
- }
1025
-
1026
1289
  if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
1027
1290
  if (!Bootstrap::$secondRequestC69CD) {
1028
1291
  Bootstrap::createUpdateRequestData();
@@ -1034,7 +1297,7 @@ try {
1034
1297
  if (file_exists($file)) {
1035
1298
  ob_start();
1036
1299
  require_once $file;
1037
- MainLayout::$childLayoutChildren .= ob_get_clean();
1300
+ MainLayout::$children .= ob_get_clean();
1038
1301
  }
1039
1302
  }
1040
1303
  }
@@ -1046,57 +1309,47 @@ try {
1046
1309
  }
1047
1310
 
1048
1311
  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']));
1312
+ $shouldCache = CacheHandler::$isCacheable === true
1313
+ || (CacheHandler::$isCacheable === null && $_ENV['CACHE_ENABLED'] === 'true');
1314
+
1315
+ if ($shouldCache) {
1316
+ CacheHandler::serveCache(Request::$decodedUri, intval($_ENV['CACHE_TTL'] ?? 600));
1051
1317
  }
1052
1318
  }
1053
1319
 
1054
- MainLayout::$children = MainLayout::$childLayoutChildren . Bootstrap::getLoadingsFiles();
1320
+ MainLayout::$children .= Bootstrap::getLoadingsFiles();
1055
1321
 
1056
1322
  ob_start();
1057
- require_once APP_PATH . '/layout.php';
1323
+ if (file_exists(Bootstrap::$parentLayoutPath)) {
1324
+ require_once Bootstrap::$parentLayoutPath;
1325
+ } else {
1326
+ echo MainLayout::$children;
1327
+ }
1328
+
1058
1329
  MainLayout::$html = ob_get_clean();
1059
1330
  MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
1060
1331
  MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
1332
+ MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
1333
+
1061
1334
  MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
1062
1335
 
1063
1336
  if (
1064
- http_response_code() === 200 && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName']) && $_ENV['CACHE_ENABLED'] === 'true' && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1337
+ http_response_code() === 200
1338
+ && isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
1339
+ && $shouldCache
1340
+ && (!Request::$isWire && !Bootstrap::$secondRequestC69CD)
1065
1341
  ) {
1066
1342
  CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
1067
1343
  }
1068
1344
 
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
1345
  echo MainLayout::$html;
1088
1346
  } else {
1089
1347
  $layoutPath = Bootstrap::$isContentIncluded
1090
1348
  ? Bootstrap::$parentLayoutPath
1091
1349
  : (Bootstrap::$layoutsToInclude[0] ?? '');
1092
1350
 
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
- }
1351
+ $message = "The layout file does not contain &lt;?php echo MainLayout::\$children; ?&gt; or &lt;?= MainLayout::\$children ?&gt;\n<strong>$layoutPath</strong>";
1352
+ $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
1353
 
1101
1354
  $errorDetails = Bootstrap::isAjaxOrXFileRequestOrRouteFile() ? $message : $htmlMessage;
1102
1355