create-prisma-php-app 5.0.0-alpha.8 → 5.0.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.
- package/README.md +23 -2
- package/dist/.github/copilot-instructions.md +191 -0
- package/dist/AGENTS.md +588 -193
- package/dist/bootstrap.php +426 -213
- package/dist/index.js +2 -2
- package/dist/phpunit.xml +25 -0
- package/dist/postcss.config.js +4 -2
- package/dist/prisma-php.js +2 -2
- package/dist/public/.htaccess +1 -1
- package/dist/public/js/main.js +13 -1
- package/dist/public/js/pp-reactive-v2.min.js +1 -0
- package/dist/settings/auto-swagger-docs.ts +10 -10
- package/dist/settings/bs-config.ts +47 -27
- package/dist/settings/build.ts +2 -32
- package/dist/settings/component-map.ts +476 -0
- package/dist/settings/files-list.ts +2 -2
- package/dist/settings/project-name.ts +3 -6
- package/dist/settings/restart-mcp.ts +2 -2
- package/dist/settings/restart-websocket.ts +2 -2
- package/dist/settings/run-postcss.ts +205 -0
- package/dist/settings/run-tests.ts +35 -0
- package/dist/settings/swagger-config.ts +3 -3
- package/dist/settings/utils.ts +3 -3
- package/dist/src/Lib/Auth/Auth.php +62 -34
- package/dist/src/Lib/MCP/mcp-server.php +2 -3
- package/dist/src/Lib/Middleware/CorsMiddleware.php +72 -0
- package/dist/src/Lib/Websocket/ConnectionManager.php +460 -7
- package/dist/src/Lib/Websocket/Socket.php +170 -0
- package/dist/src/Lib/Websocket/SocketPool.php +50 -0
- package/dist/src/Lib/Websocket/SocketRegistry.php +88 -0
- package/dist/src/Lib/Websocket/sockets.php +50 -0
- package/dist/src/Lib/Websocket/websocket-server.php +10 -3
- package/dist/src/app/globals.css +3 -1
- package/dist/src/app/layout.php +1 -1
- package/dist/tests/AuthTest.php +59 -0
- package/dist/tests/ConnectionManagerTest.php +277 -0
- package/dist/tests/CsrfTest.php +119 -0
- package/dist/tests/DeferComponentRootsTest.php +147 -0
- package/dist/tests/FeaturesTest.php +41 -0
- package/dist/tests/README.md +119 -0
- package/dist/tests/RpcWireContractTest.php +101 -0
- package/dist/tests/SocketPoolTest.php +69 -0
- package/dist/tests/SocketRegistryTest.php +77 -0
- package/dist/tests/SocketTest.php +124 -0
- package/dist/tests/SocketsRegistrationTest.php +40 -0
- package/dist/tests/Support/FakeConnection.php +62 -0
- package/dist/tests/Support/Features.php +38 -0
- package/dist/tests/Support/RequiresFeature.php +26 -0
- package/dist/tests/bootstrap.php +44 -0
- package/dist/ts/main.ts +21 -3
- package/dist/ts/tailwind-merge.ts +13 -0
- package/package.json +4 -4
- package/dist/README.md +0 -213
- package/dist/public/js/pp-reactive-v2.js +0 -1
- package/dist/settings/bs-config.json +0 -6
- package/dist/settings/class-imports.ts +0 -165
- package/dist/settings/class-log.ts +0 -244
- package/dist/settings/component-import-checker.ts +0 -90
- package/dist/settings/files-list.json +0 -1
- package/dist/settings/prisma-schema-config.json +0 -16
package/dist/bootstrap.php
CHANGED
|
@@ -31,8 +31,21 @@ use PP\Attributes\Exposed;
|
|
|
31
31
|
use PP\Attributes\ExposedRegistry;
|
|
32
32
|
use PP\Streaming\SSE;
|
|
33
33
|
use PP\Security\RateLimiter;
|
|
34
|
+
use PP\Security\Csrf;
|
|
34
35
|
use PP\Env;
|
|
35
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An RPC failure meant for the wire: message plus HTTP status, serialized as
|
|
39
|
+
* `{"error": "..."}` — the shape the PulsePoint runtime reads.
|
|
40
|
+
*/
|
|
41
|
+
final class RpcError extends RuntimeException
|
|
42
|
+
{
|
|
43
|
+
public function __construct(string $message, public readonly int $status)
|
|
44
|
+
{
|
|
45
|
+
parent::__construct($message);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
final class Bootstrap extends RuntimeException
|
|
37
50
|
{
|
|
38
51
|
public static string $contentToInclude = '';
|
|
@@ -43,7 +56,6 @@ final class Bootstrap extends RuntimeException
|
|
|
43
56
|
public static bool $isContentIncluded = false;
|
|
44
57
|
public static bool $isChildContentIncluded = false;
|
|
45
58
|
public static bool $isContentVariableIncluded = false;
|
|
46
|
-
public static bool $secondRequestC69CD = false;
|
|
47
59
|
public static array $requestFilesData = [];
|
|
48
60
|
|
|
49
61
|
private string $context;
|
|
@@ -60,6 +72,19 @@ final class Bootstrap extends RuntimeException
|
|
|
60
72
|
);
|
|
61
73
|
}
|
|
62
74
|
|
|
75
|
+
public static function isPathWithinApp(string $path): bool
|
|
76
|
+
{
|
|
77
|
+
$resolvedPath = realpath($path);
|
|
78
|
+
if ($resolvedPath === false) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
$appPath = rtrim(str_replace('\\', '/', APP_PATH), '/');
|
|
83
|
+
$normalizedResolvedPath = str_replace('\\', '/', $resolvedPath);
|
|
84
|
+
|
|
85
|
+
return str_starts_with($normalizedResolvedPath, $appPath . '/');
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
public function __construct(string $message, string $context = '', int $code = 0, ?Throwable $previous = null)
|
|
64
89
|
{
|
|
65
90
|
$this->context = $context;
|
|
@@ -81,22 +106,7 @@ final class Bootstrap extends RuntimeException
|
|
|
81
106
|
MainLayout::init();
|
|
82
107
|
ErrorHandler::registerHandlers();
|
|
83
108
|
|
|
84
|
-
|
|
85
|
-
'expires' => time() + 3600,
|
|
86
|
-
'path' => '/',
|
|
87
|
-
'domain' => '',
|
|
88
|
-
'secure' => self::isHttpsRequest(),
|
|
89
|
-
'httponly' => false,
|
|
90
|
-
'samesite' => 'Lax',
|
|
91
|
-
]);
|
|
92
|
-
|
|
93
|
-
self::setCsrfCookie();
|
|
94
|
-
|
|
95
|
-
self::$secondRequestC69CD = Request::$data['secondRequestC69CD'] ?? false;
|
|
96
|
-
|
|
97
|
-
if (Request::$isWire && !self::$secondRequestC69CD) {
|
|
98
|
-
self::isLocalStoreCallback();
|
|
99
|
-
}
|
|
109
|
+
Csrf::ensureCookie();
|
|
100
110
|
|
|
101
111
|
$contentInfo = self::determineContentToInclude();
|
|
102
112
|
self::$contentToInclude = $contentInfo['path'] ?? '';
|
|
@@ -137,79 +147,94 @@ final class Bootstrap extends RuntimeException
|
|
|
137
147
|
ErrorHandler::checkFatalError();
|
|
138
148
|
}
|
|
139
149
|
|
|
140
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Anti-CSRF origin check for RPC calls. NOT authentication: a browser
|
|
152
|
+
* cannot forge the Origin header, so this blocks cross-site script-driven
|
|
153
|
+
* calls; raw clients are handled by the CSRF token and auth gates.
|
|
154
|
+
*
|
|
155
|
+
* @return string|null An error message, or null when the origin is fine.
|
|
156
|
+
*/
|
|
157
|
+
private static function validateRpcOrigin(): ?string
|
|
141
158
|
{
|
|
142
|
-
|
|
143
|
-
|
|
159
|
+
$origin = rtrim(trim($_SERVER['HTTP_ORIGIN'] ?? ''), '/');
|
|
160
|
+
if ($origin === '') {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (Env::string('APP_ENV', 'production') !== 'production') {
|
|
165
|
+
if (preg_match('#^http://(localhost|127\.0\.0\.1)(:\d+)?$#i', $origin)) {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
144
168
|
}
|
|
145
169
|
|
|
146
|
-
$
|
|
170
|
+
$allowedOrigins = [rtrim(Request::$protocol . Request::$domainName, '/')];
|
|
171
|
+
|
|
172
|
+
$baseUrl = rtrim(Env::string('APP_BASE_URL', ''), '/');
|
|
173
|
+
if ($baseUrl !== '') {
|
|
174
|
+
$allowedOrigins[] = $baseUrl;
|
|
175
|
+
}
|
|
147
176
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
self::jsonExit(['success' => true, 'response' => 'localStorage updated']);
|
|
177
|
+
foreach (self::parseOriginList(Env::string('CORS_ALLOWED_ORIGINS', '')) as $allowed) {
|
|
178
|
+
$allowedOrigins[] = $allowed;
|
|
151
179
|
}
|
|
180
|
+
|
|
181
|
+
return in_array($origin, $allowedOrigins, true) ? null : 'Invalid origin';
|
|
152
182
|
}
|
|
153
183
|
|
|
154
|
-
|
|
184
|
+
/**
|
|
185
|
+
* Parse an origin list env value: CSV or a JSON array, the same formats
|
|
186
|
+
* CorsMiddleware accepts.
|
|
187
|
+
*
|
|
188
|
+
* @return string[]
|
|
189
|
+
*/
|
|
190
|
+
private static function parseOriginList(string $raw): array
|
|
155
191
|
{
|
|
156
|
-
$
|
|
157
|
-
$
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
$parts = explode('.', $_COOKIE['prisma_php_csrf']);
|
|
161
|
-
if (count($parts) === 2) {
|
|
162
|
-
[$nonce, $signature] = $parts;
|
|
163
|
-
$expectedSignature = hash_hmac('sha256', $nonce, $secret);
|
|
192
|
+
$raw = trim($raw);
|
|
193
|
+
if ($raw === '' || $raw === '[]') {
|
|
194
|
+
return [];
|
|
195
|
+
}
|
|
164
196
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
$values = null;
|
|
198
|
+
if ($raw[0] === '[') {
|
|
199
|
+
$decoded = json_decode($raw, true);
|
|
200
|
+
if (is_array($decoded)) {
|
|
201
|
+
$values = array_map('strval', $decoded);
|
|
168
202
|
}
|
|
169
203
|
}
|
|
170
204
|
|
|
171
|
-
|
|
172
|
-
$nonce = bin2hex(random_bytes(16));
|
|
173
|
-
$signature = hash_hmac('sha256', $nonce, $secret);
|
|
174
|
-
$token = $nonce . '.' . $signature;
|
|
205
|
+
$values ??= explode(',', $raw);
|
|
175
206
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
]);
|
|
183
|
-
|
|
184
|
-
$_COOKIE['prisma_php_csrf'] = $token;
|
|
207
|
+
$origins = [];
|
|
208
|
+
foreach ($values as $value) {
|
|
209
|
+
$value = rtrim(trim($value), '/');
|
|
210
|
+
if ($value !== '') {
|
|
211
|
+
$origins[] = $value;
|
|
212
|
+
}
|
|
185
213
|
}
|
|
214
|
+
|
|
215
|
+
return $origins;
|
|
186
216
|
}
|
|
187
217
|
|
|
188
|
-
|
|
218
|
+
/**
|
|
219
|
+
* RPC bodies are JSON or multipart uploads; any other content type with a
|
|
220
|
+
* body is refused with 415, matching the runtime's error handling.
|
|
221
|
+
*/
|
|
222
|
+
private static function isRpcContentTypeAllowed(): bool
|
|
189
223
|
{
|
|
190
|
-
$
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (!hash_equals($cookieToken, $headerToken)) {
|
|
199
|
-
self::jsonExit(['success' => false, 'error' => 'CSRF token mismatch']);
|
|
224
|
+
$contentType = strtolower(Request::$contentType);
|
|
225
|
+
if (
|
|
226
|
+
str_starts_with($contentType, 'application/json')
|
|
227
|
+
|| str_starts_with($contentType, 'multipart/form-data')
|
|
228
|
+
) {
|
|
229
|
+
return true;
|
|
200
230
|
}
|
|
201
231
|
|
|
202
|
-
$
|
|
203
|
-
if (
|
|
204
|
-
|
|
232
|
+
$contentLength = $_SERVER['CONTENT_LENGTH'] ?? $_SERVER['HTTP_CONTENT_LENGTH'] ?? null;
|
|
233
|
+
if ($contentLength !== null) {
|
|
234
|
+
return (int) $contentLength <= 0;
|
|
205
235
|
}
|
|
206
236
|
|
|
207
|
-
|
|
208
|
-
$expectedSignature = hash_hmac('sha256', $nonce, $secret);
|
|
209
|
-
|
|
210
|
-
if (!hash_equals($expectedSignature, $signature)) {
|
|
211
|
-
self::jsonExit(['success' => false, 'error' => 'Invalid CSRF token signature']);
|
|
212
|
-
}
|
|
237
|
+
return strtolower((string) ($_SERVER['HTTP_TRANSFER_ENCODING'] ?? '')) === '';
|
|
213
238
|
}
|
|
214
239
|
|
|
215
240
|
private static function fileExistsCached(string $path): bool
|
|
@@ -269,6 +294,11 @@ final class Bootstrap extends RuntimeException
|
|
|
269
294
|
}
|
|
270
295
|
}
|
|
271
296
|
|
|
297
|
+
$exactContentInfo = self::getExactContentInfo($requestUri, $pathname);
|
|
298
|
+
if ($exactContentInfo !== null) {
|
|
299
|
+
return $exactContentInfo;
|
|
300
|
+
}
|
|
301
|
+
|
|
272
302
|
if ($pathname) {
|
|
273
303
|
$groupFolder = self::findGroupFolder($pathname);
|
|
274
304
|
if ($groupFolder) {
|
|
@@ -303,6 +333,139 @@ final class Bootstrap extends RuntimeException
|
|
|
303
333
|
];
|
|
304
334
|
}
|
|
305
335
|
|
|
336
|
+
private static function getExactContentInfo(string $requestUri, string $pathname): ?array
|
|
337
|
+
{
|
|
338
|
+
$requestData = self::findExactRequestData($requestUri, $pathname);
|
|
339
|
+
if ($requestData === null) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
$includedFiles = $requestData['includedFiles'] ?? null;
|
|
344
|
+
if (!is_array($includedFiles) || empty($includedFiles)) {
|
|
345
|
+
return null;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
$includePath = '';
|
|
349
|
+
$layoutsToInclude = [];
|
|
350
|
+
|
|
351
|
+
foreach ($includedFiles as $file) {
|
|
352
|
+
if (!is_string($file) || $file === '') {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
$normalizedFile = str_replace('\\', '/', $file);
|
|
357
|
+
|
|
358
|
+
if (str_ends_with($normalizedFile, '/layout.php')) {
|
|
359
|
+
if (self::fileExistsCached($file) && !in_array($file, $layoutsToInclude, true)) {
|
|
360
|
+
$layoutsToInclude[] = $file;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if ($includePath === '' && self::fileExistsCached($file)) {
|
|
367
|
+
$includePath = $file;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if ($includePath === '') {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
self::hydrateDynamicParamsFromIncludePath($pathname, $includePath);
|
|
376
|
+
|
|
377
|
+
usort($layoutsToInclude, static function (string $left, string $right): int {
|
|
378
|
+
return substr_count(str_replace('\\', '/', $left), '/')
|
|
379
|
+
<=> substr_count(str_replace('\\', '/', $right), '/');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
return [
|
|
383
|
+
'path' => $includePath,
|
|
384
|
+
'layouts' => $layoutsToInclude,
|
|
385
|
+
'pathname' => $pathname,
|
|
386
|
+
'uri' => $requestUri,
|
|
387
|
+
];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private static function findExactRequestData(string $requestUri, string $pathname): ?array
|
|
391
|
+
{
|
|
392
|
+
$candidates = [];
|
|
393
|
+
$decodedRequestUri = Request::getDecodedUrl($requestUri);
|
|
394
|
+
|
|
395
|
+
if ($decodedRequestUri !== '') {
|
|
396
|
+
$candidates[] = $decodedRequestUri;
|
|
397
|
+
|
|
398
|
+
$trimmedDecodedRequestUri = ltrim($decodedRequestUri, '/');
|
|
399
|
+
if ($trimmedDecodedRequestUri !== '') {
|
|
400
|
+
$candidates[] = $trimmedDecodedRequestUri;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if ($pathname === '') {
|
|
405
|
+
$candidates[] = '/';
|
|
406
|
+
} else {
|
|
407
|
+
$candidates[] = $pathname;
|
|
408
|
+
$candidates[] = '/' . $pathname;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
foreach (array_values(array_unique($candidates)) as $candidate) {
|
|
412
|
+
$requestData = PrismaPHPSettings::$includeFiles[$candidate] ?? null;
|
|
413
|
+
|
|
414
|
+
if (is_array($requestData)) {
|
|
415
|
+
return $requestData;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private static function hydrateDynamicParamsFromIncludePath(string $pathname, string $includePath): void
|
|
423
|
+
{
|
|
424
|
+
$normalizedIncludePath = str_replace('\\', '/', $includePath);
|
|
425
|
+
$normalizedAppPath = str_replace('\\', '/', APP_PATH);
|
|
426
|
+
|
|
427
|
+
if (!str_starts_with($normalizedIncludePath, $normalizedAppPath . '/')) {
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
$relativeRoutePath = ltrim(substr($normalizedIncludePath, strlen($normalizedAppPath)), '/');
|
|
432
|
+
$routeSegments = array_values(array_filter(
|
|
433
|
+
explode('/', $relativeRoutePath),
|
|
434
|
+
static function (string $segment): bool {
|
|
435
|
+
return $segment !== '' && !preg_match('/^\([^)]+\)$/', $segment);
|
|
436
|
+
}
|
|
437
|
+
));
|
|
438
|
+
|
|
439
|
+
if (!empty($routeSegments) && in_array(end($routeSegments), ['index.php', 'route.php'], true)) {
|
|
440
|
+
array_pop($routeSegments);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
$pathnameSegments = $pathname === '' ? [] : explode('/', $pathname);
|
|
444
|
+
$dynamicParams = [];
|
|
445
|
+
$pathnameIndex = 0;
|
|
446
|
+
|
|
447
|
+
foreach ($routeSegments as $routeSegment) {
|
|
448
|
+
if (preg_match('/^\[\.\.\.(.+)\]$/', $routeSegment, $matches)) {
|
|
449
|
+
$dynamicParams[$matches[1]] = array_slice($pathnameSegments, $pathnameIndex);
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (!array_key_exists($pathnameIndex, $pathnameSegments)) {
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (preg_match('/^\[(.+)\]$/', $routeSegment, $matches)) {
|
|
458
|
+
$dynamicParams[$matches[1]] = $pathnameSegments[$pathnameIndex];
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
$pathnameIndex++;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!empty($dynamicParams)) {
|
|
465
|
+
Request::$dynamicParams = new ArrayObject($dynamicParams, ArrayObject::ARRAY_AS_PROPS);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
306
469
|
private static function collectLayouts(string $pathname, ?string $groupFolder, ?string $dynamicRoute): array
|
|
307
470
|
{
|
|
308
471
|
$layoutsToInclude = [];
|
|
@@ -818,21 +981,35 @@ final class Bootstrap extends RuntimeException
|
|
|
818
981
|
$callbackName = $_SERVER['HTTP_X_PP_FUNCTION'] ?? null;
|
|
819
982
|
|
|
820
983
|
if (empty($callbackName)) {
|
|
821
|
-
self::jsonExit(['
|
|
984
|
+
self::jsonExit(['error' => 'Missing function name'], 400);
|
|
822
985
|
}
|
|
823
986
|
|
|
824
|
-
self::validateCsrfToken();
|
|
825
|
-
|
|
826
987
|
if (!preg_match('/^[a-zA-Z0-9_:\->]+$/', $callbackName)) {
|
|
827
|
-
self::jsonExit(['
|
|
988
|
+
self::jsonExit(['error' => 'Invalid function name'], 400);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (($error = self::validateRpcOrigin()) !== null) {
|
|
992
|
+
self::jsonExit(['error' => $error], 403);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (!self::isRpcContentTypeAllowed()) {
|
|
996
|
+
self::jsonExit(['error' => 'Invalid content type'], 415);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (($error = Csrf::validateHeaderToken($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '')) !== null) {
|
|
1000
|
+
self::jsonExit(['error' => $error], 403);
|
|
828
1001
|
}
|
|
829
1002
|
|
|
830
1003
|
$data = self::getRequestData();
|
|
831
1004
|
$args = self::convertToArrayObject($data);
|
|
832
1005
|
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1006
|
+
try {
|
|
1007
|
+
$out = str_contains($callbackName, '->') || str_contains($callbackName, '::')
|
|
1008
|
+
? self::dispatchMethod($callbackName, $args)
|
|
1009
|
+
: self::dispatchFunction($callbackName, $args);
|
|
1010
|
+
} catch (RpcError $e) {
|
|
1011
|
+
self::jsonExit(['error' => $e->getMessage()], $e->status);
|
|
1012
|
+
}
|
|
836
1013
|
|
|
837
1014
|
if ($out instanceof SSE) {
|
|
838
1015
|
$out->send();
|
|
@@ -844,14 +1021,18 @@ final class Bootstrap extends RuntimeException
|
|
|
844
1021
|
exit;
|
|
845
1022
|
}
|
|
846
1023
|
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
exit;
|
|
1024
|
+
// Always emit valid JSON — the runtime parses every non-stream RPC
|
|
1025
|
+
// response, so a null result must arrive as the JSON literal `null`.
|
|
1026
|
+
self::jsonExit($out);
|
|
851
1027
|
}
|
|
852
1028
|
|
|
853
|
-
private static function jsonExit(mixed $payload): void
|
|
1029
|
+
private static function jsonExit(mixed $payload, int $status = 200): void
|
|
854
1030
|
{
|
|
1031
|
+
if (!headers_sent()) {
|
|
1032
|
+
http_response_code($status);
|
|
1033
|
+
header('Content-Type: application/json; charset=UTF-8');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
855
1036
|
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
|
856
1037
|
exit;
|
|
857
1038
|
}
|
|
@@ -859,7 +1040,18 @@ final class Bootstrap extends RuntimeException
|
|
|
859
1040
|
private static function getRequestData(): array
|
|
860
1041
|
{
|
|
861
1042
|
if (!empty($_FILES)) {
|
|
862
|
-
|
|
1043
|
+
// Non-file values in an RPC multipart body are JSON-encoded by
|
|
1044
|
+
// the runtime (objects/arrays) or stringified scalars; decode
|
|
1045
|
+
// what parses so `{count: 2}` arrives as a number, not "2".
|
|
1046
|
+
$data = [];
|
|
1047
|
+
foreach ($_POST as $key => $value) {
|
|
1048
|
+
if (is_string($value)) {
|
|
1049
|
+
$decoded = json_decode($value, true);
|
|
1050
|
+
$data[$key] = json_last_error() === JSON_ERROR_NONE ? $decoded : $value;
|
|
1051
|
+
} else {
|
|
1052
|
+
$data[$key] = $value;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
863
1055
|
foreach ($_FILES as $key => $file) {
|
|
864
1056
|
$data[$key] = is_array($file['name'])
|
|
865
1057
|
? array_map(
|
|
@@ -883,69 +1075,41 @@ final class Bootstrap extends RuntimeException
|
|
|
883
1075
|
return (json_last_error() === JSON_ERROR_NONE) ? $json : $_POST;
|
|
884
1076
|
}
|
|
885
1077
|
|
|
886
|
-
|
|
1078
|
+
/**
|
|
1079
|
+
* Authorize the caller for one exposed function, mirroring the reference
|
|
1080
|
+
* server: 401 when auth is required and missing, 403 on a role mismatch.
|
|
1081
|
+
*/
|
|
1082
|
+
private static function authorizeAccess(Exposed $attribute): void
|
|
887
1083
|
{
|
|
888
|
-
if (
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
if (!$auth->isAuthenticated()) {
|
|
892
|
-
return false;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
if (!empty($attribute->allowedRoles)) {
|
|
896
|
-
$payload = $auth->getPayload();
|
|
897
|
-
$currentRole = null;
|
|
898
|
-
|
|
899
|
-
if (is_scalar($payload)) {
|
|
900
|
-
$currentRole = $payload;
|
|
901
|
-
} else {
|
|
902
|
-
$roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
|
|
1084
|
+
if (!$attribute->requiresAuth && empty($attribute->allowedRoles)) {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
903
1087
|
|
|
904
|
-
|
|
905
|
-
$currentRole = $payload->$roleKey ?? null;
|
|
906
|
-
} elseif (is_array($payload)) {
|
|
907
|
-
$currentRole = $payload[$roleKey] ?? null;
|
|
908
|
-
}
|
|
909
|
-
}
|
|
1088
|
+
$auth = Auth::getInstance();
|
|
910
1089
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
}
|
|
914
|
-
}
|
|
1090
|
+
if (!$auth->isAuthenticated()) {
|
|
1091
|
+
throw new RpcError('Authentication required', 401);
|
|
915
1092
|
}
|
|
916
1093
|
|
|
917
|
-
|
|
918
|
-
|
|
1094
|
+
if (!empty($attribute->allowedRoles)) {
|
|
1095
|
+
$payload = $auth->getPayload();
|
|
1096
|
+
$currentRole = null;
|
|
919
1097
|
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
$attrs = $ref->getAttributes(Exposed::class);
|
|
1098
|
+
if (is_scalar($payload)) {
|
|
1099
|
+
$currentRole = $payload;
|
|
1100
|
+
} else {
|
|
1101
|
+
$roleKey = !empty(Auth::ROLE_NAME) ? Auth::ROLE_NAME : 'role';
|
|
925
1102
|
|
|
926
|
-
|
|
927
|
-
|
|
1103
|
+
if (is_object($payload)) {
|
|
1104
|
+
$currentRole = $payload->$roleKey ?? null;
|
|
1105
|
+
} elseif (is_array($payload)) {
|
|
1106
|
+
$currentRole = $payload[$roleKey] ?? null;
|
|
1107
|
+
}
|
|
928
1108
|
}
|
|
929
1109
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
return false;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
private static function isMethodAllowed(string $class, string $method): bool
|
|
937
|
-
{
|
|
938
|
-
try {
|
|
939
|
-
$ref = new ReflectionMethod($class, $method);
|
|
940
|
-
$attrs = $ref->getAttributes(Exposed::class);
|
|
941
|
-
|
|
942
|
-
if (empty($attrs)) {
|
|
943
|
-
return false;
|
|
1110
|
+
if ($currentRole === null || !in_array($currentRole, $attribute->allowedRoles)) {
|
|
1111
|
+
throw new RpcError('Permission denied', 403);
|
|
944
1112
|
}
|
|
945
|
-
|
|
946
|
-
return self::validateAccess($attrs[0]->newInstance());
|
|
947
|
-
} catch (Throwable) {
|
|
948
|
-
return false;
|
|
949
1113
|
}
|
|
950
1114
|
}
|
|
951
1115
|
|
|
@@ -978,7 +1142,33 @@ final class Bootstrap extends RuntimeException
|
|
|
978
1142
|
}
|
|
979
1143
|
|
|
980
1144
|
if ($limits) {
|
|
981
|
-
|
|
1145
|
+
try {
|
|
1146
|
+
RateLimiter::verify($identifier, $limits);
|
|
1147
|
+
} catch (Throwable $e) {
|
|
1148
|
+
throw new RpcError('Rate limit exceeded. Try again later.', 429);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* Run one exposed callable and translate its failures onto the wire:
|
|
1155
|
+
* `InvalidArgumentException` is a message meant for the caller (400, the
|
|
1156
|
+
* validation convention), anything else is a 500.
|
|
1157
|
+
*/
|
|
1158
|
+
private static function invokeExposed(callable $callable, mixed $args): mixed
|
|
1159
|
+
{
|
|
1160
|
+
try {
|
|
1161
|
+
return call_user_func($callable, $args);
|
|
1162
|
+
} catch (RpcError $e) {
|
|
1163
|
+
throw $e;
|
|
1164
|
+
} catch (InvalidArgumentException $e) {
|
|
1165
|
+
throw new RpcError($e->getMessage(), 400);
|
|
1166
|
+
} catch (Throwable $e) {
|
|
1167
|
+
if (Env::string('SHOW_ERRORS', 'false') === 'false') {
|
|
1168
|
+
throw new RpcError('Internal server error', 500);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
throw new RpcError("Function error: {$e->getMessage()}", 500);
|
|
982
1172
|
}
|
|
983
1173
|
}
|
|
984
1174
|
|
|
@@ -991,39 +1181,21 @@ final class Bootstrap extends RuntimeException
|
|
|
991
1181
|
}
|
|
992
1182
|
}
|
|
993
1183
|
|
|
994
|
-
if (!
|
|
995
|
-
|
|
1184
|
+
if (!function_exists($fn) || !is_callable($fn)) {
|
|
1185
|
+
throw new RpcError('Function not found', 404);
|
|
996
1186
|
}
|
|
997
1187
|
|
|
998
1188
|
$attribute = self::getExposedAttribute($fn);
|
|
999
|
-
if (
|
|
1000
|
-
|
|
1189
|
+
if ($attribute === null) {
|
|
1190
|
+
// Existing but not #[Exposed]: indistinguishable from absent, on
|
|
1191
|
+
// purpose — the wire must not reveal which functions exist.
|
|
1192
|
+
throw new RpcError('Function not found', 404);
|
|
1001
1193
|
}
|
|
1002
1194
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
self::enforceRateLimit($attribute, "fn:$fn");
|
|
1006
|
-
|
|
1007
|
-
$res = call_user_func($fn, $args);
|
|
1008
|
-
|
|
1009
|
-
if ($res instanceof Generator || $res instanceof SSE) {
|
|
1010
|
-
return $res;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
return $res;
|
|
1014
|
-
} catch (Throwable $e) {
|
|
1015
|
-
if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
|
|
1016
|
-
return ['success' => false, 'error' => $e->getMessage()];
|
|
1017
|
-
}
|
|
1195
|
+
self::authorizeAccess($attribute);
|
|
1196
|
+
self::enforceRateLimit($attribute, "fn:$fn");
|
|
1018
1197
|
|
|
1019
|
-
|
|
1020
|
-
return ['success' => false, 'error' => 'An error occurred. Please try again later.'];
|
|
1021
|
-
} else {
|
|
1022
|
-
return ['success' => false, 'error' => "Function error: {$e->getMessage()}"];
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
return ['success' => false, 'error' => 'Invalid callback'];
|
|
1198
|
+
return self::invokeExposed($fn, $args);
|
|
1027
1199
|
}
|
|
1028
1200
|
|
|
1029
1201
|
private static function dispatchMethod(string $call, mixed $args)
|
|
@@ -1046,56 +1218,36 @@ final class Bootstrap extends RuntimeException
|
|
|
1046
1218
|
}
|
|
1047
1219
|
|
|
1048
1220
|
if (!class_exists($class)) {
|
|
1049
|
-
|
|
1050
|
-
}
|
|
1051
|
-
|
|
1052
|
-
if (!self::isMethodAllowed($class, $method)) {
|
|
1053
|
-
return ['success' => false, 'error' => 'Method not callable from client'];
|
|
1221
|
+
throw new RpcError('Function not found', 404);
|
|
1054
1222
|
}
|
|
1055
1223
|
|
|
1056
1224
|
$attribute = self::getExposedAttribute($class, $method);
|
|
1057
|
-
if (
|
|
1058
|
-
|
|
1225
|
+
if ($attribute === null) {
|
|
1226
|
+
throw new RpcError('Function not found', 404);
|
|
1059
1227
|
}
|
|
1060
1228
|
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
try {
|
|
1066
|
-
self::enforceRateLimit($attribute, "method:$class::$method");
|
|
1067
|
-
|
|
1068
|
-
$res = null;
|
|
1069
|
-
if (!$isStatic) {
|
|
1070
|
-
$instance = new $class();
|
|
1071
|
-
if (!is_callable([$instance, $method])) throw new Exception("Method not callable");
|
|
1072
|
-
$res = call_user_func([$instance, $method], $args);
|
|
1073
|
-
} else {
|
|
1074
|
-
if (!is_callable([$class, $method])) throw new Exception("Static method invalid");
|
|
1075
|
-
$res = call_user_func([$class, $method], $args);
|
|
1076
|
-
}
|
|
1229
|
+
self::authorizeAccess($attribute);
|
|
1230
|
+
self::enforceRateLimit($attribute, "method:$class::$method");
|
|
1077
1231
|
|
|
1078
|
-
|
|
1079
|
-
|
|
1232
|
+
if ($isStatic) {
|
|
1233
|
+
if (!is_callable([$class, $method])) {
|
|
1234
|
+
throw new RpcError('Function not found', 404);
|
|
1080
1235
|
}
|
|
1081
1236
|
|
|
1082
|
-
return $
|
|
1083
|
-
}
|
|
1084
|
-
if ($e->getMessage() === 'Rate limit exceeded. Try again later.') {
|
|
1085
|
-
return ['success' => false, 'error' => $e->getMessage()];
|
|
1086
|
-
}
|
|
1237
|
+
return self::invokeExposed([$class, $method], $args);
|
|
1238
|
+
}
|
|
1087
1239
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
return ['success' => false, 'error' => "Call error: {$e->getMessage()}"];
|
|
1092
|
-
}
|
|
1240
|
+
$instance = new $class();
|
|
1241
|
+
if (!is_callable([$instance, $method])) {
|
|
1242
|
+
throw new RpcError('Function not found', 404);
|
|
1093
1243
|
}
|
|
1244
|
+
|
|
1245
|
+
return self::invokeExposed([$instance, $method], $args);
|
|
1094
1246
|
}
|
|
1095
1247
|
|
|
1096
1248
|
private static function resolveClassImport(string $simpleClassKey): ?array
|
|
1097
1249
|
{
|
|
1098
|
-
$logs = PrismaPHPSettings
|
|
1250
|
+
$logs = PrismaPHPSettings::getClassLogFiles()[$simpleClassKey] ?? [];
|
|
1099
1251
|
if (!is_array($logs) || empty($logs)) {
|
|
1100
1252
|
return null;
|
|
1101
1253
|
}
|
|
@@ -1178,19 +1330,35 @@ final class Bootstrap extends RuntimeException
|
|
|
1178
1330
|
}
|
|
1179
1331
|
}
|
|
1180
1332
|
|
|
1181
|
-
$currentUrl = Request::getDecodedUrl(Request::$uri);
|
|
1333
|
+
$currentUrl = self::normalizeRequestDataKey(Request::getDecodedUrl(Request::$uri));
|
|
1334
|
+
$legacyUrl = $currentUrl === '/' ? null : '/' . $currentUrl;
|
|
1335
|
+
|
|
1336
|
+
if ($legacyUrl !== null && isset($currentData[$legacyUrl])) {
|
|
1337
|
+
$legacyEntry = $currentData[$legacyUrl];
|
|
1338
|
+
unset($currentData[$legacyUrl]);
|
|
1339
|
+
|
|
1340
|
+
if (!isset($currentData[$currentUrl])) {
|
|
1341
|
+
$currentData[$currentUrl] = $legacyEntry;
|
|
1342
|
+
} elseif (isset($legacyEntry['includedFiles']) && is_array($legacyEntry['includedFiles'])) {
|
|
1343
|
+
$currentData[$currentUrl]['includedFiles'] = array_values(array_unique(array_merge(
|
|
1344
|
+
$currentData[$currentUrl]['includedFiles'] ?? [],
|
|
1345
|
+
$legacyEntry['includedFiles']
|
|
1346
|
+
)));
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1182
1349
|
|
|
1183
1350
|
if (isset($currentData[$currentUrl])) {
|
|
1351
|
+
$currentData[$currentUrl]['url'] = $currentUrl;
|
|
1184
1352
|
$currentData[$currentUrl]['includedFiles'] = array_values(array_unique(
|
|
1185
1353
|
array_merge($currentData[$currentUrl]['includedFiles'], $srcAppFiles)
|
|
1186
1354
|
));
|
|
1187
1355
|
|
|
1188
|
-
if (!Request::$
|
|
1356
|
+
if (!Request::$isRpc) {
|
|
1189
1357
|
$currentData[$currentUrl]['isCacheable'] = CacheHandler::$isCacheable;
|
|
1190
1358
|
}
|
|
1191
1359
|
} else {
|
|
1192
1360
|
$currentData[$currentUrl] = [
|
|
1193
|
-
'url' =>
|
|
1361
|
+
'url' => $currentUrl,
|
|
1194
1362
|
'fileName' => self::convertUrlToFileName($currentUrl),
|
|
1195
1363
|
'isCacheable' => CacheHandler::$isCacheable,
|
|
1196
1364
|
'cacheTtl' => CacheHandler::$ttl,
|
|
@@ -1213,6 +1381,17 @@ final class Bootstrap extends RuntimeException
|
|
|
1213
1381
|
return $fileName ? mb_strtolower($fileName, 'UTF-8') : 'index';
|
|
1214
1382
|
}
|
|
1215
1383
|
|
|
1384
|
+
private static function normalizeRequestDataKey(string $url): string
|
|
1385
|
+
{
|
|
1386
|
+
$trimmedUrl = trim($url, '/');
|
|
1387
|
+
|
|
1388
|
+
if ($trimmedUrl === '') {
|
|
1389
|
+
return '/';
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
return $trimmedUrl;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1216
1395
|
private static function authenticateUserToken(): void
|
|
1217
1396
|
{
|
|
1218
1397
|
$token = Request::getBearerToken();
|
|
@@ -1237,7 +1416,17 @@ final class Bootstrap extends RuntimeException
|
|
|
1237
1416
|
public static function applyRootLayoutId(string $html): string
|
|
1238
1417
|
{
|
|
1239
1418
|
$rootLayoutPath = self::$layoutsToInclude[0] ?? self::$parentLayoutPath;
|
|
1240
|
-
|
|
1419
|
+
|
|
1420
|
+
if (!empty($rootLayoutPath)) {
|
|
1421
|
+
$resolvedRootLayoutPath = realpath($rootLayoutPath);
|
|
1422
|
+
$canonicalRootLayoutPath = $resolvedRootLayoutPath !== false
|
|
1423
|
+
? $resolvedRootLayoutPath
|
|
1424
|
+
: $rootLayoutPath;
|
|
1425
|
+
|
|
1426
|
+
$rootLayoutId = md5(str_replace('\\', '/', $canonicalRootLayoutPath));
|
|
1427
|
+
} else {
|
|
1428
|
+
$rootLayoutId = 'default-root';
|
|
1429
|
+
}
|
|
1241
1430
|
|
|
1242
1431
|
header('X-PP-Root-Layout: ' . $rootLayoutId);
|
|
1243
1432
|
|
|
@@ -1267,11 +1456,18 @@ try {
|
|
|
1267
1456
|
|
|
1268
1457
|
if (is_file(Bootstrap::$requestFilePath)) {
|
|
1269
1458
|
if (file_exists(Bootstrap::$requestFilePath) && Request::$isXFileRequest) {
|
|
1459
|
+
if (!Bootstrap::isPathWithinApp(Bootstrap::$requestFilePath)) {
|
|
1460
|
+
http_response_code(403);
|
|
1461
|
+
exit;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
$resolvedRequestFilePath = realpath(Bootstrap::$requestFilePath);
|
|
1465
|
+
|
|
1270
1466
|
if (pathinfo(Bootstrap::$requestFilePath, PATHINFO_EXTENSION) === 'php') {
|
|
1271
|
-
include Bootstrap::$requestFilePath;
|
|
1467
|
+
include $resolvedRequestFilePath ?: Bootstrap::$requestFilePath;
|
|
1272
1468
|
} else {
|
|
1273
|
-
header('Content-Type: ' . mime_content_type(Bootstrap::$requestFilePath));
|
|
1274
|
-
readfile(Bootstrap::$requestFilePath);
|
|
1469
|
+
header('Content-Type: ' . mime_content_type($resolvedRequestFilePath ?: Bootstrap::$requestFilePath));
|
|
1470
|
+
readfile($resolvedRequestFilePath ?: Bootstrap::$requestFilePath);
|
|
1275
1471
|
}
|
|
1276
1472
|
exit;
|
|
1277
1473
|
}
|
|
@@ -1293,6 +1489,13 @@ try {
|
|
|
1293
1489
|
require_once Bootstrap::$contentToInclude;
|
|
1294
1490
|
MainLayout::$children = ob_get_clean();
|
|
1295
1491
|
|
|
1492
|
+
if (Request::$fileToInclude === 'index.php') {
|
|
1493
|
+
MainLayout::$children = TemplateCompiler::scopeRouteRoot(
|
|
1494
|
+
MainLayout::$children,
|
|
1495
|
+
Bootstrap::$contentToInclude
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1296
1499
|
if (count(Bootstrap::$layoutsToInclude) > 1) {
|
|
1297
1500
|
$nestedLayouts = array_slice(Bootstrap::$layoutsToInclude, 1);
|
|
1298
1501
|
|
|
@@ -1304,6 +1507,7 @@ try {
|
|
|
1304
1507
|
ob_start();
|
|
1305
1508
|
require_once $layoutPath;
|
|
1306
1509
|
MainLayout::$children = ob_get_clean();
|
|
1510
|
+
MainLayout::$children = TemplateCompiler::scopeRouteRoot(MainLayout::$children, $layoutPath);
|
|
1307
1511
|
}
|
|
1308
1512
|
}
|
|
1309
1513
|
} else {
|
|
@@ -1316,7 +1520,7 @@ try {
|
|
|
1316
1520
|
}
|
|
1317
1521
|
|
|
1318
1522
|
if (!Bootstrap::$isContentIncluded && !Bootstrap::$isChildContentIncluded) {
|
|
1319
|
-
if (Request::$
|
|
1523
|
+
if (Request::$isRpc) {
|
|
1320
1524
|
if (isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
|
|
1321
1525
|
foreach (Bootstrap::$requestFilesData[Request::$decodedUri]['includedFiles'] as $file) {
|
|
1322
1526
|
if (file_exists($file)) {
|
|
@@ -1328,14 +1532,14 @@ try {
|
|
|
1328
1532
|
}
|
|
1329
1533
|
}
|
|
1330
1534
|
|
|
1331
|
-
if (Request::$
|
|
1535
|
+
if (Request::$isRpc) {
|
|
1332
1536
|
while (ob_get_level() > 0) {
|
|
1333
1537
|
ob_end_clean();
|
|
1334
1538
|
}
|
|
1335
1539
|
Bootstrap::wireCallback();
|
|
1336
1540
|
}
|
|
1337
1541
|
|
|
1338
|
-
if (
|
|
1542
|
+
if (!Request::$isRpc && isset(Bootstrap::$requestFilesData[Request::$decodedUri])) {
|
|
1339
1543
|
$cacheEnabled = (Env::string('CACHE_ENABLED', 'false') === 'true');
|
|
1340
1544
|
|
|
1341
1545
|
$shouldCache = CacheHandler::$isCacheable === true
|
|
@@ -1356,25 +1560,34 @@ try {
|
|
|
1356
1560
|
}
|
|
1357
1561
|
|
|
1358
1562
|
MainLayout::$html = ob_get_clean();
|
|
1563
|
+
|
|
1564
|
+
if (file_exists(Bootstrap::$parentLayoutPath)) {
|
|
1565
|
+
TemplateCompiler::validateSingleRootHtml(
|
|
1566
|
+
MainLayout::$html,
|
|
1567
|
+
Bootstrap::$parentLayoutPath,
|
|
1568
|
+
'Layout file'
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1359
1572
|
MainLayout::$html = TemplateCompiler::compile(MainLayout::$html);
|
|
1360
1573
|
MainLayout::$html = TemplateCompiler::injectDynamicContent(MainLayout::$html);
|
|
1361
1574
|
MainLayout::$html = Bootstrap::applyRootLayoutId(MainLayout::$html);
|
|
1575
|
+
MainLayout::$html = TemplateCompiler::deferComponentRoots(MainLayout::$html);
|
|
1362
1576
|
|
|
1363
1577
|
MainLayout::$html = "<!DOCTYPE html>\n" . MainLayout::$html;
|
|
1364
1578
|
|
|
1365
|
-
|
|
1366
|
-
Bootstrap::createUpdateRequestData();
|
|
1367
|
-
}
|
|
1579
|
+
Bootstrap::createUpdateRequestData();
|
|
1368
1580
|
|
|
1369
1581
|
if (
|
|
1370
1582
|
http_response_code() === 200
|
|
1371
1583
|
&& isset(Bootstrap::$requestFilesData[Request::$decodedUri]['fileName'])
|
|
1372
1584
|
&& $shouldCache
|
|
1373
|
-
&&
|
|
1585
|
+
&& !Request::$isRpc
|
|
1374
1586
|
) {
|
|
1375
1587
|
CacheHandler::saveCache(Request::$decodedUri, MainLayout::$html);
|
|
1376
1588
|
}
|
|
1377
1589
|
|
|
1590
|
+
header('Content-Type: text/html; charset=UTF-8');
|
|
1378
1591
|
echo MainLayout::$html;
|
|
1379
1592
|
} else {
|
|
1380
1593
|
$layoutPath = Bootstrap::$isContentIncluded
|