1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\AppScopeTrait;
8: use Atk4\Core\DiContainerTrait;
9: use Atk4\Core\DynamicMethodTrait;
10: use Atk4\Core\ExceptionRenderer;
11: use Atk4\Core\Factory;
12: use Atk4\Core\HookTrait;
13: use Atk4\Core\TraitUtil;
14: use Atk4\Core\WarnDynamicPropertyTrait;
15: use Atk4\Data\Persistence;
16: use Atk4\Ui\Exception\ExitApplicationError;
17: use Atk4\Ui\Exception\LateOutputError;
18: use Atk4\Ui\Exception\UnhandledCallbackExceptionError;
19: use Atk4\Ui\Js\JsExpression;
20: use Atk4\Ui\Js\JsExpressionable;
21: use Atk4\Ui\Persistence\Ui as UiPersistence;
22: use Atk4\Ui\UserAction\ExecutorFactory;
23: use Nyholm\Psr7\Factory\Psr17Factory;
24: use Nyholm\Psr7\Response;
25: use Nyholm\Psr7Server\ServerRequestCreator;
26: use Psr\Http\Message\ResponseInterface;
27: use Psr\Http\Message\ServerRequestInterface;
28: use Psr\Http\Message\StreamInterface;
29: use Psr\Http\Message\UploadedFileInterface;
30: use Psr\Log\LoggerInterface;
31:
32: class App
33: {
34: use AppScopeTrait;
35: use DiContainerTrait;
36: use DynamicMethodTrait;
37: use HookTrait;
38:
39: private const UNSUPPRESSEABLE_ERROR_LEVELS = \PHP_MAJOR_VERSION >= 8 ? (\E_ERROR | \E_PARSE | \E_CORE_ERROR | \E_COMPILE_ERROR | \E_USER_ERROR | \E_RECOVERABLE_ERROR) : 0;
40:
41: public const HOOK_BEFORE_EXIT = self::class . '@beforeExit';
42: public const HOOK_BEFORE_RENDER = self::class . '@beforeRender';
43:
44: /** @var array|false Location where to load JS/CSS files */
45: public $cdn = [
46: 'atk' => '/public',
47: 'jquery' => '/public/external/jquery/dist',
48: 'fomantic-ui' => '/public/external/fomantic-ui/dist',
49: 'flatpickr' => '/public/external/flatpickr/dist',
50: 'highlight.js' => '/public/external/@highlightjs/cdn-assets',
51: 'chart.js' => '/public/external/chart.js/dist', // for atk4/chart
52: ];
53:
54: /** @var ExecutorFactory App wide executor factory object for Model user action. */
55: protected $executorFactory;
56:
57: /**
58: * @var string Version of Agile UI
59: *
60: * @TODO remove, no longer needed for CDN versioning as we bundle all resources
61: */
62: public $version = '5.1-dev';
63:
64: /** @var string Name of application */
65: public $title = 'Agile UI - Untitled Application';
66:
67: /** @var Layout the top-most view object */
68: public $layout;
69:
70: /** @var string|array Set one or more directories where templates should reside. */
71: public $templateDir;
72:
73: /** @var bool Will replace an exception handler with our own, that will output errors nicely. */
74: public $catchExceptions = true;
75:
76: /** Will display error if callback wasn't triggered. */
77: protected bool $catchRunawayCallbacks = true;
78:
79: /** Will always run application even if developer didn't explicitly executed run();. */
80: protected bool $alwaysRun = true;
81:
82: /** Will be set to true after app->run() is called, which may be done automatically on exit. */
83: public bool $runCalled = false;
84:
85: /** Will be set to true after exit is called. */
86: private bool $exitCalled = false;
87:
88: public bool $isRendering = false;
89:
90: /** @var UiPersistence */
91: public $uiPersistence;
92:
93: /** @var View|null For internal use */
94: public $html;
95:
96: /** @var LoggerInterface|null Target for objects with DebugTrait */
97: public $logger;
98:
99: /** @var Persistence|Persistence\Sql */
100: public $db;
101:
102: /** @var App\SessionManager */
103: public $session;
104:
105: private ServerRequestInterface $request;
106:
107: private ResponseInterface $response;
108:
109: /**
110: * If filename path part is missing during building of URL, this page will be used.
111: * Set to empty string when when your webserver supports index.php autoindex or you use mod_rewrite with routing.
112: *
113: * @internal only for self::url() method
114: */
115: protected string $urlBuildingIndexPage = 'index';
116:
117: /**
118: * Remove and re-add the extension of the file during parsing requests and building URL.
119: *
120: * @internal only for self::url() method
121: */
122: protected string $urlBuildingExt = '.php';
123:
124: /** @var bool Call exit in place of throw Exception when Application need to exit. */
125: public $callExit = true;
126:
127: /** @var array<string, bool> global sticky arguments */
128: protected array $stickyGetArguments = [
129: '__atk_json' => false,
130: '__atk_tab' => false,
131: ];
132:
133: /** @var class-string */
134: public $templateClass = HtmlTemplate::class;
135:
136: public function __construct(array $defaults = [])
137: {
138: if (isset($defaults['request'])) {
139: $this->request = $defaults['request'];
140: unset($defaults['request']);
141: } else {
142: $requestFactory = new Psr17Factory();
143: $requestCreator = new ServerRequestCreator($requestFactory, $requestFactory, $requestFactory, $requestFactory);
144:
145: $noGlobals = [];
146: foreach (['_GET', '_COOKIE', '_FILES'] as $k) {
147: if (!array_key_exists($k, $GLOBALS)) {
148: $noGlobals[] = $k;
149: $GLOBALS[$k] = [];
150: }
151: }
152: try {
153: $this->request = $requestCreator->fromGlobals();
154: } finally {
155: foreach ($noGlobals as $k) {
156: unset($GLOBALS[$k]);
157: }
158: }
159: }
160:
161: if (isset($defaults['response'])) {
162: $this->response = $defaults['response'];
163: unset($defaults['response']);
164: } else {
165: $this->response = new Response();
166: }
167:
168: // disable caching by default
169: $this->setResponseHeader('Cache-Control', 'no-store');
170:
171: $this->setApp($this);
172:
173: $this->setDefaults($defaults);
174:
175: $this->setupTemplateDirs();
176:
177: foreach ($this->cdn as $k => $v) {
178: if (str_starts_with($v, '/') && !str_starts_with($v, '//')) {
179: $this->cdn[$k] = $this->createRequestPathFromLocalPath(__DIR__ . '/..' . $v);
180: }
181: }
182:
183: // set our exception handler
184: if ($this->catchExceptions) {
185: set_exception_handler(\Closure::fromCallable([$this, 'caughtException']));
186: set_error_handler(static function (int $severity, string $msg, string $file, int $line): bool {
187: if ((error_reporting() & ~self::UNSUPPRESSEABLE_ERROR_LEVELS) === 0) {
188: $isFirstFrame = true;
189: foreach (array_slice(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 10), 1) as $frame) {
190: // allow to suppress any warning outside Atk4
191: if ($isFirstFrame) {
192: $isFirstFrame = false;
193: if (!isset($frame['class']) || !str_starts_with($frame['class'], 'Atk4\\')) {
194: return false;
195: }
196: }
197:
198: // allow to suppress undefined property warning
199: if (isset($frame['class']) && TraitUtil::hasTrait($frame['class'], WarnDynamicPropertyTrait::class)
200: && $frame['function'] === 'warnPropertyDoesNotExist') {
201: return false;
202: }
203: }
204: }
205:
206: throw new \ErrorException($msg, 0, $severity, $file, $line);
207: });
208:
209: http_response_code(500);
210: header('Content-Type: text/plain');
211: header('Cache-Control: no-store');
212: }
213:
214: // always run app on shutdown
215: if ($this->alwaysRun) {
216: $this->setupAlwaysRun();
217: }
218:
219: if ($this->uiPersistence === null) {
220: $this->uiPersistence = new UiPersistence();
221: }
222:
223: if (!str_starts_with($this->getRequest()->getUri()->getPath(), '/')) {
224: throw (new Exception('Request URL path must always start with \'/\''))
225: ->addMoreInfo('url', (string) $this->getRequest()->getUri());
226: }
227:
228: if ($this->session === null) {
229: $this->session = new App\SessionManager();
230: }
231:
232: // setting up default executor factory
233: $this->executorFactory = Factory::factory([ExecutorFactory::class]);
234: }
235:
236: public function setExecutorFactory(ExecutorFactory $factory): void
237: {
238: $this->executorFactory = $factory;
239: }
240:
241: public function getExecutorFactory(): ExecutorFactory
242: {
243: return $this->executorFactory;
244: }
245:
246: protected function setupTemplateDirs(): void
247: {
248: if ($this->templateDir === null) {
249: $this->templateDir = [];
250: } elseif (!is_array($this->templateDir)) {
251: $this->templateDir = [$this->templateDir];
252: }
253:
254: $this->templateDir[] = dirname(__DIR__) . '/template';
255: }
256:
257: protected function callBeforeExit(): void
258: {
259: if (!$this->exitCalled) {
260: $this->exitCalled = true;
261: $this->hook(self::HOOK_BEFORE_EXIT);
262: }
263: }
264:
265: /**
266: * @return never
267: */
268: public function callExit(): void
269: {
270: $this->callBeforeExit();
271:
272: if (!$this->callExit) {
273: // case process is not in shutdown mode
274: // App as already done everything
275: // App need to stop output
276: // set_handler to catch/trap any exception
277: set_exception_handler(static function (\Throwable $t): void {});
278:
279: // raise exception to be trapped and stop execution
280: throw new ExitApplicationError();
281: }
282:
283: exit;
284: }
285:
286: protected function caughtException(\Throwable $exception): void
287: {
288: if ($exception instanceof LateOutputError) {
289: $this->outputLateOutputError($exception);
290: }
291:
292: while ($exception instanceof UnhandledCallbackExceptionError) {
293: $exception = $exception->getPrevious();
294: }
295:
296: $this->catchRunawayCallbacks = false;
297:
298: // just replace layout to avoid any extended App->_construct problems
299: // it will maintain everything as in the original app StickyGet, logger, Events
300: $this->html = null;
301: $this->initLayout([Layout\Centered::class]);
302:
303: $this->layout->template->dangerouslySetHtml('Content', $this->renderExceptionHtml($exception));
304:
305: $this->layout->template->tryDel('Header');
306:
307: $this->setResponseHeader('Cache-Control', 'no-store');
308:
309: if (($this->isJsUrlRequest() || $this->getRequest()->getHeaderLine('X-Requested-With') === 'XMLHttpRequest')
310: && !$this->hasRequestQueryParam('__atk_tab')) {
311: $this->outputResponseJson([
312: 'success' => false,
313: 'message' => $this->layout->getHtml(),
314: ]);
315: } else {
316: $this->setResponseStatusCode(500);
317: $this->run();
318: }
319:
320: // process is already in shutdown because of uncaught exception
321: // no need of call exit function
322: $this->callBeforeExit();
323: }
324:
325: public function getRequest(): ServerRequestInterface
326: {
327: return $this->request;
328: }
329:
330: /**
331: * Check if a specific GET parameter exists in the HTTP request.
332: */
333: public function hasRequestQueryParam(string $key): bool
334: {
335: return $this->tryGetRequestQueryParam($key) !== null;
336: }
337:
338: /**
339: * Try to get the value of a specific GET parameter from the HTTP request.
340: */
341: public function tryGetRequestQueryParam(string $key): ?string
342: {
343: return $this->getRequest()->getQueryParams()[$key] ?? null;
344: }
345:
346: /**
347: * Get the value of a specific GET parameter from the HTTP request.
348: */
349: public function getRequestQueryParam(string $key): string
350: {
351: $res = $this->tryGetRequestQueryParam($key);
352: if ($res === null) {
353: throw (new Exception('GET param does not exist'))
354: ->addMoreInfo('key', $key);
355: }
356:
357: return $res;
358: }
359:
360: /**
361: * Check if a specific POST parameter exists in the HTTP request.
362: */
363: public function hasRequestPostParam(string $key): bool
364: {
365: return $this->tryGetRequestPostParam($key) !== null;
366: }
367:
368: /**
369: * Try to get the value of a specific POST parameter from the HTTP request.
370: */
371: public function tryGetRequestPostParam(string $key): ?string
372: {
373: return $this->getRequest()->getParsedBody()[$key] ?? null;
374: }
375:
376: /**
377: * Get the value of a specific POST parameter from the HTTP request.
378: *
379: * @return mixed
380: */
381: public function getRequestPostParam(string $key)
382: {
383: $res = $this->tryGetRequestPostParam($key);
384: if ($res === null) {
385: throw (new Exception('POST param does not exist'))
386: ->addMoreInfo('key', $key);
387: }
388:
389: return $res;
390: }
391:
392: /**
393: * Check if a specific uploaded file exists in the HTTP request.
394: */
395: public function hasRequestUploadedFile(string $key): bool
396: {
397: return $this->tryGetRequestUploadedFile($key) !== null;
398: }
399:
400: /**
401: * Try to get a specific uploaded file from the HTTP request.
402: */
403: public function tryGetRequestUploadedFile(string $key): ?UploadedFileInterface
404: {
405: return $this->getRequest()->getUploadedFiles()[$key] ?? null;
406: }
407:
408: /**
409: * Get a specific uploaded file from the HTTP request.
410: */
411: public function getRequestUploadedFile(string $key): UploadedFileInterface
412: {
413: $res = $this->tryGetRequestUploadedFile($key);
414: if ($res === null) {
415: throw (new Exception('FILES upload does not exist'))
416: ->addMoreInfo('key', $key);
417: }
418:
419: return $res;
420: }
421:
422: public function getResponse(): ResponseInterface
423: {
424: return $this->response;
425: }
426:
427: protected function assertHeadersNotSent(): void
428: {
429: if (headers_sent()
430: && \PHP_SAPI !== 'cli' // for phpunit
431: && $this->response->getHeaderLine('Content-Type') !== 'text/event-stream' // for SSE
432: ) {
433: $lateError = new LateOutputError('Headers already sent, more headers cannot be set at this stage');
434: if ($this->catchExceptions) {
435: $this->caughtException($lateError);
436: $this->outputLateOutputError($lateError);
437: }
438:
439: throw $lateError;
440: }
441: }
442:
443: /**
444: * @return $this
445: */
446: public function setResponseStatusCode(int $statusCode): self
447: {
448: $this->assertHeadersNotSent();
449:
450: $this->response = $this->response->withStatus($statusCode);
451:
452: return $this;
453: }
454:
455: /**
456: * @return $this
457: */
458: public function setResponseHeader(string $name, string $value): self
459: {
460: $this->assertHeadersNotSent();
461:
462: if ($value === '') {
463: $this->response = $this->response->withoutHeader($name);
464: } else {
465: $name = preg_replace_callback('~(?<![a-zA-Z])[a-z]~', static function ($matches) {
466: return strtoupper($matches[0]);
467: }, strtolower($name));
468:
469: $this->response = $this->response->withHeader($name, $value);
470: }
471:
472: return $this;
473: }
474:
475: /**
476: * Will perform a preemptive output and terminate. Do not use this
477: * directly, instead call it form Callback, JsCallback or similar
478: * other classes.
479: *
480: * @param string|StreamInterface|array $output Array type is supported only for JSON response
481: *
482: * @return never
483: */
484: public function terminate($output = ''): void
485: {
486: $type = preg_replace('~;.*~', '', strtolower($this->response->getHeaderLine('Content-Type'))); // in LC without charset
487: if ($type === '') {
488: throw new Exception('Content type must be always set');
489: }
490:
491: if ($output instanceof StreamInterface) {
492: $this->response = $this->response->withBody($output);
493: $this->outputResponse('');
494: } elseif ($type === 'application/json') {
495: if (is_string($output)) {
496: $output = $this->decodeJson($output);
497: }
498:
499: $this->outputResponseJson($output);
500: } elseif ($this->hasRequestQueryParam('__atk_tab') && $type === 'text/html') {
501: $output = $this->getTag('script', [], '$(function () {' . $output['atkjs'] . '});')
502: . $output['html'];
503:
504: $this->outputResponseHtml($output);
505: } elseif ($type === 'text/html') {
506: $this->outputResponseHtml($output);
507: } else {
508: $this->outputResponse($output);
509: }
510:
511: $this->runCalled = true; // prevent shutdown function from triggering
512: $this->callExit();
513: }
514:
515: /**
516: * @param string|array|View|HtmlTemplate $output
517: *
518: * @return never
519: */
520: public function terminateHtml($output): void
521: {
522: if ($output instanceof View) {
523: $output = $output->render();
524: } elseif ($output instanceof HtmlTemplate) {
525: $output = $output->renderToHtml();
526: }
527:
528: $this->setResponseHeader('Content-Type', 'text/html');
529: $this->terminate($output);
530: }
531:
532: /**
533: * @param string|array|View $output
534: *
535: * @return never
536: */
537: public function terminateJson($output): void
538: {
539: if ($output instanceof View) {
540: $output = $output->renderToJsonArr();
541: }
542:
543: $this->setResponseHeader('Content-Type', 'application/json');
544: $this->terminate($output);
545: }
546:
547: /**
548: * Initializes layout.
549: *
550: * @param Layout|array $seed
551: *
552: * @return $this
553: */
554: public function initLayout($seed)
555: {
556: $layout = Layout::fromSeed($seed);
557: $layout->setApp($this);
558:
559: if ($this->html === null) {
560: $this->html = new View(['defaultTemplate' => 'html.html']);
561: $this->html->setApp($this);
562: $this->html->invokeInit();
563: }
564:
565: $this->layout = $this->html->add($layout); // @phpstan-ignore-line
566:
567: $this->initIncludes();
568:
569: return $this;
570: }
571:
572: /**
573: * Initialize JS and CSS includes.
574: */
575: public function initIncludes(): void
576: {
577: $minified = !file_exists(__DIR__ . '/../.git');
578:
579: // jQuery
580: $this->requireJs($this->cdn['jquery'] . '/jquery' . ($minified ? '.min' : '') . '.js');
581:
582: // Fomantic-UI
583: $this->requireJs($this->cdn['fomantic-ui'] . '/semantic' . ($minified ? '.min' : '') . '.js');
584: $this->requireCss($this->cdn['fomantic-ui'] . '/semantic' . ($minified ? '.min' : '') . '.css');
585:
586: // flatpickr - TODO should be load only when needed
587: // needs https://github.com/atk4/ui/issues/1875
588: $this->requireJs($this->cdn['flatpickr'] . '/flatpickr' . ($minified ? '.min' : '') . '.js');
589: $this->requireCss($this->cdn['flatpickr'] . '/flatpickr' . ($minified ? '.min' : '') . '.css');
590: if ($this->uiPersistence->locale !== 'en') {
591: $this->requireJs($this->cdn['flatpickr'] . '/l10n/' . $this->uiPersistence->locale . '.js');
592: $this->html->js(true, new JsExpression('flatpickr.localize(window.flatpickr.l10ns.' . $this->uiPersistence->locale . ')'));
593: }
594:
595: // Agile UI
596: $this->requireJs($this->cdn['atk'] . '/js/atkjs-ui' . ($minified ? '.min' : '') . '.js');
597: $this->requireCss($this->cdn['atk'] . '/css/agileui.min.css');
598:
599: // set JS bundle dynamic loading path
600: $this->html->template->dangerouslySetHtml(
601: 'InitJsBundle',
602: (new JsExpression('window.__atkBundlePublicPath = [];', [$this->cdn['atk']]))->jsRender()
603: );
604: }
605:
606: /**
607: * Adds a <style> block to the HTML Header. Not escaped. Try to avoid
608: * and use file include instead.
609: *
610: * @param string $style CSS rules, like ".foo { background: red }".
611: */
612: public function addStyle($style): void
613: {
614: $this->html->template->dangerouslyAppendHtml('Head', $this->getTag('style', [], $style));
615: }
616:
617: /**
618: * Add a new object into the app. You will need to have Layout first.
619: *
620: * @param AbstractView $object
621: * @param string|array|null $region
622: *
623: * @return ($object is View ? View : AbstractView)
624: */
625: public function add($object, $region = null): AbstractView
626: {
627: if (!$this->layout) { // @phpstan-ignore-line
628: throw (new Exception('App layout is missing'))
629: ->addSolution('$app->initLayout() must be called first');
630: }
631:
632: return $this->layout->add($object, $region);
633: }
634:
635: /**
636: * Runs app and echo rendered template.
637: */
638: public function run(): void
639: {
640: $isExitException = false;
641: try {
642: $this->runCalled = true;
643: $this->hook(self::HOOK_BEFORE_RENDER);
644: $this->isRendering = true;
645:
646: $this->html->template->set('title', $this->title);
647: $this->html->renderAll();
648: $this->html->template->dangerouslyAppendHtml('Head', $this->getTag('script', [], '$(function () {' . $this->html->getJs() . ';});'));
649: $this->isRendering = false;
650:
651: if ($this->hasRequestQueryParam(Callback::URL_QUERY_TARGET) && $this->catchRunawayCallbacks) {
652: throw (new Exception('Callback requested, but never reached. You may be missing some arguments in request URL.'))
653: ->addMoreInfo('callback', $this->getRequestQueryParam(Callback::URL_QUERY_TARGET));
654: }
655:
656: $output = $this->html->template->renderToHtml();
657: } catch (ExitApplicationError $e) {
658: $output = '';
659: $isExitException = true;
660: }
661:
662: if (!$this->exitCalled) { // output already sent by terminate()
663: if ($this->isJsUrlRequest()) {
664: $this->outputResponseJson($output);
665: } else {
666: $this->outputResponseHtml($output);
667: }
668: }
669:
670: if ($isExitException) {
671: $this->callExit();
672: }
673: }
674:
675: /**
676: * Load template by template file name.
677: *
678: * @return HtmlTemplate
679: */
680: public function loadTemplate(string $filename)
681: {
682: $template = new $this->templateClass();
683: $template->setApp($this);
684:
685: if ((['.' => true, '/' => true, '\\' => true][substr($filename, 0, 1)] ?? false) || str_contains($filename, ':\\')) {
686: return $template->loadFromFile($filename);
687: }
688:
689: $dirs = is_array($this->templateDir) ? $this->templateDir : [$this->templateDir];
690: foreach ($dirs as $dir) {
691: $t = $template->tryLoadFromFile($dir . '/' . $filename);
692: if ($t !== false) {
693: return $t;
694: }
695: }
696:
697: throw (new Exception('Cannot find template file'))
698: ->addMoreInfo('filename', $filename)
699: ->addMoreInfo('templateDir', $this->templateDir);
700: }
701:
702: protected function createRequestPathFromLocalPath(string $localPath): string
703: {
704: // $localPath does not need realpath() as the path is expected to be built using __DIR__
705: // which has symlinks resolved
706:
707: static $requestUrlPath = null;
708: static $requestLocalPath = null;
709: if ($requestUrlPath === null) {
710: if (\PHP_SAPI === 'cli') { // for phpunit
711: $requestUrlPath = '/';
712: $requestLocalPath = \Closure::bind(static function () {
713: return (new ExceptionRenderer\Html(new \Exception()))->getVendorDirectory();
714: }, null, ExceptionRenderer\Html::class)();
715: } else {
716: $request = new \Symfony\Component\HttpFoundation\Request([], [], [], [], [], $_SERVER);
717: $requestUrlPath = $request->getBasePath();
718: $requestLocalPath = realpath($request->server->get('SCRIPT_FILENAME'));
719: }
720: }
721: $fs = new \Symfony\Component\Filesystem\Filesystem();
722: $localPathRelative = $fs->makePathRelative($localPath, dirname($requestLocalPath));
723: $res = '/' . $fs->makePathRelative($requestUrlPath . '/' . $localPathRelative, '/');
724: // fix https://github.com/symfony/symfony/pull/40051
725: if (str_ends_with($res, '/') && !str_ends_with($localPath, '/')) {
726: $res = substr($res, 0, -1);
727: }
728:
729: return $res;
730: }
731:
732: /**
733: * Make current get argument with specified name automatically appended to all generated URLs.
734: */
735: public function stickyGet(string $name, bool $isDeleting = false): ?string
736: {
737: $this->stickyGetArguments[$name] = !$isDeleting;
738:
739: return $this->tryGetRequestQueryParam($name);
740: }
741:
742: /**
743: * Remove sticky GET which was set by stickyGet.
744: */
745: public function stickyForget(string $name): void
746: {
747: unset($this->stickyGetArguments[$name]);
748: }
749:
750: /**
751: * Build a URL that application can use for loading HTML data.
752: *
753: * @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
754: * @param array<string, string> $extraRequestUrlArgs additional URL arguments, deleting sticky can delete them
755: */
756: public function url($page = [], array $extraRequestUrlArgs = []): string
757: {
758: if (is_string($page)) {
759: $pageExploded = explode('?', $page, 2);
760: parse_str($pageExploded[1] ?? '', $page);
761: $pagePath = $pageExploded[0] !== '' ? $pageExploded[0] : null;
762: } else {
763: $pagePath = $page[0] ?? null;
764: unset($page[0]);
765: }
766:
767: $request = $this->getRequest();
768:
769: if ($pagePath === null) {
770: $pagePath = $request->getUri()->getPath();
771: }
772: if (str_ends_with($pagePath, '/')) {
773: $pagePath .= $this->urlBuildingIndexPage;
774: }
775: if (!str_ends_with($pagePath, '/') && !str_contains(basename($pagePath), '.')) {
776: $pagePath .= $this->urlBuildingExt;
777: }
778:
779: $args = $extraRequestUrlArgs;
780:
781: // add sticky arguments
782: $requestQueryParams = $request->getQueryParams();
783: foreach ($this->stickyGetArguments as $k => $v) {
784: if ($v && isset($requestQueryParams[$k])) {
785: $args[$k] = $requestQueryParams[$k];
786: } else {
787: unset($args[$k]);
788: }
789: }
790:
791: // add arguments
792: foreach ($page as $k => $v) {
793: if ($v === false) {
794: unset($args[$k]);
795: } else {
796: $args[$k] = $v;
797: }
798: }
799:
800: $pageQuery = http_build_query($args, '', '&', \PHP_QUERY_RFC3986);
801:
802: return $pagePath . ($pageQuery !== '' ? '?' . $pageQuery : '');
803: }
804:
805: /**
806: * Build a URL that application can use for JS callbacks. Some framework integration will use a different routing
807: * mechanism for non-HTML response.
808: *
809: * @param string|array<0|string, string|int|false> $page URL as string or array with page path as first element and other GET arguments
810: * @param array<string, string> $extraRequestUrlArgs additional URL arguments, deleting sticky can delete them
811: */
812: public function jsUrl($page = [], array $extraRequestUrlArgs = []): string
813: {
814: // append to the end but allow override
815: $extraRequestUrlArgs = array_merge($extraRequestUrlArgs, ['__atk_json' => 1], $extraRequestUrlArgs);
816:
817: return $this->url($page, $extraRequestUrlArgs);
818: }
819:
820: /**
821: * Request was made using App::jsUrl().
822: */
823: public function isJsUrlRequest(): bool
824: {
825: return $this->hasRequestQueryParam('__atk_json') && $this->getRequestQueryParam('__atk_json') !== '0';
826: }
827:
828: /**
829: * Adds additional JS script include in application template.
830: *
831: * @param string $url
832: * @param bool $isAsync whether or not you want Async loading
833: * @param bool $isDefer whether or not you want Defer loading
834: *
835: * @return $this
836: */
837: public function requireJs($url, $isAsync = false, $isDefer = false)
838: {
839: $this->html->template->dangerouslyAppendHtml('Head', $this->getTag('script', ['src' => $url, 'defer' => $isDefer, 'async' => $isAsync], '') . "\n");
840:
841: return $this;
842: }
843:
844: /**
845: * Adds additional CSS stylesheet include in application template.
846: *
847: * @param string $url
848: *
849: * @return $this
850: */
851: public function requireCss($url)
852: {
853: $this->html->template->dangerouslyAppendHtml('Head', $this->getTag('link/', ['rel' => 'stylesheet', 'type' => 'text/css', 'href' => $url]) . "\n");
854:
855: return $this;
856: }
857:
858: /**
859: * A convenient wrapper for sending user to another page.
860: *
861: * @param string|array<0|string, string|int|false> $page
862: */
863: public function redirect($page, bool $permanent = false): void
864: {
865: $this->setResponseStatusCode($permanent ? 301 : 302);
866: $this->setResponseHeader('location', $this->url($page));
867: $this->terminateHtml('');
868: }
869:
870: /**
871: * Generate action for redirecting user to another page.
872: *
873: * @param string|array<0|string, string|int|false> $page
874: */
875: public function jsRedirect($page, bool $newWindow = false): JsExpressionable
876: {
877: return new JsExpression('window.open([], [])', [$this->url($page), $newWindow ? '_blank' : '_top']);
878: }
879:
880: public function isVoidTag(string $tag): bool
881: {
882: return [
883: 'area' => true, 'base' => true, 'br' => true, 'col' => true, 'embed' => true,
884: 'hr' => true, 'img' => true, 'input' => true, 'link' => true, 'meta' => true,
885: 'param' => true, 'source' => true, 'track' => true, 'wbr' => true,
886: ][strtolower($tag)] ?? false;
887: }
888:
889: /**
890: * Construct HTML tag with supplied attributes.
891: *
892: * $html = getTag('img/', ['src' => 'foo.gif', 'border' => 0])
893: * --> "<img src="foo.gif" border="0">"
894: *
895: *
896: * The following rules are respected:
897: *
898: * 1. all array key => val elements appear as attributes with value escaped.
899: * getTag('input/', ['value' => 'he"llo'])
900: * --> <input value="he&quot;llo">
901: *
902: * 2. true value will add attribute without value
903: * getTag('td', ['nowrap' => true])
904: * --> <td nowrap="nowrap">
905: *
906: * 3. false value will ignore the attribute
907: * getTag('img/', ['src' => false])
908: * --> <img>
909: *
910: * 4. passing key 0 => "val" will re-define the element itself
911: * getTag('div', ['a', 'href' => 'picture'])
912: * --> <a href="picture">
913: *
914: * 5. use '/' at end of tag to self-close it (self closing slash is not rendered because of HTML5 void tag)
915: * getTag('img/', ['src' => 'foo.gif'])
916: * --> <img src="foo.gif">
917: *
918: * 6. if main tag is self-closing, overriding it keeps it self-closing
919: * getTag('img/', ['input', 'type' => 'picture'])
920: * --> <input type="picture">
921: *
922: * 7. simple way to close tag. Any attributes to closing tags are ignored
923: * getTag('/td')
924: * --> </td>
925: *
926: * 7b. except for 0 => 'newtag'
927: * getTag('/td', ['th', 'align' => 'left'])
928: * --> </th>
929: *
930: * 8. using $value will add value inside tag. It will also encode value.
931: * getTag('a', ['href' => 'foo.html'], 'click here >>')
932: * --> <a href="foo.html">click here &gt;&gt;</a>
933: *
934: * 9. pass array as 3rd parameter to nest tags. Each element can be either string (inserted as-is) or
935: * array (passed to getTag recursively)
936: * getTag('a', ['href' => 'foo.html'], [['b', 'click here'], ' for fun'])
937: * --> <a href="foo.html"><b>click here</b> for fun</a>
938: *
939: * 10. extended example:
940: * getTag('a', ['href' => 'hello'], [
941: * ['b', 'class' => 'red', [
942: * ['i', 'class' => 'blue', 'welcome']
943: * ]]
944: * ])
945: * --> <a href="hello"><b class="red"><i class="blue">welcome</i></b></a>'
946: *
947: * @param array<0|string, string|bool> $attr
948: * @param string|array<int, array{0: string, 1?: array<0|string, string|bool>, 2?: string|array|null}|string>|null $value
949: */
950: public function getTag(string $tag, array $attr = [], $value = null): string
951: {
952: $tag = strtolower($tag);
953: $tagOrig = $tag;
954:
955: $isOpening = true;
956: $isClosing = false;
957: if (substr($tag, 0, 1) === '/') {
958: $tag = substr($tag, 1);
959: $isOpening = false;
960: $isClosing = true;
961: } elseif (substr($tag, -1) === '/') {
962: $tag = substr($tag, 0, -1);
963: $isClosing = true;
964: }
965:
966: $isVoid = $this->isVoidTag($tag);
967: if ($isVoid
968: ? $isOpening && !$isClosing || !$isOpening || $value !== null
969: : $isOpening && $isClosing
970: ) {
971: throw (new Exception('Wrong void tag usage'))
972: ->addMoreInfo('tag', $tagOrig)
973: ->addMoreInfo('isVoid', $isVoid);
974: }
975:
976: if (isset($attr[0])) {
977: if ($isClosing) {
978: if ($isOpening) {
979: $tag = $attr[0] . '/';
980: } else {
981: $tag = '/' . $attr[0];
982: }
983: } else {
984: $tag = $attr[0];
985: }
986: unset($attr[0]);
987:
988: return $this->getTag($tag, $attr, $value);
989: }
990:
991: if ($value !== null) {
992: $result = [];
993: foreach (is_scalar($value) ? [$value] : $value as $v) {
994: if (is_array($v)) {
995: $result[] = $this->getTag(...$v);
996: } elseif (['script' => true, 'style' => true][$tag] ?? false) {
997: if ($tag === 'script' && $v !== '') {
998: $result[] = '\'use strict\'; ';
999: }
1000: // see https://mathiasbynens.be/notes/etago
1001: $result[] = preg_replace('~(?<=<)(?=/\s*' . preg_quote($tag, '~') . '|!--)~', '\\\\', $v);
1002: } elseif (is_array($value)) { // todo, remove later and fix wrong usages, this is the original behaviour, only directly passed strings were escaped
1003: $result[] = $v;
1004: } else {
1005: $result[] = $this->encodeHtml($v);
1006: }
1007: }
1008:
1009: $value = implode('', $result);
1010: }
1011:
1012: $tmp = [];
1013: foreach ($attr as $key => $val) {
1014: if ($val === false) {
1015: continue;
1016: }
1017:
1018: if ($val === true) {
1019: $val = $key;
1020: }
1021:
1022: $val = (string) $val;
1023: $tmp[] = $key . '="' . $this->encodeHtml($val) . '"';
1024: }
1025:
1026: if ($isClosing && !$isOpening) {
1027: return '</' . $tag . '>';
1028: }
1029:
1030: return '<' . $tag . ($tmp !== [] ? ' ' . implode(' ', $tmp) : '') . ($isClosing && !$isVoid ? ' /' : '') . '>'
1031: . ($value !== null ? $value . '</' . $tag . '>' : '');
1032: }
1033:
1034: /**
1035: * Encodes string - convert all applicable chars to HTML entities.
1036: */
1037: public function encodeHtml(string $value): string
1038: {
1039: return htmlspecialchars($value, \ENT_HTML5 | \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
1040: }
1041:
1042: /**
1043: * @return mixed
1044: */
1045: public function decodeJson(string $json)
1046: {
1047: $data = json_decode($json, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR);
1048:
1049: return $data;
1050: }
1051:
1052: /**
1053: * @param mixed $data
1054: */
1055: public function encodeJson($data, bool $forceObject = false): string
1056: {
1057: if (is_array($data) || is_object($data)) {
1058: $checkNoObjectFx = static function ($v) {
1059: if (is_object($v)) {
1060: throw (new Exception('Object to JSON encode is not supported'))
1061: ->addMoreInfo('value', $v);
1062: }
1063: };
1064:
1065: if (is_object($data)) {
1066: $checkNoObjectFx($data);
1067: } else {
1068: array_walk_recursive($data, $checkNoObjectFx);
1069: }
1070: }
1071:
1072: $options = \JSON_UNESCAPED_SLASHES | \JSON_PRESERVE_ZERO_FRACTION | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT;
1073: if ($forceObject) {
1074: $options |= \JSON_FORCE_OBJECT;
1075: }
1076:
1077: $json = json_encode($data, $options | \JSON_THROW_ON_ERROR, 512);
1078:
1079: // IMPORTANT: always convert large integers to string, otherwise numbers can be rounded by JS
1080: // replace large JSON integers only, do not replace anything in JSON/JS strings
1081: $json = preg_replace_callback('~"(?:[^"\\\\]+|\\\\.)*+"\K|\'(?:[^\'\\\\]+|\\\\.)*+\'\K'
1082: . '|(?:^|[{\[,:])[ \n\r\t]*\K-?[1-9]\d{15,}(?=[ \n\r\t]*(?:$|[}\],:]))~s', static function ($matches) {
1083: if ($matches[0] === '' || abs((int) $matches[0]) < (2 ** 53)) {
1084: return $matches[0];
1085: }
1086:
1087: return '"' . $matches[0] . '"';
1088: }, $json);
1089:
1090: return $json;
1091: }
1092:
1093: /**
1094: * Return exception message using HTML block and Fomantic-UI formatting. It's your job
1095: * to put it inside boilerplate HTML and output, e.g:.
1096: *
1097: * $app = new App();
1098: * $app->initLayout([Layout\Centered::class]);
1099: * $app->layout->template->dangerouslySetHtml('Content', $e->getHtml());
1100: * $app->run();
1101: * $app->callBeforeExit();
1102: */
1103: public function renderExceptionHtml(\Throwable $exception): string
1104: {
1105: return (string) new ExceptionRenderer\Html($exception);
1106: }
1107:
1108: protected function setupAlwaysRun(): void
1109: {
1110: register_shutdown_function(function () {
1111: if (!$this->runCalled) {
1112: try {
1113: $this->run();
1114: } catch (ExitApplicationError $e) {
1115: // let the process continue and terminate using self::callExit() below
1116: } catch (\Throwable $e) {
1117: // set_exception_handler does not work in shutdown
1118: // https://github.com/php/php-src/issues/10695
1119: $this->caughtException($e);
1120: }
1121:
1122: $this->callBeforeExit();
1123: }
1124: });
1125: }
1126:
1127: /**
1128: * @internal should be called only from self::outputResponse() and self::outputLateOutputError()
1129: */
1130: protected function emitResponse(): void
1131: {
1132: if (http_response_code() !== 500 || $this->response->getStatusCode() !== 500 || $this->response->getHeaders() !== []) { // avoid throwing late error in loop
1133: http_response_code($this->response->getStatusCode());
1134: header_remove('Content-Type');
1135: header_remove('Cache-Control');
1136: }
1137:
1138: foreach ($this->response->getHeaders() as $name => $values) {
1139: foreach ($values as $value) {
1140: header($name . ': ' . $value, false);
1141: }
1142: }
1143:
1144: $stream = $this->response->getBody();
1145: if ($stream->isSeekable()) {
1146: $stream->rewind();
1147: }
1148:
1149: // for streaming response
1150: if (!$stream->isReadable()) {
1151: return;
1152: }
1153:
1154: while (!$stream->eof()) {
1155: echo $stream->read(16 * 1024);
1156: }
1157: }
1158:
1159: protected function outputResponse(string $data): void
1160: {
1161: foreach (ob_get_status(true) as $status) {
1162: if ($status['buffer_used'] !== 0) {
1163: $lateError = new LateOutputError('Unexpected output detected');
1164: if ($this->catchExceptions) {
1165: $this->caughtException($lateError);
1166: $this->outputLateOutputError($lateError);
1167: }
1168:
1169: throw $lateError;
1170: }
1171: }
1172:
1173: $this->assertHeadersNotSent();
1174:
1175: // TODO hack for SSE
1176: // https://github.com/atk4/ui/pull/1706#discussion_r757819527
1177: if (headers_sent() && $this->response->getHeaderLine('Content-Type') === 'text/event-stream') {
1178: echo $data;
1179:
1180: return;
1181: }
1182:
1183: if ($data !== '') {
1184: $this->response->getBody()->write($data);
1185: }
1186:
1187: $this->emitResponse();
1188: }
1189:
1190: /**
1191: * @return never
1192: */
1193: protected function outputLateOutputError(LateOutputError $exception): void
1194: {
1195: $this->response = $this->response->withStatus(500);
1196:
1197: // late error means headers were already sent to the client, so remove all response headers,
1198: // to avoid throwing late error in loop
1199: foreach (array_keys($this->response->getHeaders()) as $name) {
1200: $this->response = $this->response->withoutHeader($name);
1201: }
1202:
1203: $this->response = $this->response->withBody((new Psr17Factory())->createStream("\n"
1204: . '!! FATAL UI ERROR: ' . $exception->getMessage() . ' !!'
1205: . "\n"));
1206: $this->emitResponse();
1207:
1208: $this->runCalled = true; // prevent shutdown function from triggering
1209:
1210: exit(1); // should be never reached from phpunit because we set catchExceptions = false
1211: }
1212:
1213: /**
1214: * Output HTML response to the client.
1215: */
1216: private function outputResponseHtml(string $data): void
1217: {
1218: $this->setResponseHeader('Content-Type', 'text/html');
1219: $this->outputResponse($data);
1220: }
1221:
1222: /**
1223: * @param string|array $data
1224: */
1225: private function outputResponseJson($data): void
1226: {
1227: if (!is_string($data)) {
1228: $data = $this->encodeJson($data);
1229: }
1230:
1231: $this->setResponseHeader('Content-Type', 'application/json');
1232: $this->outputResponse($data);
1233: }
1234: }
1235: