diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -15,12 +15,75 @@ 'AASTTree' => 'parser/aast/api/AASTTree.php', 'AbstractDirectedGraph' => 'utils/AbstractDirectedGraph.php', 'AbstractDirectedGraphTestCase' => 'utils/__tests__/AbstractDirectedGraphTestCase.php', + 'Aphront304Response' => 'aphront/response/Aphront304Response.php', + 'Aphront400Response' => 'aphront/response/Aphront400Response.php', + 'Aphront403Response' => 'aphront/response/Aphront403Response.php', + 'Aphront404Response' => 'aphront/response/Aphront404Response.php', + 'AphrontAbstractAttachedFileView' => 'aphront/view/control/AphrontAbstractAttachedFileView.php', + 'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php', + 'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php', + 'AphrontBarView' => 'aphront/view/widget/bars/AphrontBarView.php', + 'AphrontCSRFException' => 'aphront/exception/AphrontCSRFException.php', + 'AphrontContextBarView' => 'aphront/view/layout/AphrontContextBarView.php', + 'AphrontController' => 'aphront/AphrontController.php', + 'AphrontCursorPagerView' => 'aphront/view/control/AphrontCursorPagerView.php', 'AphrontDatabaseConnection' => 'aphront/storage/connection/AphrontDatabaseConnection.php', 'AphrontDatabaseTransactionState' => 'aphront/storage/connection/AphrontDatabaseTransactionState.php', + 'AphrontDefaultApplicationConfiguration' => 'aphront/configuration/AphrontDefaultApplicationConfiguration.php', + 'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php', + 'AphrontDialogView' => 'aphront/view/AphrontDialogView.php', + 'AphrontErrorView' => 'aphront/view/form/AphrontErrorView.php', + 'AphrontException' => 'aphront/exception/AphrontException.php', + 'AphrontFileResponse' => 'aphront/response/AphrontFileResponse.php', + 'AphrontFormCheckboxControl' => 'aphront/view/form/control/AphrontFormCheckboxControl.php', + 'AphrontFormChooseButtonControl' => 'aphront/view/form/control/AphrontFormChooseButtonControl.php', + 'AphrontFormControl' => 'aphront/view/form/control/AphrontFormControl.php', + 'AphrontFormCropControl' => 'aphront/view/form/control/AphrontFormCropControl.php', + 'AphrontFormDateControl' => 'aphront/view/form/control/AphrontFormDateControl.php', + 'AphrontFormDividerControl' => 'aphront/view/form/control/AphrontFormDividerControl.php', + 'AphrontFormFileControl' => 'aphront/view/form/control/AphrontFormFileControl.php', + 'AphrontFormImageControl' => 'aphront/view/form/control/AphrontFormImageControl.php', + 'AphrontFormInsetView' => 'aphront/view/form/AphrontFormInsetView.php', + 'AphrontFormMarkupControl' => 'aphront/view/form/control/AphrontFormMarkupControl.php', + 'AphrontFormPasswordControl' => 'aphront/view/form/control/AphrontFormPasswordControl.php', + 'AphrontFormPolicyControl' => 'aphront/view/form/control/AphrontFormPolicyControl.php', + 'AphrontFormRadioButtonControl' => 'aphront/view/form/control/AphrontFormRadioButtonControl.php', + 'AphrontFormRecaptchaControl' => 'aphront/view/form/control/AphrontFormRecaptchaControl.php', + 'AphrontFormSectionControl' => 'aphront/view/form/control/AphrontFormSectionControl.php', + 'AphrontFormSelectControl' => 'aphront/view/form/control/AphrontFormSelectControl.php', + 'AphrontFormStaticControl' => 'aphront/view/form/control/AphrontFormStaticControl.php', + 'AphrontFormSubmitControl' => 'aphront/view/form/control/AphrontFormSubmitControl.php', + 'AphrontFormTextAreaControl' => 'aphront/view/form/control/AphrontFormTextAreaControl.php', + 'AphrontFormTextControl' => 'aphront/view/form/control/AphrontFormTextControl.php', + 'AphrontFormTextWithSubmitControl' => 'aphront/view/form/control/AphrontFormTextWithSubmitControl.php', + 'AphrontFormToggleButtonsControl' => 'aphront/view/form/control/AphrontFormToggleButtonsControl.php', + 'AphrontFormTokenizerControl' => 'aphront/view/form/control/AphrontFormTokenizerControl.php', + 'AphrontFormTypeaheadControl' => 'aphront/view/form/control/AphrontFormTypeaheadControl.php', + 'AphrontFormView' => 'aphront/view/form/AphrontFormView.php', + 'AphrontGlyphBarView' => 'aphront/view/widget/bars/AphrontGlyphBarView.php', + 'AphrontHTMLResponse' => 'aphront/response/AphrontHTMLResponse.php', + 'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php', + 'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php', 'AphrontIsolatedDatabaseConnection' => 'aphront/storage/connection/AphrontIsolatedDatabaseConnection.php', + 'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php', + 'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php', + 'AphrontJavelinView' => 'aphront/view/AphrontJavelinView.php', + 'AphrontKeyboardShortcutsAvailableView' => 'aphront/view/widget/AphrontKeyboardShortcutsAvailableView.php', + 'AphrontListFilterView' => 'aphront/view/layout/AphrontListFilterView.php', + 'AphrontMiniPanelView' => 'aphront/view/layout/AphrontMiniPanelView.php', + 'AphrontMoreView' => 'aphront/view/layout/AphrontMoreView.php', + 'AphrontMultiColumnView' => 'aphront/view/layout/AphrontMultiColumnView.php', 'AphrontMySQLDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnection.php', 'AphrontMySQLDatabaseConnectionBase' => 'aphront/storage/connection/mysql/AphrontMySQLDatabaseConnectionBase.php', 'AphrontMySQLiDatabaseConnection' => 'aphront/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php', + 'AphrontNullView' => 'aphront/view/AphrontNullView.php', + 'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php', + 'AphrontPageView' => 'aphront/view/page/AphrontPageView.php', + 'AphrontPagerView' => 'aphront/view/control/AphrontPagerView.php', + 'AphrontPanelView' => 'aphront/view/layout/AphrontPanelView.php', + 'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php', + 'AphrontProgressBarView' => 'aphront/view/widget/bars/AphrontProgressBarView.php', + 'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php', 'AphrontQueryAccessDeniedException' => 'aphront/storage/exception/AphrontQueryAccessDeniedException.php', 'AphrontQueryCharacterSetException' => 'aphront/storage/exception/AphrontQueryCharacterSetException.php', 'AphrontQueryConnectionException' => 'aphront/storage/exception/AphrontQueryConnectionException.php', @@ -34,7 +97,24 @@ 'AphrontQueryParameterException' => 'aphront/storage/exception/AphrontQueryParameterException.php', 'AphrontQueryRecoverableException' => 'aphront/storage/exception/AphrontQueryRecoverableException.php', 'AphrontQuerySchemaException' => 'aphront/storage/exception/AphrontQuerySchemaException.php', + 'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php', + 'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php', + 'AphrontRequest' => 'aphront/AphrontRequest.php', + 'AphrontRequestFailureView' => 'aphront/view/page/AphrontRequestFailureView.php', + 'AphrontRequestTestCase' => 'aphront/__tests__/AphrontRequestTestCase.php', + 'AphrontResponse' => 'aphront/response/AphrontResponse.php', 'AphrontScopedUnguardedWriteCapability' => 'aphront/writeguard/AphrontScopedUnguardedWriteCapability.php', + 'AphrontSideNavFilterView' => 'aphront/view/layout/AphrontSideNavFilterView.php', + 'AphrontStackTraceView' => 'aphront/view/widget/AphrontStackTraceView.php', + 'AphrontTableView' => 'aphront/view/control/AphrontTableView.php', + 'AphrontTagView' => 'aphront/view/AphrontTagView.php', + 'AphrontTokenizerTemplateView' => 'aphront/view/control/AphrontTokenizerTemplateView.php', + 'AphrontTwoColumnView' => 'aphront/view/layout/AphrontTwoColumnView.php', + 'AphrontTypeaheadTemplateView' => 'aphront/view/control/AphrontTypeaheadTemplateView.php', + 'AphrontURIMapper' => 'aphront/AphrontURIMapper.php', + 'AphrontUsageException' => 'aphront/exception/AphrontUsageException.php', + 'AphrontView' => 'aphront/view/AphrontView.php', + 'AphrontWebpageResponse' => 'aphront/response/AphrontWebpageResponse.php', 'AphrontWriteGuard' => 'aphront/writeguard/AphrontWriteGuard.php', 'AphrontWriteGuardExitEventListener' => 'aphront/writeguard/event/AphrontWriteGuardExitEventListener.php', 'BaseHTTPFuture' => 'future/http/BaseHTTPFuture.php', @@ -70,6 +150,45 @@ 'LinesOfALargeFileTestCase' => 'filesystem/linesofalarge/__tests__/LinesOfALargeFileTestCase.php', 'MFilterTestHelper' => 'utils/__tests__/MFilterTestHelper.php', 'PHPASTParserTestCase' => 'parser/xhpast/__tests__/PHPASTParserTestCase.php', + 'PHUI' => 'aphront/view/phui/PHUI.php', + 'PHUIActionHeaderView' => 'aphront/view/phui/PHUIActionHeaderView.php', + 'PHUIBoxView' => 'aphront/view/phui/PHUIBoxView.php', + 'PHUIButtonBarView' => 'aphront/view/phui/PHUIButtonBarView.php', + 'PHUIButtonView' => 'aphront/view/phui/PHUIButtonView.php', + 'PHUICalendarListView' => 'aphront/view/phui/calendar/PHUICalendarListView.php', + 'PHUICalendarMonthView' => 'aphront/view/phui/calendar/PHUICalendarMonthView.php', + 'PHUICalendarWidgetView' => 'aphront/view/phui/calendar/PHUICalendarWidgetView.php', + 'PHUIDocumentView' => 'aphront/view/phui/PHUIDocumentView.php', + 'PHUIFeedStoryView' => 'aphront/view/phui/PHUIFeedStoryView.php', + 'PHUIFormDividerControl' => 'aphront/view/form/control/PHUIFormDividerControl.php', + 'PHUIFormFreeformDateControl' => 'aphront/view/form/control/PHUIFormFreeformDateControl.php', + 'PHUIFormLayoutView' => 'aphront/view/form/PHUIFormLayoutView.php', + 'PHUIFormMultiSubmitControl' => 'aphront/view/form/control/PHUIFormMultiSubmitControl.php', + 'PHUIFormPageView' => 'aphront/view/form/PHUIFormPageView.php', + 'PHUIHeaderView' => 'aphront/view/phui/PHUIHeaderView.php', + 'PHUIIconView' => 'aphront/view/phui/PHUIIconView.php', + 'PHUIImageMaskView' => 'aphront/view/phui/PHUIImageMaskView.php', + 'PHUIInfoPanelView' => 'aphront/view/phui/PHUIInfoPanelView.php', + 'PHUIListItemView' => 'aphront/view/phui/PHUIListItemView.php', + 'PHUIListView' => 'aphront/view/phui/PHUIListView.php', + 'PHUIListViewTestCase' => 'aphront/view/layout/__tests__/PHUIListViewTestCase.php', + 'PHUIObjectBoxView' => 'aphront/view/phui/PHUIObjectBoxView.php', + 'PHUIObjectItemListView' => 'aphront/view/phui/PHUIObjectItemListView.php', + 'PHUIObjectItemView' => 'aphront/view/phui/PHUIObjectItemView.php', + 'PHUIPagedFormView' => 'aphront/view/form/PHUIPagedFormView.php', + 'PHUIPinboardItemView' => 'aphront/view/phui/PHUIPinboardItemView.php', + 'PHUIPinboardView' => 'aphront/view/phui/PHUIPinboardView.php', + 'PHUIPropertyGroupView' => 'aphront/view/phui/PHUIPropertyGroupView.php', + 'PHUIPropertyListView' => 'aphront/view/phui/PHUIPropertyListView.php', + 'PHUIRemarkupPreviewPanel' => 'aphront/view/phui/PHUIRemarkupPreviewPanel.php', + 'PHUIStatusItemView' => 'aphront/view/phui/PHUIStatusItemView.php', + 'PHUIStatusListView' => 'aphront/view/phui/PHUIStatusListView.php', + 'PHUITagView' => 'aphront/view/phui/PHUITagView.php', + 'PHUITextView' => 'aphront/view/phui/PHUITextView.php', + 'PHUITimelineEventView' => 'aphront/view/phui/PHUITimelineEventView.php', + 'PHUITimelineView' => 'aphront/view/phui/PHUITimelineView.php', + 'PHUIWorkboardView' => 'aphront/view/phui/PHUIWorkboardView.php', + 'PHUIWorkpanelView' => 'aphront/view/phui/PHUIWorkpanelView.php', 'PhageAgentBootloader' => 'phage/bootloader/PhageAgentBootloader.php', 'PhageAgentTestCase' => 'phage/__tests__/PhageAgentTestCase.php', 'PhagePHPAgent' => 'phage/agent/PhagePHPAgent.php', @@ -469,11 +588,72 @@ 1 => 'Countable', ), 'AbstractDirectedGraphTestCase' => 'PhutilTestCase', + 'Aphront304Response' => 'AphrontResponse', + 'Aphront400Response' => 'AphrontResponse', + 'Aphront403Response' => 'AphrontHTMLResponse', + 'Aphront404Response' => 'AphrontHTMLResponse', + 'AphrontAbstractAttachedFileView' => 'AphrontView', + 'AphrontAjaxResponse' => 'AphrontResponse', + 'AphrontBarView' => 'AphrontView', + 'AphrontCSRFException' => 'AphrontException', + 'AphrontContextBarView' => 'AphrontView', + 'AphrontController' => 'Phobject', + 'AphrontCursorPagerView' => 'AphrontView', 'AphrontDatabaseConnection' => 'PhutilQsprintfInterface', + 'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration', + 'AphrontDialogResponse' => 'AphrontResponse', + 'AphrontDialogView' => 'AphrontView', + 'AphrontErrorView' => 'AphrontView', + 'AphrontException' => 'Exception', + 'AphrontFileResponse' => 'AphrontResponse', + 'AphrontFormCheckboxControl' => 'AphrontFormControl', + 'AphrontFormChooseButtonControl' => 'AphrontFormControl', + 'AphrontFormControl' => 'AphrontView', + 'AphrontFormCropControl' => 'AphrontFormControl', + 'AphrontFormDateControl' => 'AphrontFormControl', + 'AphrontFormDividerControl' => 'AphrontFormControl', + 'AphrontFormFileControl' => 'AphrontFormControl', + 'AphrontFormImageControl' => 'AphrontFormControl', + 'AphrontFormInsetView' => 'AphrontView', + 'AphrontFormMarkupControl' => 'AphrontFormControl', + 'AphrontFormPasswordControl' => 'AphrontFormControl', + 'AphrontFormPolicyControl' => 'AphrontFormControl', + 'AphrontFormRadioButtonControl' => 'AphrontFormControl', + 'AphrontFormRecaptchaControl' => 'AphrontFormControl', + 'AphrontFormSectionControl' => 'AphrontFormControl', + 'AphrontFormSelectControl' => 'AphrontFormControl', + 'AphrontFormStaticControl' => 'AphrontFormControl', + 'AphrontFormSubmitControl' => 'AphrontFormControl', + 'AphrontFormTextAreaControl' => 'AphrontFormControl', + 'AphrontFormTextControl' => 'AphrontFormControl', + 'AphrontFormTextWithSubmitControl' => 'AphrontFormControl', + 'AphrontFormToggleButtonsControl' => 'AphrontFormControl', + 'AphrontFormTokenizerControl' => 'AphrontFormControl', + 'AphrontFormTypeaheadControl' => 'AphrontFormControl', + 'AphrontFormView' => 'AphrontView', + 'AphrontGlyphBarView' => 'AphrontBarView', + 'AphrontHTMLResponse' => 'AphrontResponse', + 'AphrontHTTPSinkTestCase' => 'PhutilTestCase', 'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection', + 'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink', + 'AphrontJSONResponse' => 'AphrontResponse', + 'AphrontJavelinView' => 'AphrontView', + 'AphrontKeyboardShortcutsAvailableView' => 'AphrontView', + 'AphrontListFilterView' => 'AphrontView', + 'AphrontMiniPanelView' => 'AphrontView', + 'AphrontMoreView' => 'AphrontView', + 'AphrontMultiColumnView' => 'AphrontView', 'AphrontMySQLDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase', 'AphrontMySQLDatabaseConnectionBase' => 'AphrontDatabaseConnection', 'AphrontMySQLiDatabaseConnection' => 'AphrontMySQLDatabaseConnectionBase', + 'AphrontNullView' => 'AphrontView', + 'AphrontPHPHTTPSink' => 'AphrontHTTPSink', + 'AphrontPageView' => 'AphrontView', + 'AphrontPagerView' => 'AphrontView', + 'AphrontPanelView' => 'AphrontView', + 'AphrontPlainTextResponse' => 'AphrontResponse', + 'AphrontProgressBarView' => 'AphrontBarView', + 'AphrontProxyResponse' => 'AphrontResponse', 'AphrontQueryAccessDeniedException' => 'AphrontQueryRecoverableException', 'AphrontQueryCharacterSetException' => 'AphrontQueryException', 'AphrontQueryConnectionException' => 'AphrontQueryException', @@ -487,6 +667,23 @@ 'AphrontQueryParameterException' => 'AphrontQueryException', 'AphrontQueryRecoverableException' => 'AphrontQueryException', 'AphrontQuerySchemaException' => 'AphrontQueryException', + 'AphrontRedirectResponse' => 'AphrontResponse', + 'AphrontReloadResponse' => 'AphrontRedirectResponse', + 'AphrontRequestFailureView' => 'AphrontView', + 'AphrontRequestTestCase' => 'PhutilTestCase', + 'AphrontSideNavFilterView' => 'AphrontView', + 'AphrontStackTraceView' => 'AphrontView', + 'AphrontTableView' => 'AphrontView', + 'AphrontTagView' => 'AphrontView', + 'AphrontTokenizerTemplateView' => 'AphrontView', + 'AphrontTwoColumnView' => 'AphrontView', + 'AphrontTypeaheadTemplateView' => 'AphrontView', + 'AphrontUsageException' => 'AphrontException', + 'AphrontView' => array( + 0 => 'Phobject', + 1 => 'PhutilSafeHTMLProducerInterface', + ), + 'AphrontWebpageResponse' => 'AphrontHTMLResponse', 'AphrontWriteGuardExitEventListener' => 'PhutilEventListener', 'BaseHTTPFuture' => 'Future', 'CommandException' => 'Exception', @@ -515,6 +712,44 @@ 'LinesOfALargeFile' => 'LinesOfALarge', 'LinesOfALargeFileTestCase' => 'PhutilTestCase', 'PHPASTParserTestCase' => 'PhutilTestCase', + 'PHUIActionHeaderView' => 'AphrontView', + 'PHUIBoxView' => 'AphrontTagView', + 'PHUIButtonBarView' => 'AphrontTagView', + 'PHUIButtonView' => 'AphrontTagView', + 'PHUICalendarListView' => 'AphrontTagView', + 'PHUICalendarMonthView' => 'AphrontView', + 'PHUICalendarWidgetView' => 'AphrontTagView', + 'PHUIDocumentView' => 'AphrontTagView', + 'PHUIFeedStoryView' => 'AphrontView', + 'PHUIFormDividerControl' => 'AphrontFormControl', + 'PHUIFormFreeformDateControl' => 'AphrontFormControl', + 'PHUIFormLayoutView' => 'AphrontView', + 'PHUIFormMultiSubmitControl' => 'AphrontFormControl', + 'PHUIFormPageView' => 'AphrontView', + 'PHUIHeaderView' => 'AphrontView', + 'PHUIIconView' => 'AphrontTagView', + 'PHUIImageMaskView' => 'AphrontTagView', + 'PHUIInfoPanelView' => 'AphrontView', + 'PHUIListItemView' => 'AphrontTagView', + 'PHUIListView' => 'AphrontTagView', + 'PHUIListViewTestCase' => 'PhutilTestCase', + 'PHUIObjectBoxView' => 'AphrontView', + 'PHUIObjectItemListView' => 'AphrontTagView', + 'PHUIObjectItemView' => 'AphrontTagView', + 'PHUIPagedFormView' => 'AphrontTagView', + 'PHUIPinboardItemView' => 'AphrontView', + 'PHUIPinboardView' => 'AphrontView', + 'PHUIPropertyGroupView' => 'AphrontTagView', + 'PHUIPropertyListView' => 'AphrontView', + 'PHUIRemarkupPreviewPanel' => 'AphrontTagView', + 'PHUIStatusItemView' => 'AphrontTagView', + 'PHUIStatusListView' => 'AphrontTagView', + 'PHUITagView' => 'AphrontTagView', + 'PHUITextView' => 'AphrontTagView', + 'PHUITimelineEventView' => 'AphrontView', + 'PHUITimelineView' => 'AphrontView', + 'PHUIWorkboardView' => 'AphrontTagView', + 'PHUIWorkpanelView' => 'AphrontTagView', 'PhageAgentTestCase' => 'PhutilTestCase', 'PhagePHPAgentBootloader' => 'PhageAgentBootloader', 'Phobject' => 'Iterator', diff --git a/src/aphront/AphrontController.php b/src/aphront/AphrontController.php new file mode 100644 --- /dev/null +++ b/src/aphront/AphrontController.php @@ -0,0 +1,83 @@ +delegatingController = $delegating_controller; + return $this; + } + + public function getDelegatingController() { + return $this->delegatingController; + } + + public function willBeginExecution() { + return; + } + + public function willProcessRequest(array $uri_data) { + return; + } + + public function didProcessRequest($response) { + return $response; + } + + abstract public function processRequest(); + + final public function __construct(AphrontRequest $request) { + $this->request = $request; + } + + final public function getRequest() { + return $this->request; + } + + final public function delegateToController(AphrontController $controller) { + $controller->setDelegatingController($this); + + $application = $this->getCurrentApplication(); + if ($application) { + $controller->setCurrentApplication($application); + } + + return $controller->processRequest(); + } + + final public function setCurrentApplication( + PhabricatorApplication $current_application) { + + $this->currentApplication = $current_application; + return $this; + } + + final public function getCurrentApplication() { + return $this->currentApplication; + } + + public function getDefaultResourceSource() { + throw new Exception( + pht( + 'A Controller must implement getDefaultResourceSource() before you '. + 'can invoke requireResource() or initBehavior().')); + } + + public function requireResource($symbol) { + $response = CelerityAPI::getStaticResourceResponse(); + $response->requireResource($symbol, $this->getDefaultResourceSource()); + return $this; + } + + public function initBehavior($name, $config = array()) { + Javelin::initBehavior( + $name, + $config, + $this->getDefaultResourceSource()); + } + +} diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php new file mode 100644 --- /dev/null +++ b/src/aphront/AphrontRequest.php @@ -0,0 +1,611 @@ +host = $host; + $this->path = $path; + } + + final public function setApplicationConfiguration( + $application_configuration) { + $this->applicationConfiguration = $application_configuration; + return $this; + } + + final public function getApplicationConfiguration() { + return $this->applicationConfiguration; + } + + final public function setPath($path) { + $this->path = $path; + return $this; + } + + final public function getPath() { + return $this->path; + } + + final public function getHost() { + // The "Host" header may include a port number, or may be a malicious + // header in the form "realdomain.com:ignored@evil.com". Invoke the full + // parser to extract the real domain correctly. See here for coverage of + // a similar issue in Django: + // + // https://www.djangoproject.com/weblog/2012/oct/17/security/ + $uri = new PhutilURI('http://'.$this->host); + return $uri->getDomain(); + } + + +/* -( Accessing Request Data )--------------------------------------------- */ + + + /** + * @task data + */ + final public function setRequestData(array $request_data) { + $this->requestData = $request_data; + return $this; + } + + + /** + * @task data + */ + final public function getRequestData() { + return $this->requestData; + } + + + /** + * @task data + */ + final public function getInt($name, $default = null) { + if (isset($this->requestData[$name])) { + return (int)$this->requestData[$name]; + } else { + return $default; + } + } + + + /** + * @task data + */ + final public function getBool($name, $default = null) { + if (isset($this->requestData[$name])) { + if ($this->requestData[$name] === 'true') { + return true; + } else if ($this->requestData[$name] === 'false') { + return false; + } else { + return (bool)$this->requestData[$name]; + } + } else { + return $default; + } + } + + + /** + * @task data + */ + final public function getStr($name, $default = null) { + if (isset($this->requestData[$name])) { + $str = (string)$this->requestData[$name]; + // Normalize newline craziness. + $str = str_replace( + array("\r\n", "\r"), + array("\n", "\n"), + $str); + return $str; + } else { + return $default; + } + } + + + /** + * @task data + */ + final public function getArr($name, $default = array()) { + if (isset($this->requestData[$name]) && + is_array($this->requestData[$name])) { + return $this->requestData[$name]; + } else { + return $default; + } + } + + + /** + * @task data + */ + final public function getStrList($name, $default = array()) { + if (!isset($this->requestData[$name])) { + return $default; + } + $list = $this->getStr($name); + $list = preg_split('/[\s,]+/', $list, $limit = -1, PREG_SPLIT_NO_EMPTY); + return $list; + } + + + /** + * @task data + */ + final public function getExists($name) { + return array_key_exists($name, $this->requestData); + } + + final public function getFileExists($name) { + return isset($_FILES[$name]) && + (idx($_FILES[$name], 'error') !== UPLOAD_ERR_NO_FILE); + } + + final public function isHTTPGet() { + return ($_SERVER['REQUEST_METHOD'] == 'GET'); + } + + final public function isHTTPPost() { + return ($_SERVER['REQUEST_METHOD'] == 'POST'); + } + + final public function isAjax() { + return $this->getExists(self::TYPE_AJAX); + } + + final public function isJavelinWorkflow() { + return $this->getExists(self::TYPE_WORKFLOW); + } + + final public function isConduit() { + return $this->getExists(self::TYPE_CONDUIT); + } + + public static function getCSRFTokenName() { + return '__csrf__'; + } + + public static function getCSRFHeaderName() { + return 'X-Phabricator-Csrf'; + } + + final public function validateCSRF() { + $token_name = self::getCSRFTokenName(); + $token = $this->getStr($token_name); + + // No token in the request, check the HTTP header which is added for Ajax + // requests. + if (empty($token)) { + $token = self::getHTTPHeader(self::getCSRFHeaderName()); + } + + $valid = $this->getUser()->validateCSRFToken($token); + if (!$valid) { + + // Add some diagnostic details so we can figure out if some CSRF issues + // are JS problems or people accessing Ajax URIs directly with their + // browsers. + $more_info = array(); + + if ($this->isAjax()) { + $more_info[] = pht('This was an Ajax request.'); + } else { + $more_info[] = pht('This was a Web request.'); + } + + if ($token) { + $more_info[] = pht('This request had an invalid CSRF token.'); + } else { + $more_info[] = pht('This request had no CSRF token.'); + } + + // Give a more detailed explanation of how to avoid the exception + // in developer mode. + if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { + // TODO: Clean this up, see T1921. + $more_info[] = + "To avoid this error, use phabricator_form() to construct forms. ". + "If you are already using phabricator_form(), make sure the form ". + "'action' uses a relative URI (i.e., begins with a '/'). Forms ". + "using absolute URIs do not include CSRF tokens, to prevent ". + "leaking tokens to external sites.\n\n". + "If this page performs writes which do not require CSRF ". + "protection (usually, filling caches or logging), you can use ". + "AphrontWriteGuard::beginScopedUnguardedWrites() to temporarily ". + "bypass CSRF protection while writing. You should use this only ". + "for writes which can not be protected with normal CSRF ". + "mechanisms.\n\n". + "Some UI elements (like PhabricatorActionListView) also have ". + "methods which will allow you to render links as forms (like ". + "setRenderAsForm(true))."; + } + + // This should only be able to happen if you load a form, pull your + // internet for 6 hours, and then reconnect and immediately submit, + // but give the user some indication of what happened since the workflow + // is incredibly confusing otherwise. + throw new AphrontCSRFException( + pht( + "You are trying to save some data to Phabricator, but the request ". + "your browser made included an incorrect token. Reload the page ". + "and try again. You may need to clear your cookies.\n\n%s", + implode("\n", $more_info))); + } + + return true; + } + + final public function isFormPost() { + $post = $this->getExists(self::TYPE_FORM) && + !$this->getExists(self::TYPE_HISEC) && + $this->isHTTPPost(); + + if (!$post) { + return false; + } + + return $this->validateCSRF(); + } + + final public function isFormOrHisecPost() { + $post = $this->getExists(self::TYPE_FORM) && + $this->isHTTPPost(); + + if (!$post) { + return false; + } + + return $this->validateCSRF(); + } + + + final public function setCookiePrefix($prefix) { + $this->cookiePrefix = $prefix; + return $this; + } + + final private function getPrefixedCookieName($name) { + if (strlen($this->cookiePrefix)) { + return $this->cookiePrefix.'_'.$name; + } else { + return $name; + } + } + + final public function getCookie($name, $default = null) { + $name = $this->getPrefixedCookieName($name); + $value = idx($_COOKIE, $name, $default); + + // Internally, PHP deletes cookies by setting them to the value 'deleted' + // with an expiration date in the past. + + // At least in Safari, the browser may send this cookie anyway in some + // circumstances. After logging out, the 302'd GET to /login/ consistently + // includes deleted cookies on my local install. If a cookie value is + // literally 'deleted', pretend it does not exist. + + if ($value === 'deleted') { + return null; + } + + return $value; + } + + final public function clearCookie($name) { + $name = $this->getPrefixedCookieName($name); + $this->setCookieWithExpiration($name, '', time() - (60 * 60 * 24 * 30)); + unset($_COOKIE[$name]); + } + + /** + * Get the domain which cookies should be set on for this request, or null + * if the request does not correspond to a valid cookie domain. + * + * @return PhutilURI|null Domain URI, or null if no valid domain exists. + * + * @task cookie + */ + private function getCookieDomainURI() { + if (PhabricatorEnv::getEnvConfig('security.require-https') && + !$this->isHTTPS()) { + return null; + } + + $host = $this->getHost(); + + // If there's no base domain configured, just use whatever the request + // domain is. This makes setup easier, and we'll tell administrators to + // configure a base domain during the setup process. + $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); + if (!strlen($base_uri)) { + return new PhutilURI('http://'.$host.'/'); + } + + $alternates = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris'); + $allowed_uris = array_merge( + array($base_uri), + $alternates); + + foreach ($allowed_uris as $allowed_uri) { + $uri = new PhutilURI($allowed_uri); + if ($uri->getDomain() == $host) { + return $uri; + } + } + + return null; + } + + /** + * Determine if security policy rules will allow cookies to be set when + * responding to the request. + * + * @return bool True if setCookie() will succeed. If this method returns + * false, setCookie() will throw. + * + * @task cookie + */ + final public function canSetCookies() { + return (bool)$this->getCookieDomainURI(); + } + + + /** + * Set a cookie which does not expire for a long time. + * + * To set a temporary cookie, see @{method:setTemporaryCookie}. + * + * @param string Cookie name. + * @param string Cookie value. + * @return this + * @task cookie + */ + final public function setCookie($name, $value) { + $far_future = time() + (60 * 60 * 24 * 365 * 5); + return $this->setCookieWithExpiration($name, $value, $far_future); + } + + + /** + * Set a cookie which expires soon. + * + * To set a durable cookie, see @{method:setCookie}. + * + * @param string Cookie name. + * @param string Cookie value. + * @return this + * @task cookie + */ + final public function setTemporaryCookie($name, $value) { + return $this->setCookieWithExpiration($name, $value, 0); + } + + + /** + * Set a cookie with a given expiration policy. + * + * @param string Cookie name. + * @param string Cookie value. + * @param int Epoch timestamp for cookie expiration. + * @return this + * @task cookie + */ + final private function setCookieWithExpiration( + $name, + $value, + $expire) { + + $is_secure = false; + + $base_domain_uri = $this->getCookieDomainURI(); + if (!$base_domain_uri) { + $configured_as = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); + $accessed_as = $this->getHost(); + + throw new Exception( + pht( + 'This Phabricator install is configured as "%s", but you are '. + 'using the domain name "%s" to access a page which is trying to '. + 'set a cookie. Acccess Phabricator on the configured primary '. + 'domain or a configured alternate domain. Phabricator will not '. + 'set cookies on other domains for security reasons.', + $configured_as, + $accessed_as)); + } + + $base_domain = $base_domain_uri->getDomain(); + $is_secure = ($base_domain_uri->getProtocol() == 'https'); + + $name = $this->getPrefixedCookieName($name); + + if (php_sapi_name() == 'cli') { + // Do nothing, to avoid triggering "Cannot modify header information" + // warnings. + + // TODO: This is effectively a test for whether we're running in a unit + // test or not. Move this actual call to HTTPSink? + } else { + setcookie( + $name, + $value, + $expire, + $path = '/', + $base_domain, + $is_secure, + $http_only = true); + } + + $_COOKIE[$name] = $value; + + return $this; + } + + final public function setUser($user) { + $this->user = $user; + return $this; + } + + final public function getUser() { + return $this->user; + } + + final public function getRequestURI() { + $get = $_GET; + unset($get['__path__']); + $path = phutil_escape_uri($this->getPath()); + return id(new PhutilURI($path))->setQueryParams($get); + } + + final public function isDialogFormPost() { + return $this->isFormPost() && $this->getStr('__dialog__'); + } + + final public function getRemoteAddr() { + return $_SERVER['REMOTE_ADDR']; + } + + public function isHTTPS() { + if (empty($_SERVER['HTTPS'])) { + return false; + } + if (!strcasecmp($_SERVER['HTTPS'], 'off')) { + return false; + } + return true; + } + + public function isContinueRequest() { + return $this->isFormPost() && $this->getStr('__continue__'); + } + + public function isPreviewRequest() { + return $this->isFormPost() && $this->getStr('__preview__'); + } + + /** + * Get application request parameters in a flattened form suitable for + * inclusion in an HTTP request, excluding parameters with special meanings. + * This is primarily useful if you want to ask the user for more input and + * then resubmit their request. + * + * @return dict Original request parameters. + */ + public function getPassthroughRequestParameters() { + return self::flattenData($this->getPassthroughRequestData()); + } + + /** + * Get request data other than "magic" parameters. + * + * @return dict Request data, with magic filtered out. + */ + public function getPassthroughRequestData() { + $data = $this->getRequestData(); + + // Remove magic parameters like __dialog__ and __ajax__. + foreach ($data as $key => $value) { + if (!strncmp($key, '__', 2)) { + unset($data[$key]); + } + } + + return $data; + } + + + /** + * Flatten an array of key-value pairs (possibly including arrays as values) + * into a list of key-value pairs suitable for submitting via HTTP request + * (with arrays flattened). + * + * @param dict Data to flatten. + * @return dict Flat data suitable for inclusion in an HTTP + * request. + */ + public static function flattenData(array $data) { + $result = array(); + foreach ($data as $key => $value) { + if (is_array($value)) { + foreach (self::flattenData($value) as $fkey => $fvalue) { + $fkey = '['.preg_replace('/(?=\[)|$/', ']', $fkey, $limit = 1); + $result[$key.$fkey] = $fvalue; + } + } else { + $result[$key] = (string)$value; + } + } + + ksort($result); + + return $result; + } + + + /** + * Read the value of an HTTP header from `$_SERVER`, or a similar datasource. + * + * This function accepts a canonical header name, like `"Accept-Encoding"`, + * and looks up the appropriate value in `$_SERVER` (in this case, + * `"HTTP_ACCEPT_ENCODING"`). + * + * @param string Canonical header name, like `"Accept-Encoding"`. + * @param wild Default value to return if header is not present. + * @param array? Read this instead of `$_SERVER`. + * @return string|wild Header value if present, or `$default` if not. + */ + public static function getHTTPHeader($name, $default = null, $data = null) { + // PHP mangles HTTP headers by uppercasing them and replacing hyphens with + // underscores, then prepending 'HTTP_'. + $php_index = strtoupper($name); + $php_index = str_replace('-', '_', $php_index); + + $try_names = array(); + + $try_names[] = 'HTTP_'.$php_index; + if ($php_index == 'CONTENT_TYPE' || $php_index == 'CONTENT_LENGTH') { + // These headers may be available under alternate names. See + // http://www.php.net/manual/en/reserved.variables.server.php#110763 + $try_names[] = $php_index; + } + + if ($data === null) { + $data = $_SERVER; + } + + foreach ($try_names as $try_name) { + if (array_key_exists($try_name, $data)) { + return $data[$try_name]; + } + } + + return $default; + } + +} diff --git a/src/aphront/AphrontURIMapper.php b/src/aphront/AphrontURIMapper.php new file mode 100644 --- /dev/null +++ b/src/aphront/AphrontURIMapper.php @@ -0,0 +1,50 @@ +map = $map; + } + + final public function mapPath($path) { + $map = $this->map; + foreach ($map as $rule => $value) { + list($controller, $data) = $this->tryRule($rule, $value, $path); + if ($controller) { + foreach ($data as $k => $v) { + if (is_numeric($k)) { + unset($data[$k]); + } + } + return array($controller, $data); + } + } + + return array(null, null); + } + + final private function tryRule($rule, $value, $path) { + $match = null; + $pattern = '#^'.$rule.(is_array($value) ? '' : '$').'#'; + if (!preg_match($pattern, $path, $match)) { + return array(null, null); + } + + if (!is_array($value)) { + return array($value, $match); + } + + $path = substr($path, strlen($match[0])); + foreach ($value as $srule => $sval) { + list($controller, $data) = $this->tryRule($srule, $sval, $path); + if ($controller) { + return array($controller, $data + $match); + } + } + + return array(null, null); + } + +} diff --git a/src/aphront/__tests__/AphrontRequestTestCase.php b/src/aphront/__tests__/AphrontRequestTestCase.php new file mode 100644 --- /dev/null +++ b/src/aphront/__tests__/AphrontRequestTestCase.php @@ -0,0 +1,151 @@ +setRequestData( + array( + 'str_empty' => '', + 'str' => 'derp', + 'str_true' => 'true', + 'str_false' => 'false', + + 'zero' => '0', + 'one' => '1', + + 'arr_empty' => array(), + 'arr_num' => array(1, 2, 3), + + 'comma' => ',', + 'comma_1' => 'a, b', + 'comma_2' => ' ,a ,, b ,,,, ,, ', + 'comma_3' => '0', + 'comma_4' => 'a, a, b, a', + 'comma_5' => "a\nb, c\n\nd\n\n\n,\n", + )); + + $this->assertEqual(1, $r->getInt('one')); + $this->assertEqual(0, $r->getInt('zero')); + $this->assertEqual(null, $r->getInt('does-not-exist')); + $this->assertEqual(0, $r->getInt('str_empty')); + + $this->assertEqual(true, $r->getBool('one')); + $this->assertEqual(false, $r->getBool('zero')); + $this->assertEqual(true, $r->getBool('str_true')); + $this->assertEqual(false, $r->getBool('str_false')); + $this->assertEqual(true, $r->getBool('str')); + $this->assertEqual(null, $r->getBool('does-not-exist')); + $this->assertEqual(false, $r->getBool('str_empty')); + + $this->assertEqual('derp', $r->getStr('str')); + $this->assertEqual('', $r->getStr('str_empty')); + $this->assertEqual(null, $r->getStr('does-not-exist')); + + $this->assertEqual(array(), $r->getArr('arr_empty')); + $this->assertEqual(array(1, 2, 3), $r->getArr('arr_num')); + $this->assertEqual(null, $r->getArr('str_empty', null)); + $this->assertEqual(null, $r->getArr('str_true', null)); + $this->assertEqual(null, $r->getArr('does-not-exist', null)); + $this->assertEqual(array(), $r->getArr('does-not-exist')); + + $this->assertEqual(array(), $r->getStrList('comma')); + $this->assertEqual(array('a', 'b'), $r->getStrList('comma_1')); + $this->assertEqual(array('a', 'b'), $r->getStrList('comma_2')); + $this->assertEqual(array('0'), $r->getStrList('comma_3')); + $this->assertEqual(array('a', 'a', 'b', 'a'), $r->getStrList('comma_4')); + $this->assertEqual(array('a', 'b', 'c', 'd'), $r->getStrList('comma_5')); + $this->assertEqual(array(), $r->getStrList('does-not-exist')); + $this->assertEqual(null, $r->getStrList('does-not-exist', null)); + + $this->assertEqual(true, $r->getExists('str')); + $this->assertEqual(false, $r->getExists('does-not-exist')); + } + + public function testHostAttacks() { + static $tests = array( + 'domain.com' => 'domain.com', + 'domain.com:80' => 'domain.com', + 'evil.com:evil.com@real.com' => 'real.com', + 'evil.com:evil.com@real.com:80' => 'real.com', + ); + + foreach ($tests as $input => $expect) { + $r = new AphrontRequest($input, '/'); + $this->assertEqual( + $expect, + $r->getHost(), + 'Host: '.$input); + } + } + + public function testFlattenRequestData() { + $test_cases = array( + array( + 'a' => 'a', + 'b' => '1', + 'c' => '', + ), + array( + 'a' => 'a', + 'b' => '1', + 'c' => '', + ), + + array( + 'x' => array( + 0 => 'a', + 1 => 'b', + 2 => 'c', + ), + ), + array( + 'x[0]' => 'a', + 'x[1]' => 'b', + 'x[2]' => 'c', + ), + + array( + 'x' => array( + 'y' => array( + 'z' => array( + 40 => 'A', + 50 => 'B', + 'C' => 60, + ), + ), + ), + ), + array( + 'x[y][z][40]' => 'A', + 'x[y][z][50]' => 'B', + 'x[y][z][C]' => '60', + ), + ); + + for ($ii = 0; $ii < count($test_cases); $ii += 2) { + $input = $test_cases[$ii]; + $expect = $test_cases[$ii + 1]; + + $this->assertEqual($expect, AphrontRequest::flattenData($input)); + } + } + + public function testGetHTTPHeader() { + $server_data = array( + 'HTTP_ACCEPT_ENCODING' => 'duck/quack', + 'CONTENT_TYPE' => 'cow/moo', + ); + + $this->assertEqual( + 'duck/quack', + AphrontRequest::getHTTPHeader('AcCePt-EncOdING', null, $server_data)); + $this->assertEqual( + 'cow/moo', + AphrontRequest::getHTTPHeader('cONTent-TyPE', null, $server_data)); + $this->assertEqual( + null, + AphrontRequest::getHTTPHeader('Pie-Flavor', null, $server_data)); + } + +} diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php new file mode 100644 --- /dev/null +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -0,0 +1,231 @@ +request = $request; + return $this; + } + + final public function getRequest() { + return $this->request; + } + + final public function getConsole() { + return $this->console; + } + + final public function setConsole($console) { + $this->console = $console; + return $this; + } + + final public function setHost($host) { + $this->host = $host; + return $this; + } + + final public function getHost() { + return $this->host; + } + + final public function setPath($path) { + $this->path = $path; + return $this; + } + + final public function getPath() { + return $this->path; + } + + public function willBuildRequest() { + } + + +/* -( URI Routing )-------------------------------------------------------- */ + + + /** + * Using builtin and application routes, build the appropriate + * @{class:AphrontController} class for the request. To route a request, we + * first test if the HTTP_HOST is configured as a valid Phabricator URI. If + * it isn't, we do a special check to see if it's a custom domain for a blog + * in the Phame application and if that fails we error. Otherwise, we test + * the URI against all builtin routes from @{method:getURIMap}, then against + * all application routes from installed @{class:PhabricatorApplication}s. + * + * If we match a route, we construct the controller it points at, build it, + * and return it. + * + * If we fail to match a route, but the current path is missing a trailing + * "/", we try routing the same path with a trailing "/" and do a redirect + * if that has a valid route. The idea is to canoncalize URIs for consistency, + * but avoid breaking noncanonical URIs that we can easily salvage. + * + * NOTE: We only redirect on GET. On POST, we'd drop parameters and most + * likely mutate the request implicitly, and a bad POST usually indicates a + * programming error rather than a sloppy typist. + * + * If the failing path already has a trailing "/", or we can't route the + * version with a "/", we call @{method:build404Controller}, which build a + * fallback @{class:AphrontController}. + * + * @return pair Controller and dictionary of request + * parameters. + * @task routing + */ + final public function buildController() { + $request = $this->getRequest(); + + if (PhabricatorEnv::getEnvConfig('security.require-https')) { + if (!$request->isHTTPS()) { + $https_uri = $request->getRequestURI(); + $https_uri->setDomain($request->getHost()); + $https_uri->setProtocol('https'); + return $this->buildRedirectController($https_uri); + } + } + + $path = $request->getPath(); + $host = $request->getHost(); + $base_uri = PhabricatorEnv::getEnvConfig('phabricator.base-uri'); + $prod_uri = PhabricatorEnv::getEnvConfig('phabricator.production-uri'); + $file_uri = PhabricatorEnv::getEnvConfig( + 'security.alternate-file-domain'); + $conduit_uris = PhabricatorEnv::getEnvConfig('conduit.servers'); + $allowed_uris = PhabricatorEnv::getEnvConfig('phabricator.allowed-uris'); + + $uris = array_merge( + array( + $base_uri, + $prod_uri, + $file_uri, + ), + $conduit_uris, + $allowed_uris); + + $host_match = false; + foreach ($uris as $uri) { + if ($host === id(new PhutilURI($uri))->getDomain()) { + $host_match = true; + break; + } + } + + // NOTE: If the base URI isn't defined yet, don't activate alternate + // domains. + if ($base_uri && !$host_match) { + + try { + $blog = id(new PhameBlogQuery()) + ->setViewer(new PhabricatorUser()) + ->withDomain($host) + ->executeOne(); + } catch (PhabricatorPolicyException $ex) { + throw new Exception( + 'This blog is not visible to logged out users, so it can not be '. + 'visited from a custom domain.'); + } + + if (!$blog) { + if ($prod_uri && $prod_uri != $base_uri) { + $prod_str = ' or '.$prod_uri; + } else { + $prod_str = ''; + } + throw new Exception( + 'Specified domain '.$host.' is not configured for Phabricator '. + 'requests. Please use '.$base_uri.$prod_str.' to visit this instance.' + ); + } + + // TODO: Make this more flexible and modular so any application can + // do crazy stuff here if it wants. + + $path = '/phame/live/'.$blog->getID().'/'.$path; + } + + list($controller, $uri_data) = $this->buildControllerForPath($path); + if (!$controller) { + if (!preg_match('@/$@', $path)) { + // If we failed to match anything but don't have a trailing slash, try + // to add a trailing slash and issue a redirect if that resolves. + list($controller, $uri_data) = $this->buildControllerForPath($path.'/'); + + // NOTE: For POST, just 404 instead of redirecting, since the redirect + // will be a GET without parameters. + + if ($controller && !$request->isHTTPPost()) { + $slash_uri = $request->getRequestURI()->setPath($path.'/'); + return $this->buildRedirectController($slash_uri); + } + } + return $this->build404Controller(); + } + + return array($controller, $uri_data); + } + + + /** + * Map a specific path to the corresponding controller. For a description + * of routing, see @{method:buildController}. + * + * @return pair Controller and dictionary of request + * parameters. + * @task routing + */ + final public function buildControllerForPath($path) { + $maps = array(); + $maps[] = array(null, $this->getURIMap()); + + $applications = PhabricatorApplication::getAllInstalledApplications(); + foreach ($applications as $application) { + $maps[] = array($application, $application->getRoutes()); + } + + $current_application = null; + $controller_class = null; + foreach ($maps as $map_info) { + list($application, $map) = $map_info; + + $mapper = new AphrontURIMapper($map); + list($controller_class, $uri_data) = $mapper->mapPath($path); + + if ($controller_class) { + if ($application) { + $current_application = $application; + } + break; + } + } + + if (!$controller_class) { + return array(null, null); + } + + $request = $this->getRequest(); + + $controller = newv($controller_class, array($request)); + if ($current_application) { + $controller->setCurrentApplication($current_application); + } + + return array($controller, $uri_data); + } + +} diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php new file mode 100644 --- /dev/null +++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php @@ -0,0 +1,315 @@ +getResourceURIMapRules() + array( + '/~/' => array( + '' => 'DarkConsoleController', + 'data/(?P[^/]+)/' => 'DarkConsoleDataController', + ), + ); + } + + protected function getResourceURIMapRules() { + $extensions = CelerityResourceController::getSupportedResourceTypes(); + $extensions = array_keys($extensions); + $extensions = implode('|', $extensions); + + return array( + '/res/' => array( + '(?:(?P[0-9]+)T/)?'. + '(?P[^/]+)/'. + '(?P[a-f0-9]{8})/'. + '(?P.+\.(?:'.$extensions.'))' + => 'CelerityPhabricatorResourceController', + ), + ); + } + + /** + * @phutil-external-symbol class PhabricatorStartup + */ + public function buildRequest() { + $parser = new PhutilQueryStringParser(); + $data = array(); + + // If the request has "multipart/form-data" content, we can't use + // PhutilQueryStringParser to parse it, and the raw data supposedly is not + // available anyway (according to the PHP documentation, "php://input" is + // not available for "multipart/form-data" requests). However, it is + // available at least some of the time (see T3673), so double check that + // we aren't trying to parse data we won't be able to parse correctly by + // examining the Content-Type header. + $content_type = idx($_SERVER, 'CONTENT_TYPE'); + $is_form_data = preg_match('@^multipart/form-data@i', $content_type); + + $raw_input = PhabricatorStartup::getRawInput(); + if (strlen($raw_input) && !$is_form_data) { + $data += $parser->parseQueryString($raw_input); + } else if ($_POST) { + $data += $_POST; + } + + $data += $parser->parseQueryString(idx($_SERVER, 'QUERY_STRING', '')); + + $cookie_prefix = PhabricatorEnv::getEnvConfig('phabricator.cookie-prefix'); + + $request = new AphrontRequest($this->getHost(), $this->getPath()); + $request->setRequestData($data); + $request->setApplicationConfiguration($this); + $request->setCookiePrefix($cookie_prefix); + + return $request; + } + + public function handleException(Exception $ex) { + $request = $this->getRequest(); + + // For Conduit requests, return a Conduit response. + if ($request->isConduit()) { + $response = new ConduitAPIResponse(); + $response->setErrorCode(get_class($ex)); + $response->setErrorInfo($ex->getMessage()); + + return id(new AphrontJSONResponse()) + ->setAddJSONShield(false) + ->setContent($response->toDictionary()); + } + + // For non-workflow requests, return a Ajax response. + if ($request->isAjax() && !$request->isJavelinWorkflow()) { + // Log these; they don't get shown on the client and can be difficult + // to debug. + phlog($ex); + + $response = new AphrontAjaxResponse(); + $response->setError( + array( + 'code' => get_class($ex), + 'info' => $ex->getMessage(), + )); + return $response; + } + + $user = $request->getUser(); + if (!$user) { + // If we hit an exception very early, we won't have a user. + $user = new PhabricatorUser(); + } + + if ($ex instanceof PhabricatorSystemActionRateLimitException) { + $dialog = id(new AphrontDialogView()) + ->setTitle(pht('Slow Down!')) + ->setUser($user) + ->setErrors(array(pht('You are being rate limited.'))) + ->appendParagraph($ex->getMessage()) + ->appendParagraph($ex->getRateExplanation()) + ->addCancelButton('/', pht('Okaaaaaaaaaaaaaay...')); + + $response = new AphrontDialogResponse(); + $response->setDialog($dialog); + return $response; + } + + if ($ex instanceof PhabricatorAuthHighSecurityRequiredException) { + + $form = id(new PhabricatorAuthSessionEngine())->renderHighSecurityForm( + $ex->getFactors(), + $ex->getFactorValidationResults(), + $user, + $request); + + $dialog = id(new AphrontDialogView()) + ->setUser($user) + ->setTitle(pht('Entering High Security')) + ->setShortTitle(pht('Security Checkpoint')) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->addHiddenInput(AphrontRequest::TYPE_HISEC, true) + ->setErrors( + array( + pht( + 'You are taking an action which requires you to enter '. + 'high security.'), + )) + ->appendParagraph( + pht( + 'High security mode helps protect your account from security '. + 'threats, like session theft or someone messing with your stuff '. + 'while you\'re grabbing a coffee. To enter high security mode, '. + 'confirm your credentials.')) + ->appendChild($form->buildLayoutView()) + ->appendParagraph( + pht( + 'Your account will remain in high security mode for a short '. + 'period of time. When you are finished taking sensitive '. + 'actions, you should leave high security.')) + ->setSubmitURI($request->getPath()) + ->addCancelButton($ex->getCancelURI()) + ->addSubmitButton(pht('Enter High Security')); + + foreach ($request->getPassthroughRequestParameters() as $key => $value) { + $dialog->addHiddenInput($key, $value); + } + + $response = new AphrontDialogResponse(); + $response->setDialog($dialog); + return $response; + } + + if ($ex instanceof PhabricatorPolicyException) { + + if (!$user->isLoggedIn()) { + // If the user isn't logged in, just give them a login form. This is + // probably a generally more useful response than a policy dialog that + // they have to click through to get a login form. + // + // Possibly we should add a header here like "you need to login to see + // the thing you are trying to look at". + $login_controller = new PhabricatorAuthStartController($request); + + $auth_app_class = 'PhabricatorApplicationAuth'; + $auth_app = PhabricatorApplication::getByClass($auth_app_class); + $login_controller->setCurrentApplication($auth_app); + + return $login_controller->processRequest(); + } + + $list = $ex->getMoreInfo(); + foreach ($list as $key => $item) { + $list[$key] = phutil_tag('li', array(), $item); + } + if ($list) { + $list = phutil_tag('ul', array(), $list); + } + + $content = array( + phutil_tag( + 'div', + array( + 'class' => 'aphront-policy-rejection', + ), + $ex->getRejection()), + phutil_tag( + 'div', + array( + 'class' => 'aphront-capability-details', + ), + pht('Users with the "%s" capability:', $ex->getCapabilityName())), + $list, + ); + + $dialog = new AphrontDialogView(); + $dialog + ->setTitle($ex->getTitle()) + ->setClass('aphront-access-dialog') + ->setUser($user) + ->appendChild($content); + + if ($this->getRequest()->isAjax()) { + $dialog->addCancelButton('/', pht('Close')); + } else { + $dialog->addCancelButton('/', pht('OK')); + } + + $response = new AphrontDialogResponse(); + $response->setDialog($dialog); + return $response; + } + + if ($ex instanceof AphrontUsageException) { + $error = new AphrontErrorView(); + $error->setTitle($ex->getTitle()); + $error->appendChild($ex->getMessage()); + + $view = new PhabricatorStandardPageView(); + $view->setRequest($this->getRequest()); + $view->appendChild($error); + + $response = new AphrontWebpageResponse(); + $response->setContent($view->render()); + $response->setHTTPResponseCode(500); + + return $response; + } + + + // Always log the unhandled exception. + phlog($ex); + + $class = get_class($ex); + $message = $ex->getMessage(); + + if ($ex instanceof AphrontQuerySchemaException) { + $message .= + "\n\n". + "NOTE: This usually indicates that the MySQL schema has not been ". + "properly upgraded. Run 'bin/storage upgrade' to ensure your ". + "schema is up to date."; + } + + if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { + $trace = id(new AphrontStackTraceView()) + ->setUser($user) + ->setTrace($ex->getTrace()); + } else { + $trace = null; + } + + $content = phutil_tag( + 'div', + array('class' => 'aphront-unhandled-exception'), + array( + phutil_tag('div', array('class' => 'exception-message'), $message), + $trace, + )); + + $dialog = new AphrontDialogView(); + $dialog + ->setTitle('Unhandled Exception ("'.$class.'")') + ->setClass('aphront-exception-dialog') + ->setUser($user) + ->appendChild($content); + + if ($this->getRequest()->isAjax()) { + $dialog->addCancelButton('/', 'Close'); + } + + $response = new AphrontDialogResponse(); + $response->setDialog($dialog); + $response->setHTTPResponseCode(500); + + return $response; + } + + public function willSendResponse(AphrontResponse $response) { + return $response; + } + + public function build404Controller() { + return array(new Phabricator404Controller($this->getRequest()), array()); + } + + public function buildRedirectController($uri) { + return array( + new PhabricatorRedirectController($this->getRequest()), + array( + 'uri' => $uri, + )); + } + +} diff --git a/src/aphront/exception/AphrontCSRFException.php b/src/aphront/exception/AphrontCSRFException.php new file mode 100644 --- /dev/null +++ b/src/aphront/exception/AphrontCSRFException.php @@ -0,0 +1,5 @@ +title = $title; + parent::__construct($message); + } + + public function getTitle() { + return $this->title; + } + +} diff --git a/src/aphront/response/Aphront304Response.php b/src/aphront/response/Aphront304Response.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/Aphront304Response.php @@ -0,0 +1,16 @@ +forbiddenText = $text; + return $this; + } + private function getForbiddenText() { + return $this->forbiddenText; + } + + public function getHTTPResponseCode() { + return 403; + } + + public function buildResponseString() { + $forbidden_text = $this->getForbiddenText(); + if (!$forbidden_text) { + $forbidden_text = + 'You do not have privileges to access the requested page.'; + } + $failure = new AphrontRequestFailureView(); + $failure->setHeader('403 Forbidden'); + $failure->appendChild(phutil_tag('p', array(), $forbidden_text)); + + $view = new PhabricatorStandardPageView(); + $view->setTitle('403 Forbidden'); + $view->setRequest($this->getRequest()); + $view->appendChild($failure); + + return $view->render(); + } + +} diff --git a/src/aphront/response/Aphront404Response.php b/src/aphront/response/Aphront404Response.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/Aphront404Response.php @@ -0,0 +1,23 @@ +setHeader('404 Not Found'); + $failure->appendChild(phutil_tag('p', array(), pht( + 'The page you requested was not found.'))); + + $view = new PhabricatorStandardPageView(); + $view->setTitle('404 Not Found'); + $view->setRequest($this->getRequest()); + $view->appendChild($failure); + + return $view->render(); + } + +} diff --git a/src/aphront/response/AphrontAjaxResponse.php b/src/aphront/response/AphrontAjaxResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontAjaxResponse.php @@ -0,0 +1,77 @@ +content = $content; + return $this; + } + + public function setError($error) { + $this->error = $error; + return $this; + } + + public function setDisableConsole($disable) { + $this->disableConsole = $disable; + return $this; + } + + private function getConsole() { + if ($this->disableConsole) { + $console = null; + } else { + $request = $this->getRequest(); + $console = $request->getApplicationConfiguration()->getConsole(); + } + return $console; + } + + public function buildResponseString() { + $console = $this->getConsole(); + if ($console) { + // NOTE: We're stripping query parameters here both for readability and + // to mitigate BREACH and similar attacks. The parameters are available + // in the "Request" tab, so this should not impact usability. See T3684. + $uri = $this->getRequest()->getRequestURI(); + $uri = new PhutilURI($uri); + $uri->setQueryParams(array()); + + Javelin::initBehavior( + 'dark-console', + array( + 'uri' => (string)$uri, + 'key' => $console->getKey($this->getRequest()), + 'color' => $console->getColor(), + )); + } + + // Flatten the response first, so we initialize any behaviors and metadata + // we need to. + $content = array( + 'payload' => $this->content, + ); + $this->encodeJSONForHTTPResponse($content); + + $response = CelerityAPI::getStaticResourceResponse(); + $object = $response->buildAjaxResponse( + $content['payload'], + $this->error); + + $response_json = $this->encodeJSONForHTTPResponse($object); + return $this->addJSONShield($response_json); + } + + public function getHeaders() { + $headers = array( + array('Content-Type', 'text/plain; charset=UTF-8'), + ); + $headers = array_merge(parent::getHeaders(), $headers); + return $headers; + } + +} diff --git a/src/aphront/response/AphrontDialogResponse.php b/src/aphront/response/AphrontDialogResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontDialogResponse.php @@ -0,0 +1,20 @@ +dialog = $dialog; + return $this; + } + + public function getDialog() { + return $this->dialog; + } + + public function buildResponseString() { + return $this->dialog->render(); + } + +} diff --git a/src/aphront/response/AphrontFileResponse.php b/src/aphront/response/AphrontFileResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontFileResponse.php @@ -0,0 +1,92 @@ +allowOrigins[] = $origin; + return $this; + } + + public function setDownload($download) { + $download = preg_replace('/[^A-Za-z0-9_.-]/', '_', $download); + if (!strlen($download)) { + $download = 'untitled_document.txt'; + } + $this->download = $download; + return $this; + } + + public function getDownload() { + return $this->download; + } + + public function setMimeType($mime_type) { + $this->mimeType = $mime_type; + return $this; + } + + public function getMimeType() { + return $this->mimeType; + } + + public function setContent($content) { + $this->content = $content; + return $this; + } + + public function buildResponseString() { + if ($this->rangeMin || $this->rangeMax) { + $length = ($this->rangeMax - $this->rangeMin) + 1; + return substr($this->content, $this->rangeMin, $length); + } else { + return $this->content; + } + } + + public function setRange($min, $max) { + $this->rangeMin = $min; + $this->rangeMax = $max; + return $this; + } + + public function getHeaders() { + $headers = array( + array('Content-Type', $this->getMimeType()), + array('Content-Length', strlen($this->buildResponseString())), + ); + + if ($this->rangeMin || $this->rangeMax) { + $len = strlen($this->content); + $min = $this->rangeMin; + $max = $this->rangeMax; + $headers[] = array('Content-Range', "bytes {$min}-{$max}/{$len}"); + } + + if (strlen($this->getDownload())) { + $headers[] = array('X-Download-Options', 'noopen'); + + $filename = $this->getDownload(); + $headers[] = array( + 'Content-Disposition', + 'attachment; filename='.$filename, + ); + } + + if ($this->allowOrigins) { + $headers[] = array( + 'Access-Control-Allow-Origin', + implode(',', $this->allowOrigins)); + } + + $headers = array_merge(parent::getHeaders(), $headers); + return $headers; + } + +} diff --git a/src/aphront/response/AphrontHTMLResponse.php b/src/aphront/response/AphrontHTMLResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontHTMLResponse.php @@ -0,0 +1,13 @@ +content = $content; + return $this; + } + + public function setAddJSONShield($should_add) { + $this->addJSONShield = $should_add; + return $this; + } + + public function shouldAddJSONShield() { + if ($this->addJSONShield === null) { + return true; + } + return (bool) $this->addJSONShield; + } + + public function buildResponseString() { + $response = $this->encodeJSONForHTTPResponse($this->content); + if ($this->shouldAddJSONShield()) { + $response = $this->addJSONShield($response); + } + return $response; + } + + public function getHeaders() { + $headers = array( + array('Content-Type', 'application/json'), + ); + $headers = array_merge(parent::getHeaders(), $headers); + return $headers; + } + +} diff --git a/src/aphront/response/AphrontPlainTextResponse.php b/src/aphront/response/AphrontPlainTextResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontPlainTextResponse.php @@ -0,0 +1,22 @@ +content = $content; + return $this; + } + + public function buildResponseString() { + return $this->content; + } + + public function getHeaders() { + $headers = array( + array('Content-Type', 'text/plain; charset=utf-8'), + ); + + return array_merge(parent::getHeaders(), $headers); + } + +} diff --git a/src/aphront/response/AphrontProxyResponse.php b/src/aphront/response/AphrontProxyResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontProxyResponse.php @@ -0,0 +1,71 @@ +proxy) { + $this->proxy = $this->buildProxy(); + } + return $this->proxy; + } + + public function setRequest($request) { + $this->getProxy()->setRequest($request); + return $this; + } + + public function getRequest() { + return $this->getProxy()->getRequest(); + } + + public function getHeaders() { + return $this->getProxy()->getHeaders(); + } + + public function setCacheDurationInSeconds($duration) { + $this->getProxy()->setCacheDurationInSeconds($duration); + return $this; + } + + public function setLastModified($epoch_timestamp) { + $this->getProxy()->setLastModified($epoch_timestamp); + return $this; + } + + public function setHTTPResponseCode($code) { + $this->getProxy()->setHTTPResponseCode($code); + return $this; + } + + public function getHTTPResponseCode() { + return $this->getProxy()->getHTTPResponseCode(); + } + + public function setFrameable($frameable) { + $this->getProxy()->setFrameable($frameable); + return $this; + } + + public function getCacheHeaders() { + return $this->getProxy()->getCacheHeaders(); + } + + abstract protected function buildProxy(); + abstract public function reduceProxyResponse(); + + final public function buildResponseString() { + throw new Exception( + 'AphrontProxyResponse must implement reduceProxyResponse().'); + } + +} diff --git a/src/aphront/response/AphrontRedirectResponse.php b/src/aphront/response/AphrontRedirectResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontRedirectResponse.php @@ -0,0 +1,88 @@ +shouldStopForDebugging()) { + // If we're going to stop, capture the stack so we can print it out. + $this->stackWhenCreated = id(new Exception())->getTrace(); + } + } + + public function setURI($uri) { + $this->uri = $uri; + return $this; + } + + public function getURI() { + return (string)$this->uri; + } + + public function shouldStopForDebugging() { + return PhabricatorEnv::getEnvConfig('debug.stop-on-redirect'); + } + + public function getHeaders() { + $headers = array(); + if (!$this->shouldStopForDebugging()) { + $headers[] = array('Location', $this->uri); + } + $headers = array_merge(parent::getHeaders(), $headers); + return $headers; + } + + public function buildResponseString() { + if ($this->shouldStopForDebugging()) { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $view = new PhabricatorStandardPageView(); + $view->setRequest($this->getRequest()); + $view->setApplicationName(pht('Debug')); + $view->setTitle(pht('Stopped on Redirect')); + + $dialog = new AphrontDialogView(); + $dialog->setUser($viewer); + $dialog->setTitle(pht('Stopped on Redirect')); + + $dialog->appendParagraph( + pht( + 'You were stopped here because %s is set in your configuration.', + phutil_tag('tt', array(), 'debug.stop-on-redirect'))); + + $dialog->appendParagraph( + pht( + 'You are being redirected to: %s', + phutil_tag('tt', array(), $this->getURI()))); + + $dialog->addCancelButton($this->getURI(), pht('Continue')); + + $dialog->appendChild(phutil_tag('br')); + + $dialog->appendChild( + id(new AphrontStackTraceView()) + ->setUser($viewer) + ->setTrace($this->stackWhenCreated)); + + $dialog->setIsStandalone(true); + $dialog->setWidth(AphrontDialogView::WIDTH_FULL); + + $box = id(new PHUIBoxView()) + ->addMargin(PHUI::MARGIN_LARGE) + ->appendChild($dialog); + + $view->appendChild($box); + + return $view->render(); + } + + return ''; + } + +} diff --git a/src/aphront/response/AphrontReloadResponse.php b/src/aphront/response/AphrontReloadResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontReloadResponse.php @@ -0,0 +1,19 @@ +getRequest()->isAjax()) { + return null; + } else { + return parent::getURI(); + } + } + +} diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontResponse.php @@ -0,0 +1,147 @@ +request = $request; + return $this; + } + + public function getRequest() { + return $this->request; + } + + public function getHeaders() { + $headers = array(); + if (!$this->frameable) { + $headers[] = array('X-Frame-Options', 'Deny'); + } + + return $headers; + } + + public function setCacheDurationInSeconds($duration) { + $this->cacheable = $duration; + return $this; + } + + public function setLastModified($epoch_timestamp) { + $this->lastModified = $epoch_timestamp; + return $this; + } + + public function setHTTPResponseCode($code) { + $this->responseCode = $code; + return $this; + } + + public function getHTTPResponseCode() { + return $this->responseCode; + } + + public function getHTTPResponseMessage() { + return ''; + } + + public function setFrameable($frameable) { + $this->frameable = $frameable; + return $this; + } + + public static function processValueForJSONEncoding(&$value, $key) { + if ($value instanceof PhutilSafeHTMLProducerInterface) { + // This renders the producer down to PhutilSafeHTML, which will then + // be simplified into a string below. + $value = hsprintf('%s', $value); + } + + if ($value instanceof PhutilSafeHTML) { + // TODO: Javelin supports implicity conversion of '__html' objects to + // JX.HTML, but only for Ajax responses, not behaviors. Just leave things + // as they are for now (where behaviors treat responses as HTML or plain + // text at their discretion). + $value = $value->getHTMLContent(); + } + } + + public static function encodeJSONForHTTPResponse(array $object) { + + array_walk_recursive( + $object, + array('AphrontResponse', 'processValueForJSONEncoding')); + + $response = json_encode($object); + + // Prevent content sniffing attacks by encoding "<" and ">", so browsers + // won't try to execute the document as HTML even if they ignore + // Content-Type and X-Content-Type-Options. See T865. + $response = str_replace( + array('<', '>'), + array('\u003c', '\u003e'), + $response); + + return $response; + } + + protected function addJSONShield($json_response) { + // Add a shield to prevent "JSON Hijacking" attacks where an attacker + // requests a JSON response using a normal