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