Correct Aphlict websocket URI construction after PHP8 compatibility changes
[phabricator.git] / src / view / page / PhabricatorStandardPageView.php
blob8949d7ae60c6394a046c90640a652366ca2f6049
1 <?php
3 /**
4 * This is a standard Phabricator page with menus, Javelin, DarkConsole, and
5 * basic styles.
6 */
7 final class PhabricatorStandardPageView extends PhabricatorBarePageView
8 implements AphrontResponseProducerInterface {
10 private $baseURI;
11 private $applicationName;
12 private $glyph;
13 private $menuContent;
14 private $showChrome = true;
15 private $classes = array();
16 private $disableConsole;
17 private $pageObjects = array();
18 private $applicationMenu;
19 private $showFooter = true;
20 private $showDurableColumn = true;
21 private $quicksandConfig = array();
22 private $tabs;
23 private $crumbs;
24 private $navigation;
25 private $footer;
27 public function setShowFooter($show_footer) {
28 $this->showFooter = $show_footer;
29 return $this;
32 public function getShowFooter() {
33 return $this->showFooter;
36 public function setApplicationName($application_name) {
37 $this->applicationName = $application_name;
38 return $this;
41 public function setDisableConsole($disable) {
42 $this->disableConsole = $disable;
43 return $this;
46 public function getApplicationName() {
47 return $this->applicationName;
50 public function setBaseURI($base_uri) {
51 $this->baseURI = $base_uri;
52 return $this;
55 public function getBaseURI() {
56 return $this->baseURI;
59 public function setShowChrome($show_chrome) {
60 $this->showChrome = $show_chrome;
61 return $this;
64 public function getShowChrome() {
65 return $this->showChrome;
68 public function addClass($class) {
69 $this->classes[] = $class;
70 return $this;
73 public function setPageObjectPHIDs(array $phids) {
74 $this->pageObjects = $phids;
75 return $this;
78 public function setShowDurableColumn($show) {
79 $this->showDurableColumn = $show;
80 return $this;
83 public function getShowDurableColumn() {
84 $request = $this->getRequest();
85 if (!$request) {
86 return false;
89 $viewer = $request->getUser();
90 if (!$viewer->isLoggedIn()) {
91 return false;
94 $conpherence_installed = PhabricatorApplication::isClassInstalledForViewer(
95 'PhabricatorConpherenceApplication',
96 $viewer);
97 if (!$conpherence_installed) {
98 return false;
101 if ($this->isQuicksandBlacklistURI()) {
102 return false;
105 return true;
108 private function isQuicksandBlacklistURI() {
109 $request = $this->getRequest();
110 if (!$request) {
111 return false;
114 $patterns = $this->getQuicksandURIPatternBlacklist();
115 $path = $request->getRequestURI()->getPath();
116 foreach ($patterns as $pattern) {
117 if (preg_match('(^'.$pattern.'$)', $path)) {
118 return true;
121 return false;
124 public function getDurableColumnVisible() {
125 $column_key = PhabricatorConpherenceColumnVisibleSetting::SETTINGKEY;
126 return (bool)$this->getUserPreference($column_key, false);
129 public function getDurableColumnMinimize() {
130 $column_key = PhabricatorConpherenceColumnMinimizeSetting::SETTINGKEY;
131 return (bool)$this->getUserPreference($column_key, false);
134 public function addQuicksandConfig(array $config) {
135 $this->quicksandConfig = $config + $this->quicksandConfig;
136 return $this;
139 public function getQuicksandConfig() {
140 return $this->quicksandConfig;
143 public function setCrumbs(PHUICrumbsView $crumbs) {
144 $this->crumbs = $crumbs;
145 return $this;
148 public function getCrumbs() {
149 return $this->crumbs;
152 public function setTabs(PHUIListView $tabs) {
153 $tabs->setType(PHUIListView::TABBAR_LIST);
154 $tabs->addClass('phabricator-standard-page-tabs');
155 $this->tabs = $tabs;
156 return $this;
159 public function getTabs() {
160 return $this->tabs;
163 public function setNavigation(AphrontSideNavFilterView $navigation) {
164 $this->navigation = $navigation;
165 return $this;
168 public function getNavigation() {
169 return $this->navigation;
172 public function getTitle() {
173 $glyph_key = PhabricatorTitleGlyphsSetting::SETTINGKEY;
174 $glyph_on = PhabricatorTitleGlyphsSetting::VALUE_TITLE_GLYPHS;
175 $glyph_setting = $this->getUserPreference($glyph_key, $glyph_on);
177 $use_glyph = ($glyph_setting == $glyph_on);
179 $title = parent::getTitle();
181 $prefix = null;
182 if ($use_glyph) {
183 $prefix = $this->getGlyph();
184 } else {
185 $application_name = $this->getApplicationName();
186 if (strlen($application_name)) {
187 $prefix = '['.$application_name.']';
191 if ($prefix !== null && strlen($prefix)) {
192 $title = $prefix.' '.$title;
195 return $title;
199 protected function willRenderPage() {
200 $footer = $this->renderFooter();
202 // NOTE: A cleaner solution would be to let body layout elements implement
203 // some kind of "LayoutInterface" so content can be embedded inside frames,
204 // but there's only really one use case for this for now.
205 $children = $this->renderChildren();
206 if ($children) {
207 $layout = head($children);
208 if ($layout instanceof PHUIFormationView) {
209 $layout->setFooter($footer);
210 $footer = null;
214 $this->footer = $footer;
216 parent::willRenderPage();
218 if (!$this->getRequest()) {
219 throw new Exception(
220 pht(
221 'You must set the %s to render a %s.',
222 'Request',
223 __CLASS__));
226 $console = $this->getConsole();
228 require_celerity_resource('phabricator-core-css');
229 require_celerity_resource('phabricator-zindex-css');
230 require_celerity_resource('phui-button-css');
231 require_celerity_resource('phui-spacing-css');
232 require_celerity_resource('phui-form-css');
233 require_celerity_resource('phabricator-standard-page-view');
234 require_celerity_resource('conpherence-durable-column-view');
235 require_celerity_resource('font-lato');
237 Javelin::initBehavior('workflow', array());
239 $request = $this->getRequest();
240 $user = null;
241 if ($request) {
242 $user = $request->getUser();
245 if ($user) {
246 if ($user->isUserActivated()) {
247 $offset = $user->getTimeZoneOffset();
249 $ignore_key = PhabricatorTimezoneIgnoreOffsetSetting::SETTINGKEY;
250 $ignore = $user->getUserSetting($ignore_key);
252 Javelin::initBehavior(
253 'detect-timezone',
254 array(
255 'offset' => $offset,
256 'uri' => '/settings/timezone/',
257 'message' => pht(
258 'Your browser timezone setting differs from the timezone '.
259 'setting in your profile, click to reconcile.'),
260 'ignoreKey' => $ignore_key,
261 'ignore' => $ignore,
264 if ($user->getIsAdmin()) {
265 $server_https = $request->isHTTPS();
266 $server_protocol = $server_https ? 'HTTPS' : 'HTTP';
267 $client_protocol = $server_https ? 'HTTP' : 'HTTPS';
269 $doc_name = 'Configuring a Preamble Script';
270 $doc_href = PhabricatorEnv::getDoclink($doc_name);
272 Javelin::initBehavior(
273 'setup-check-https',
274 array(
275 'server_https' => $server_https,
276 'doc_name' => pht('See Documentation'),
277 'doc_href' => $doc_href,
278 'message' => pht(
279 'This server thinks you are using %s, but your '.
280 'client is convinced that it is using %s. This is a serious '.
281 'misconfiguration with subtle, but significant, consequences.',
282 $server_protocol, $client_protocol),
287 Javelin::initBehavior('lightbox-attachments');
290 Javelin::initBehavior('aphront-form-disable-on-submit');
291 Javelin::initBehavior('toggle-class', array());
292 Javelin::initBehavior('history-install');
293 Javelin::initBehavior('phabricator-gesture');
295 $current_token = null;
296 if ($user) {
297 $current_token = $user->getCSRFToken();
300 Javelin::initBehavior(
301 'refresh-csrf',
302 array(
303 'tokenName' => AphrontRequest::getCSRFTokenName(),
304 'header' => AphrontRequest::getCSRFHeaderName(),
305 'viaHeader' => AphrontRequest::getViaHeaderName(),
306 'current' => $current_token,
309 Javelin::initBehavior('device');
311 Javelin::initBehavior(
312 'high-security-warning',
313 $this->getHighSecurityWarningConfig());
315 if (PhabricatorEnv::isReadOnly()) {
316 Javelin::initBehavior(
317 'read-only-warning',
318 array(
319 'message' => PhabricatorEnv::getReadOnlyMessage(),
320 'uri' => PhabricatorEnv::getReadOnlyURI(),
324 // If we aren't showing the page chrome, skip rendering DarkConsole and the
325 // main menu, since they won't be visible on the page.
326 if (!$this->getShowChrome()) {
327 return;
330 if ($console) {
331 require_celerity_resource('aphront-dark-console-css');
333 $headers = array();
334 if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
335 $headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
337 if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
338 $headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
341 Javelin::initBehavior(
342 'dark-console',
343 $this->getConsoleConfig());
346 if ($user) {
347 $viewer = $user;
348 } else {
349 $viewer = new PhabricatorUser();
352 $menu = id(new PhabricatorMainMenuView())
353 ->setUser($viewer);
355 if ($this->getController()) {
356 $menu->setController($this->getController());
359 $application_menu = $this->applicationMenu;
360 if ($application_menu) {
361 if ($application_menu instanceof PHUIApplicationMenuView) {
362 $crumbs = $this->getCrumbs();
363 if ($crumbs) {
364 $application_menu->setCrumbs($crumbs);
367 $application_menu = $application_menu->buildListView();
370 $menu->setApplicationMenu($application_menu);
374 $this->menuContent = $menu->render();
378 protected function getHead() {
379 $monospaced = null;
381 $request = $this->getRequest();
382 if ($request) {
383 $user = $request->getUser();
384 if ($user) {
385 $monospaced = $user->getUserSetting(
386 PhabricatorMonospacedFontSetting::SETTINGKEY);
390 $response = CelerityAPI::getStaticResourceResponse();
392 $font_css = null;
393 if (!empty($monospaced)) {
394 // We can't print this normally because escaping quotation marks will
395 // break the CSS. Instead, filter it strictly and then mark it as safe.
396 $monospaced = new PhutilSafeHTML(
397 PhabricatorMonospacedFontSetting::filterMonospacedCSSRule(
398 $monospaced));
400 $font_css = hsprintf(
401 '<style type="text/css">'.
402 '.PhabricatorMonospaced, '.
403 '.phabricator-remarkup .remarkup-code-block '.
404 '.remarkup-code { font: %s !important; } '.
405 '</style>',
406 $monospaced);
409 return hsprintf(
410 '%s%s%s',
411 parent::getHead(),
412 $font_css,
413 $response->renderSingleResource('javelin-magical-init', 'phabricator'));
416 public function setGlyph($glyph) {
417 $this->glyph = $glyph;
418 return $this;
421 public function getGlyph() {
422 return $this->glyph;
425 protected function willSendResponse($response) {
426 $request = $this->getRequest();
427 $response = parent::willSendResponse($response);
429 $console = $request->getApplicationConfiguration()->getConsole();
431 if ($console) {
432 $response = PhutilSafeHTML::applyFunction(
433 'str_replace',
434 hsprintf('<darkconsole />'),
435 $console->render($request),
436 $response);
439 return $response;
442 protected function getBody() {
443 $user = null;
444 $request = $this->getRequest();
445 if ($request) {
446 $user = $request->getUser();
449 $header_chrome = null;
450 if ($this->getShowChrome()) {
451 $header_chrome = $this->menuContent;
454 $classes = array();
455 $classes[] = 'main-page-frame';
456 $developer_warning = null;
457 if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode') &&
458 DarkConsoleErrorLogPluginAPI::getErrors()) {
459 $developer_warning = phutil_tag_div(
460 'aphront-developer-error-callout',
461 pht(
462 'This page raised PHP errors. Find them in DarkConsole '.
463 'or the error log.'));
466 $main_page = phutil_tag(
467 'div',
468 array(
469 'id' => 'phabricator-standard-page',
470 'class' => 'phabricator-standard-page',
472 array(
473 $developer_warning,
474 $header_chrome,
475 phutil_tag(
476 'div',
477 array(
478 'id' => 'phabricator-standard-page-body',
479 'class' => 'phabricator-standard-page-body',
481 $this->renderPageBodyContent()),
484 $durable_column = null;
485 if ($this->getShowDurableColumn()) {
486 $is_visible = $this->getDurableColumnVisible();
487 $is_minimize = $this->getDurableColumnMinimize();
488 $durable_column = id(new ConpherenceDurableColumnView())
489 ->setSelectedConpherence(null)
490 ->setUser($user)
491 ->setQuicksandConfig($this->buildQuicksandConfig())
492 ->setVisible($is_visible)
493 ->setMinimize($is_minimize)
494 ->setInitialLoad(true);
495 if ($is_minimize) {
496 $this->classes[] = 'minimize-column';
500 Javelin::initBehavior('quicksand-blacklist', array(
501 'patterns' => $this->getQuicksandURIPatternBlacklist(),
504 return phutil_tag(
505 'div',
506 array(
507 'class' => implode(' ', $classes),
508 'id' => 'main-page-frame',
510 array(
511 $main_page,
512 $durable_column,
516 private function renderPageBodyContent() {
517 $console = $this->getConsole();
519 $body = parent::getBody();
521 $nav = $this->getNavigation();
522 $tabs = $this->getTabs();
523 if ($nav) {
524 $crumbs = $this->getCrumbs();
525 if ($crumbs) {
526 $nav->setCrumbs($crumbs);
528 $nav->appendChild($body);
529 $nav->appendFooter($this->footer);
530 $content = phutil_implode_html('', array($nav->render()));
531 } else {
532 $content = array();
534 $crumbs = $this->getCrumbs();
535 if ($crumbs) {
536 if ($this->getTabs()) {
537 $crumbs->setBorder(true);
539 $content[] = $crumbs;
542 $tabs = $this->getTabs();
543 if ($tabs) {
544 $content[] = $tabs;
547 $content[] = $body;
548 $content[] = $this->footer;
550 $content = phutil_implode_html('', $content);
553 return array(
554 ($console ? hsprintf('<darkconsole />') : null),
555 $content,
559 protected function getTail() {
560 $request = $this->getRequest();
561 $user = $request->getUser();
563 $tail = array(
564 parent::getTail(),
567 $response = CelerityAPI::getStaticResourceResponse();
569 if ($request->isHTTPS()) {
570 $with_protocol = 'https';
571 } else {
572 $with_protocol = 'http';
575 $servers = PhabricatorNotificationServerRef::getEnabledClientServers(
576 $with_protocol);
578 if ($servers) {
579 if ($user && $user->isLoggedIn()) {
580 // TODO: We could tell the browser about all the servers and let it
581 // do random reconnects to improve reliability.
582 shuffle($servers);
583 $server = head($servers);
585 $client_uri = $server->getWebsocketURI();
587 Javelin::initBehavior(
588 'aphlict-listen',
589 array(
590 'websocketURI' => (string)$client_uri,
591 ) + $this->buildAphlictListenConfigData());
593 CelerityAPI::getStaticResourceResponse()
594 ->addContentSecurityPolicyURI('connect-src', $client_uri);
598 $tail[] = $response->renderHTMLFooter($this->getFrameable());
600 return $tail;
603 protected function getBodyClasses() {
604 $classes = array();
606 if (!$this->getShowChrome()) {
607 $classes[] = 'phabricator-chromeless-page';
610 $agent = AphrontRequest::getHTTPHeader('User-Agent');
612 // Try to guess the device resolution based on UA strings to avoid a flash
613 // of incorrectly-styled content.
614 $device_guess = 'device-desktop';
615 if (preg_match('@iPhone|iPod|(Android.*Chrome/[.0-9]* Mobile)@', $agent)) {
616 $device_guess = 'device-phone device';
617 } else if (preg_match('@iPad|(Android.*Chrome/)@', $agent)) {
618 $device_guess = 'device-tablet device';
621 $classes[] = $device_guess;
623 if (preg_match('@Windows@', $agent)) {
624 $classes[] = 'platform-windows';
625 } else if (preg_match('@Macintosh@', $agent)) {
626 $classes[] = 'platform-mac';
627 } else if (preg_match('@X11@', $agent)) {
628 $classes[] = 'platform-linux';
631 if ($this->getRequest()->getStr('__print__')) {
632 $classes[] = 'printable';
635 if ($this->getRequest()->getStr('__aural__')) {
636 $classes[] = 'audible';
639 $classes[] = 'phui-theme-'.PhabricatorEnv::getEnvConfig('ui.header-color');
640 foreach ($this->classes as $class) {
641 $classes[] = $class;
644 return implode(' ', $classes);
647 private function getConsole() {
648 if ($this->disableConsole) {
649 return null;
651 return $this->getRequest()->getApplicationConfiguration()->getConsole();
654 private function getConsoleConfig() {
655 $user = $this->getRequest()->getUser();
657 $headers = array();
658 if (DarkConsoleXHProfPluginAPI::isProfilerStarted()) {
659 $headers[DarkConsoleXHProfPluginAPI::getProfilerHeader()] = 'page';
661 if (DarkConsoleServicesPlugin::isQueryAnalyzerRequested()) {
662 $headers[DarkConsoleServicesPlugin::getQueryAnalyzerHeader()] = true;
665 if ($user) {
666 $setting_tab = PhabricatorDarkConsoleTabSetting::SETTINGKEY;
667 $setting_visible = PhabricatorDarkConsoleVisibleSetting::SETTINGKEY;
668 $tab = $user->getUserSetting($setting_tab);
669 $visible = $user->getUserSetting($setting_visible);
670 } else {
671 $tab = null;
672 $visible = true;
675 return array(
676 // NOTE: We use a generic label here to prevent input reflection
677 // and mitigate compression attacks like BREACH. See discussion in
678 // T3684.
679 'uri' => pht('Main Request'),
680 'selected' => $tab,
681 'visible' => $visible,
682 'headers' => $headers,
686 private function getHighSecurityWarningConfig() {
687 $user = $this->getRequest()->getUser();
689 $show = false;
690 if ($user->hasSession()) {
691 $hisec = ($user->getSession()->getHighSecurityUntil() - time());
692 if ($hisec > 0) {
693 $show = true;
697 return array(
698 'show' => $show,
699 'uri' => '/auth/session/downgrade/',
700 'message' => pht(
701 'Your session is in high security mode. When you '.
702 'finish using it, click here to leave.'),
706 private function renderFooter() {
707 if (!$this->getShowChrome()) {
708 return null;
711 if (!$this->getShowFooter()) {
712 return null;
715 $items = PhabricatorEnv::getEnvConfig('ui.footer-items');
716 if (!$items) {
717 return null;
720 $foot = array();
721 foreach ($items as $item) {
722 $name = idx($item, 'name', pht('Unnamed Footer Item'));
724 $href = idx($item, 'href');
725 if (!PhabricatorEnv::isValidURIForLink($href)) {
726 $href = null;
729 if ($href !== null) {
730 $tag = 'a';
731 } else {
732 $tag = 'span';
735 $foot[] = phutil_tag(
736 $tag,
737 array(
738 'href' => $href,
740 $name);
742 $foot = phutil_implode_html(" \xC2\xB7 ", $foot);
744 return phutil_tag(
745 'div',
746 array(
747 'class' => 'phabricator-standard-page-footer grouped',
749 $foot);
752 public function renderForQuicksand() {
753 parent::willRenderPage();
754 $response = $this->renderPageBodyContent();
755 $response = $this->willSendResponse($response);
757 $extra_config = $this->getQuicksandConfig();
759 return array(
760 'content' => hsprintf('%s', $response),
761 ) + $this->buildQuicksandConfig()
762 + $extra_config;
765 private function buildQuicksandConfig() {
766 $viewer = $this->getRequest()->getUser();
767 $controller = $this->getController();
769 $dropdown_query = id(new AphlictDropdownDataQuery())
770 ->setViewer($viewer);
771 $dropdown_query->execute();
773 $hisec_warning_config = $this->getHighSecurityWarningConfig();
775 $console_config = null;
776 $console = $this->getConsole();
777 if ($console) {
778 $console_config = $this->getConsoleConfig();
781 $upload_enabled = false;
782 if ($controller) {
783 $upload_enabled = $controller->isGlobalDragAndDropUploadEnabled();
786 $application_class = null;
787 $application_search_icon = null;
788 $application_help = null;
789 $controller = $this->getController();
790 if ($controller) {
791 $application = $controller->getCurrentApplication();
792 if ($application) {
793 $application_class = get_class($application);
794 if ($application->getApplicationSearchDocumentTypes()) {
795 $application_search_icon = $application->getIcon();
798 $help_items = $application->getHelpMenuItems($viewer);
799 if ($help_items) {
800 $help_list = id(new PhabricatorActionListView())
801 ->setViewer($viewer);
802 foreach ($help_items as $help_item) {
803 $help_list->addAction($help_item);
805 $application_help = $help_list->getDropdownMenuMetadata();
810 return array(
811 'title' => $this->getTitle(),
812 'bodyClasses' => $this->getBodyClasses(),
813 'aphlictDropdownData' => array(
814 $dropdown_query->getNotificationData(),
815 $dropdown_query->getConpherenceData(),
817 'globalDragAndDrop' => $upload_enabled,
818 'hisecWarningConfig' => $hisec_warning_config,
819 'consoleConfig' => $console_config,
820 'applicationClass' => $application_class,
821 'applicationSearchIcon' => $application_search_icon,
822 'helpItems' => $application_help,
823 ) + $this->buildAphlictListenConfigData();
826 private function buildAphlictListenConfigData() {
827 $user = $this->getRequest()->getUser();
828 $subscriptions = $this->pageObjects;
829 $subscriptions[] = $user->getPHID();
831 return array(
832 'pageObjects' => array_fill_keys($this->pageObjects, true),
833 'subscriptions' => $subscriptions,
837 private function getQuicksandURIPatternBlacklist() {
838 $applications = PhabricatorApplication::getAllApplications();
840 $blacklist = array();
841 foreach ($applications as $application) {
842 $blacklist[] = $application->getQuicksandURIPatternBlacklist();
845 // See T4340. Currently, Phortune and Auth both require pulling in external
846 // Javascript (for Stripe card management and Recaptcha, respectively).
847 // This can put us in a position where the user loads a page with a
848 // restrictive Content-Security-Policy, then uses Quicksand to navigate to
849 // a page which needs to load external scripts. For now, just blacklist
850 // these entire applications since we aren't giving up anything
851 // significant by doing so.
853 $blacklist[] = array(
854 '/phortune/.*',
855 '/auth/.*',
858 return array_mergev($blacklist);
861 private function getUserPreference($key, $default = null) {
862 $request = $this->getRequest();
863 if (!$request) {
864 return $default;
867 $user = $request->getUser();
868 if (!$user) {
869 return $default;
872 return $user->getUserSetting($key);
875 public function produceAphrontResponse() {
876 $controller = $this->getController();
878 $viewer = $this->getUser();
879 if ($viewer && $viewer->getPHID()) {
880 $object_phids = $this->pageObjects;
881 foreach ($object_phids as $object_phid) {
882 PhabricatorFeedStoryNotification::updateObjectNotificationViews(
883 $viewer,
884 $object_phid);
888 if ($this->getRequest()->isQuicksand()) {
889 $content = $this->renderForQuicksand();
890 $response = id(new AphrontAjaxResponse())
891 ->setContent($content);
892 } else {
893 // See T13247. Try to find some navigational menu items to create a
894 // mobile navigation menu from.
895 $application_menu = $controller->buildApplicationMenu();
896 if (!$application_menu) {
897 $navigation = $this->getNavigation();
898 if ($navigation) {
899 $application_menu = $navigation->getMenu();
902 $this->applicationMenu = $application_menu;
904 $content = $this->render();
906 $response = id(new AphrontWebpageResponse())
907 ->setContent($content)
908 ->setFrameable($this->getFrameable());
911 return $response;