1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-04 12:42:43 +01:00

(stable) Promote 2019 Week 36

This commit is contained in:
epriestley 2019-09-08 17:02:58 -07:00
commit 19af9d74f8
254 changed files with 18597 additions and 201 deletions

View file

@ -1,7 +1,8 @@
{
"exclude": [
"(^externals/)",
"(^webroot/rsrc/externals/(?!javelin/))"
"(^webroot/rsrc/externals/(?!javelin/))",
"(/__tests__/data/)"
],
"linters": {
"chmod": {

View file

@ -176,15 +176,27 @@ phutil_register_library_map(array(
'Aphront400Response' => 'aphront/response/Aphront400Response.php',
'Aphront403Response' => 'aphront/response/Aphront403Response.php',
'Aphront404Response' => 'aphront/response/Aphront404Response.php',
'AphrontAccessDeniedQueryException' => 'infrastructure/storage/exception/AphrontAccessDeniedQueryException.php',
'AphrontAjaxResponse' => 'aphront/response/AphrontAjaxResponse.php',
'AphrontApplicationConfiguration' => 'aphront/configuration/AphrontApplicationConfiguration.php',
'AphrontBarView' => 'view/widget/bars/AphrontBarView.php',
'AphrontBaseMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontBaseMySQLDatabaseConnection.php',
'AphrontBoolHTTPParameterType' => 'aphront/httpparametertype/AphrontBoolHTTPParameterType.php',
'AphrontCalendarEventView' => 'applications/calendar/view/AphrontCalendarEventView.php',
'AphrontCharacterSetQueryException' => 'infrastructure/storage/exception/AphrontCharacterSetQueryException.php',
'AphrontConnectionLostQueryException' => 'infrastructure/storage/exception/AphrontConnectionLostQueryException.php',
'AphrontConnectionQueryException' => 'infrastructure/storage/exception/AphrontConnectionQueryException.php',
'AphrontController' => 'aphront/AphrontController.php',
'AphrontCountQueryException' => 'infrastructure/storage/exception/AphrontCountQueryException.php',
'AphrontCursorPagerView' => 'view/control/AphrontCursorPagerView.php',
'AphrontDatabaseConnection' => 'infrastructure/storage/connection/AphrontDatabaseConnection.php',
'AphrontDatabaseTableRef' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRef.php',
'AphrontDatabaseTableRefInterface' => 'infrastructure/storage/xsprintf/AphrontDatabaseTableRefInterface.php',
'AphrontDatabaseTransactionState' => 'infrastructure/storage/connection/AphrontDatabaseTransactionState.php',
'AphrontDeadlockQueryException' => 'infrastructure/storage/exception/AphrontDeadlockQueryException.php',
'AphrontDialogResponse' => 'aphront/response/AphrontDialogResponse.php',
'AphrontDialogView' => 'view/AphrontDialogView.php',
'AphrontDuplicateKeyQueryException' => 'infrastructure/storage/exception/AphrontDuplicateKeyQueryException.php',
'AphrontEpochHTTPParameterType' => 'aphront/httpparametertype/AphrontEpochHTTPParameterType.php',
'AphrontException' => 'aphront/exception/AphrontException.php',
'AphrontFileHTTPParameterType' => 'aphront/httpparametertype/AphrontFileHTTPParameterType.php',
@ -217,6 +229,8 @@ phutil_register_library_map(array(
'AphrontHTTPSink' => 'aphront/sink/AphrontHTTPSink.php',
'AphrontHTTPSinkTestCase' => 'aphront/sink/__tests__/AphrontHTTPSinkTestCase.php',
'AphrontIntHTTPParameterType' => 'aphront/httpparametertype/AphrontIntHTTPParameterType.php',
'AphrontInvalidCredentialsQueryException' => 'infrastructure/storage/exception/AphrontInvalidCredentialsQueryException.php',
'AphrontIsolatedDatabaseConnection' => 'infrastructure/storage/connection/AphrontIsolatedDatabaseConnection.php',
'AphrontIsolatedDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontIsolatedDatabaseConnectionTestCase.php',
'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php',
'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php',
@ -224,19 +238,28 @@ phutil_register_library_map(array(
'AphrontKeyboardShortcutsAvailableView' => 'view/widget/AphrontKeyboardShortcutsAvailableView.php',
'AphrontListFilterView' => 'view/layout/AphrontListFilterView.php',
'AphrontListHTTPParameterType' => 'aphront/httpparametertype/AphrontListHTTPParameterType.php',
'AphrontLockTimeoutQueryException' => 'infrastructure/storage/exception/AphrontLockTimeoutQueryException.php',
'AphrontMalformedRequestException' => 'aphront/exception/AphrontMalformedRequestException.php',
'AphrontMoreView' => 'view/layout/AphrontMoreView.php',
'AphrontMultiColumnView' => 'view/layout/AphrontMultiColumnView.php',
'AphrontMySQLDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLDatabaseConnection.php',
'AphrontMySQLDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontMySQLDatabaseConnectionTestCase.php',
'AphrontMySQLiDatabaseConnection' => 'infrastructure/storage/connection/mysql/AphrontMySQLiDatabaseConnection.php',
'AphrontNotSupportedQueryException' => 'infrastructure/storage/exception/AphrontNotSupportedQueryException.php',
'AphrontNullView' => 'view/AphrontNullView.php',
'AphrontObjectMissingQueryException' => 'infrastructure/storage/exception/AphrontObjectMissingQueryException.php',
'AphrontPHIDHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDHTTPParameterType.php',
'AphrontPHIDListHTTPParameterType' => 'aphront/httpparametertype/AphrontPHIDListHTTPParameterType.php',
'AphrontPHPHTTPSink' => 'aphront/sink/AphrontPHPHTTPSink.php',
'AphrontPageView' => 'view/page/AphrontPageView.php',
'AphrontParameterQueryException' => 'infrastructure/storage/exception/AphrontParameterQueryException.php',
'AphrontPlainTextResponse' => 'aphront/response/AphrontPlainTextResponse.php',
'AphrontProgressBarView' => 'view/widget/bars/AphrontProgressBarView.php',
'AphrontProjectListHTTPParameterType' => 'aphront/httpparametertype/AphrontProjectListHTTPParameterType.php',
'AphrontProxyResponse' => 'aphront/response/AphrontProxyResponse.php',
'AphrontQueryException' => 'infrastructure/storage/exception/AphrontQueryException.php',
'AphrontQueryTimeoutQueryException' => 'infrastructure/storage/exception/AphrontQueryTimeoutQueryException.php',
'AphrontRecoverableQueryException' => 'infrastructure/storage/exception/AphrontRecoverableQueryException.php',
'AphrontRedirectResponse' => 'aphront/response/AphrontRedirectResponse.php',
'AphrontRedirectResponseTestCase' => 'aphront/response/__tests__/AphrontRedirectResponseTestCase.php',
'AphrontReloadResponse' => 'aphront/response/AphrontReloadResponse.php',
@ -247,6 +270,7 @@ phutil_register_library_map(array(
'AphrontResponseProducerInterface' => 'aphront/interface/AphrontResponseProducerInterface.php',
'AphrontRoutingMap' => 'aphront/site/AphrontRoutingMap.php',
'AphrontRoutingResult' => 'aphront/site/AphrontRoutingResult.php',
'AphrontSchemaQueryException' => 'infrastructure/storage/exception/AphrontSchemaQueryException.php',
'AphrontSelectHTTPParameterType' => 'aphront/httpparametertype/AphrontSelectHTTPParameterType.php',
'AphrontSideNavFilterView' => 'view/layout/AphrontSideNavFilterView.php',
'AphrontSite' => 'aphront/site/AphrontSite.php',
@ -997,6 +1021,7 @@ phutil_register_library_map(array(
'DiffusionSSHWorkflow' => 'applications/diffusion/ssh/DiffusionSSHWorkflow.php',
'DiffusionSearchQueryConduitAPIMethod' => 'applications/diffusion/conduit/DiffusionSearchQueryConduitAPIMethod.php',
'DiffusionServeController' => 'applications/diffusion/controller/DiffusionServeController.php',
'DiffusionServiceRef' => 'applications/diffusion/ref/DiffusionServiceRef.php',
'DiffusionSetPasswordSettingsPanel' => 'applications/diffusion/panel/DiffusionSetPasswordSettingsPanel.php',
'DiffusionSetupException' => 'applications/diffusion/exception/DiffusionSetupException.php',
'DiffusionSourceHyperlinkEngineExtension' => 'applications/diffusion/engineextension/DiffusionSourceHyperlinkEngineExtension.php',
@ -4176,6 +4201,7 @@ phutil_register_library_map(array(
'PhabricatorPolicyTestObject' => 'applications/policy/__tests__/PhabricatorPolicyTestObject.php',
'PhabricatorPolicyType' => 'applications/policy/constants/PhabricatorPolicyType.php',
'PhabricatorPonderApplication' => 'applications/ponder/application/PhabricatorPonderApplication.php',
'PhabricatorPreambleTestCase' => 'infrastructure/util/__tests__/PhabricatorPreambleTestCase.php',
'PhabricatorPrimaryEmailUserLogType' => 'applications/people/userlog/PhabricatorPrimaryEmailUserLogType.php',
'PhabricatorProfileMenuEditEngine' => 'applications/search/editor/PhabricatorProfileMenuEditEngine.php',
'PhabricatorProfileMenuEditor' => 'applications/search/editor/PhabricatorProfileMenuEditor.php',
@ -4629,6 +4655,7 @@ phutil_register_library_map(array(
'PhabricatorSearchScopeSetting' => 'applications/settings/setting/PhabricatorSearchScopeSetting.php',
'PhabricatorSearchSelectField' => 'applications/search/field/PhabricatorSearchSelectField.php',
'PhabricatorSearchService' => 'infrastructure/cluster/search/PhabricatorSearchService.php',
'PhabricatorSearchSettingsPanel' => 'applications/settings/panel/PhabricatorSearchSettingsPanel.php',
'PhabricatorSearchStringListField' => 'applications/search/field/PhabricatorSearchStringListField.php',
'PhabricatorSearchSubscribersField' => 'applications/search/field/PhabricatorSearchSubscribersField.php',
'PhabricatorSearchTextField' => 'applications/search/field/PhabricatorSearchTextField.php',
@ -5512,6 +5539,93 @@ phutil_register_library_map(array(
'PhrictionTransactionComment' => 'applications/phriction/storage/PhrictionTransactionComment.php',
'PhrictionTransactionEditor' => 'applications/phriction/editor/PhrictionTransactionEditor.php',
'PhrictionTransactionQuery' => 'applications/phriction/query/PhrictionTransactionQuery.php',
'PhutilAmazonAuthAdapter' => 'applications/auth/adapter/PhutilAmazonAuthAdapter.php',
'PhutilAsanaAuthAdapter' => 'applications/auth/adapter/PhutilAsanaAuthAdapter.php',
'PhutilAuthAdapter' => 'applications/auth/adapter/PhutilAuthAdapter.php',
'PhutilAuthConfigurationException' => 'applications/auth/exception/PhutilAuthConfigurationException.php',
'PhutilAuthCredentialException' => 'applications/auth/exception/PhutilAuthCredentialException.php',
'PhutilAuthException' => 'applications/auth/exception/PhutilAuthException.php',
'PhutilAuthUserAbortedException' => 'applications/auth/exception/PhutilAuthUserAbortedException.php',
'PhutilBitbucketAuthAdapter' => 'applications/auth/adapter/PhutilBitbucketAuthAdapter.php',
'PhutilCLikeCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCLikeCodeSnippetContextFreeGrammar.php',
'PhutilCalendarAbsoluteDateTime' => 'applications/calendar/parser/data/PhutilCalendarAbsoluteDateTime.php',
'PhutilCalendarContainerNode' => 'applications/calendar/parser/data/PhutilCalendarContainerNode.php',
'PhutilCalendarDateTime' => 'applications/calendar/parser/data/PhutilCalendarDateTime.php',
'PhutilCalendarDateTimeTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarDateTimeTestCase.php',
'PhutilCalendarDocumentNode' => 'applications/calendar/parser/data/PhutilCalendarDocumentNode.php',
'PhutilCalendarDuration' => 'applications/calendar/parser/data/PhutilCalendarDuration.php',
'PhutilCalendarEventNode' => 'applications/calendar/parser/data/PhutilCalendarEventNode.php',
'PhutilCalendarNode' => 'applications/calendar/parser/data/PhutilCalendarNode.php',
'PhutilCalendarProxyDateTime' => 'applications/calendar/parser/data/PhutilCalendarProxyDateTime.php',
'PhutilCalendarRawNode' => 'applications/calendar/parser/data/PhutilCalendarRawNode.php',
'PhutilCalendarRecurrenceList' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceList.php',
'PhutilCalendarRecurrenceRule' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceRule.php',
'PhutilCalendarRecurrenceRuleTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceRuleTestCase.php',
'PhutilCalendarRecurrenceSet' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSet.php',
'PhutilCalendarRecurrenceSource' => 'applications/calendar/parser/data/PhutilCalendarRecurrenceSource.php',
'PhutilCalendarRecurrenceTestCase' => 'applications/calendar/parser/data/__tests__/PhutilCalendarRecurrenceTestCase.php',
'PhutilCalendarRelativeDateTime' => 'applications/calendar/parser/data/PhutilCalendarRelativeDateTime.php',
'PhutilCalendarRootNode' => 'applications/calendar/parser/data/PhutilCalendarRootNode.php',
'PhutilCalendarUserNode' => 'applications/calendar/parser/data/PhutilCalendarUserNode.php',
'PhutilCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilCodeSnippetContextFreeGrammar.php',
'PhutilDisqusAuthAdapter' => 'applications/auth/adapter/PhutilDisqusAuthAdapter.php',
'PhutilEmptyAuthAdapter' => 'applications/auth/adapter/PhutilEmptyAuthAdapter.php',
'PhutilFacebookAuthAdapter' => 'applications/auth/adapter/PhutilFacebookAuthAdapter.php',
'PhutilGitHubAuthAdapter' => 'applications/auth/adapter/PhutilGitHubAuthAdapter.php',
'PhutilGoogleAuthAdapter' => 'applications/auth/adapter/PhutilGoogleAuthAdapter.php',
'PhutilICSParser' => 'applications/calendar/parser/ics/PhutilICSParser.php',
'PhutilICSParserException' => 'applications/calendar/parser/ics/PhutilICSParserException.php',
'PhutilICSParserTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSParserTestCase.php',
'PhutilICSWriter' => 'applications/calendar/parser/ics/PhutilICSWriter.php',
'PhutilICSWriterTestCase' => 'applications/calendar/parser/ics/__tests__/PhutilICSWriterTestCase.php',
'PhutilJIRAAuthAdapter' => 'applications/auth/adapter/PhutilJIRAAuthAdapter.php',
'PhutilJavaCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilJavaCodeSnippetContextFreeGrammar.php',
'PhutilLDAPAuthAdapter' => 'applications/auth/adapter/PhutilLDAPAuthAdapter.php',
'PhutilLipsumContextFreeGrammar' => 'infrastructure/lipsum/PhutilLipsumContextFreeGrammar.php',
'PhutilOAuth1AuthAdapter' => 'applications/auth/adapter/PhutilOAuth1AuthAdapter.php',
'PhutilOAuthAuthAdapter' => 'applications/auth/adapter/PhutilOAuthAuthAdapter.php',
'PhutilPHPCodeSnippetContextFreeGrammar' => 'infrastructure/lipsum/code/PhutilPHPCodeSnippetContextFreeGrammar.php',
'PhutilPhabricatorAuthAdapter' => 'applications/auth/adapter/PhutilPhabricatorAuthAdapter.php',
'PhutilQsprintfInterface' => 'infrastructure/storage/xsprintf/PhutilQsprintfInterface.php',
'PhutilQueryString' => 'infrastructure/storage/xsprintf/PhutilQueryString.php',
'PhutilRealNameContextFreeGrammar' => 'infrastructure/lipsum/PhutilRealNameContextFreeGrammar.php',
'PhutilRemarkupBlockInterpreter' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockInterpreter.php',
'PhutilRemarkupBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupBlockRule.php',
'PhutilRemarkupBlockStorage' => 'infrastructure/markup/PhutilRemarkupBlockStorage.php',
'PhutilRemarkupBoldRule' => 'infrastructure/markup/markuprule/PhutilRemarkupBoldRule.php',
'PhutilRemarkupCodeBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupCodeBlockRule.php',
'PhutilRemarkupDefaultBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupDefaultBlockRule.php',
'PhutilRemarkupDelRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDelRule.php',
'PhutilRemarkupDocumentLinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupDocumentLinkRule.php',
'PhutilRemarkupEngine' => 'infrastructure/markup/remarkup/PhutilRemarkupEngine.php',
'PhutilRemarkupEngineTestCase' => 'infrastructure/markup/remarkup/__tests__/PhutilRemarkupEngineTestCase.php',
'PhutilRemarkupEscapeRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupEscapeRemarkupRule.php',
'PhutilRemarkupHeaderBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHeaderBlockRule.php',
'PhutilRemarkupHighlightRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHighlightRule.php',
'PhutilRemarkupHorizontalRuleBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupHorizontalRuleBlockRule.php',
'PhutilRemarkupHyperlinkEngineExtension' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkEngineExtension.php',
'PhutilRemarkupHyperlinkRef' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRef.php',
'PhutilRemarkupHyperlinkRule' => 'infrastructure/markup/markuprule/PhutilRemarkupHyperlinkRule.php',
'PhutilRemarkupInlineBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInlineBlockRule.php',
'PhutilRemarkupInterpreterBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupInterpreterBlockRule.php',
'PhutilRemarkupItalicRule' => 'infrastructure/markup/markuprule/PhutilRemarkupItalicRule.php',
'PhutilRemarkupLinebreaksRule' => 'infrastructure/markup/markuprule/PhutilRemarkupLinebreaksRule.php',
'PhutilRemarkupListBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupListBlockRule.php',
'PhutilRemarkupLiteralBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupLiteralBlockRule.php',
'PhutilRemarkupMonospaceRule' => 'infrastructure/markup/markuprule/PhutilRemarkupMonospaceRule.php',
'PhutilRemarkupNoteBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupNoteBlockRule.php',
'PhutilRemarkupQuotedBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotedBlockRule.php',
'PhutilRemarkupQuotesBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupQuotesBlockRule.php',
'PhutilRemarkupReplyBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupReplyBlockRule.php',
'PhutilRemarkupRule' => 'infrastructure/markup/markuprule/PhutilRemarkupRule.php',
'PhutilRemarkupSimpleTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupSimpleTableBlockRule.php',
'PhutilRemarkupTableBlockRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTableBlockRule.php',
'PhutilRemarkupTestInterpreterRule' => 'infrastructure/markup/blockrule/PhutilRemarkupTestInterpreterRule.php',
'PhutilRemarkupUnderlineRule' => 'infrastructure/markup/markuprule/PhutilRemarkupUnderlineRule.php',
'PhutilSlackAuthAdapter' => 'applications/auth/adapter/PhutilSlackAuthAdapter.php',
'PhutilTwitchAuthAdapter' => 'applications/auth/adapter/PhutilTwitchAuthAdapter.php',
'PhutilTwitterAuthAdapter' => 'applications/auth/adapter/PhutilTwitterAuthAdapter.php',
'PhutilWordPressAuthAdapter' => 'applications/auth/adapter/PhutilWordPressAuthAdapter.php',
'PolicyLockOptionType' => 'applications/policy/config/PolicyLockOptionType.php',
'PonderAddAnswerView' => 'applications/ponder/view/PonderAddAnswerView.php',
'PonderAnswer' => 'applications/ponder/storage/PonderAnswer.php',
@ -5587,6 +5701,7 @@ phutil_register_library_map(array(
'ProjectReplyHandler' => 'applications/project/mail/ProjectReplyHandler.php',
'ProjectSearchConduitAPIMethod' => 'applications/project/conduit/ProjectSearchConduitAPIMethod.php',
'QueryFormattingTestCase' => 'infrastructure/storage/__tests__/QueryFormattingTestCase.php',
'QueryFuture' => 'infrastructure/storage/future/QueryFuture.php',
'ReleephAuthorFieldSpecification' => 'applications/releeph/field/specification/ReleephAuthorFieldSpecification.php',
'ReleephBranch' => 'applications/releeph/storage/ReleephBranch.php',
'ReleephBranchAccessController' => 'applications/releeph/controller/branch/ReleephBranchAccessController.php',
@ -5713,7 +5828,15 @@ phutil_register_library_map(array(
'phid_get_subtype' => 'applications/phid/utils.php',
'phid_get_type' => 'applications/phid/utils.php',
'phid_group_by_type' => 'applications/phid/utils.php',
'qsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php',
'qsprintf_check_scalar_type' => 'infrastructure/storage/xsprintf/qsprintf.php',
'qsprintf_check_type' => 'infrastructure/storage/xsprintf/qsprintf.php',
'queryfx' => 'infrastructure/storage/xsprintf/queryfx.php',
'queryfx_all' => 'infrastructure/storage/xsprintf/queryfx.php',
'queryfx_one' => 'infrastructure/storage/xsprintf/queryfx.php',
'require_celerity_resource' => 'applications/celerity/api.php',
'vqsprintf' => 'infrastructure/storage/xsprintf/qsprintf.php',
'xsprintf_query' => 'infrastructure/storage/xsprintf/qsprintf.php',
),
'xmap' => array(
'AlmanacAddress' => 'Phobject',
@ -5937,18 +6060,35 @@ phutil_register_library_map(array(
'Aphront400Response' => 'AphrontResponse',
'Aphront403Response' => 'AphrontHTMLResponse',
'Aphront404Response' => 'AphrontHTMLResponse',
'AphrontAccessDeniedQueryException' => 'AphrontQueryException',
'AphrontAjaxResponse' => 'AphrontResponse',
'AphrontApplicationConfiguration' => 'Phobject',
'AphrontBarView' => 'AphrontView',
'AphrontBaseMySQLDatabaseConnection' => 'AphrontDatabaseConnection',
'AphrontBoolHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontCalendarEventView' => 'AphrontView',
'AphrontCharacterSetQueryException' => 'AphrontQueryException',
'AphrontConnectionLostQueryException' => 'AphrontRecoverableQueryException',
'AphrontConnectionQueryException' => 'AphrontQueryException',
'AphrontController' => 'Phobject',
'AphrontCountQueryException' => 'AphrontQueryException',
'AphrontCursorPagerView' => 'AphrontView',
'AphrontDatabaseConnection' => array(
'Phobject',
'PhutilQsprintfInterface',
),
'AphrontDatabaseTableRef' => array(
'Phobject',
'AphrontDatabaseTableRefInterface',
),
'AphrontDatabaseTransactionState' => 'Phobject',
'AphrontDeadlockQueryException' => 'AphrontRecoverableQueryException',
'AphrontDialogResponse' => 'AphrontResponse',
'AphrontDialogView' => array(
'AphrontView',
'AphrontResponseProducerInterface',
),
'AphrontDuplicateKeyQueryException' => 'AphrontQueryException',
'AphrontEpochHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontException' => 'Exception',
'AphrontFileHTTPParameterType' => 'AphrontHTTPParameterType',
@ -5981,6 +6121,8 @@ phutil_register_library_map(array(
'AphrontHTTPSink' => 'Phobject',
'AphrontHTTPSinkTestCase' => 'PhabricatorTestCase',
'AphrontIntHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontInvalidCredentialsQueryException' => 'AphrontQueryException',
'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection',
'AphrontIsolatedDatabaseConnectionTestCase' => 'PhabricatorTestCase',
'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink',
'AphrontJSONResponse' => 'AphrontResponse',
@ -5988,15 +6130,21 @@ phutil_register_library_map(array(
'AphrontKeyboardShortcutsAvailableView' => 'AphrontView',
'AphrontListFilterView' => 'AphrontView',
'AphrontListHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontLockTimeoutQueryException' => 'AphrontRecoverableQueryException',
'AphrontMalformedRequestException' => 'AphrontException',
'AphrontMoreView' => 'AphrontView',
'AphrontMultiColumnView' => 'AphrontView',
'AphrontMySQLDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
'AphrontMySQLDatabaseConnectionTestCase' => 'PhabricatorTestCase',
'AphrontMySQLiDatabaseConnection' => 'AphrontBaseMySQLDatabaseConnection',
'AphrontNotSupportedQueryException' => 'AphrontQueryException',
'AphrontNullView' => 'AphrontView',
'AphrontObjectMissingQueryException' => 'AphrontQueryException',
'AphrontPHIDHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontPHIDListHTTPParameterType' => 'AphrontListHTTPParameterType',
'AphrontPHPHTTPSink' => 'AphrontHTTPSink',
'AphrontPageView' => 'AphrontView',
'AphrontParameterQueryException' => 'AphrontQueryException',
'AphrontPlainTextResponse' => 'AphrontResponse',
'AphrontProgressBarView' => 'AphrontBarView',
'AphrontProjectListHTTPParameterType' => 'AphrontListHTTPParameterType',
@ -6004,6 +6152,9 @@ phutil_register_library_map(array(
'AphrontResponse',
'AphrontResponseProducerInterface',
),
'AphrontQueryException' => 'Exception',
'AphrontQueryTimeoutQueryException' => 'AphrontRecoverableQueryException',
'AphrontRecoverableQueryException' => 'AphrontQueryException',
'AphrontRedirectResponse' => 'AphrontResponse',
'AphrontRedirectResponseTestCase' => 'PhabricatorTestCase',
'AphrontReloadResponse' => 'AphrontRedirectResponse',
@ -6013,6 +6164,7 @@ phutil_register_library_map(array(
'AphrontResponse' => 'Phobject',
'AphrontRoutingMap' => 'Phobject',
'AphrontRoutingResult' => 'Phobject',
'AphrontSchemaQueryException' => 'AphrontQueryException',
'AphrontSelectHTTPParameterType' => 'AphrontHTTPParameterType',
'AphrontSideNavFilterView' => 'AphrontView',
'AphrontSite' => 'Phobject',
@ -6818,6 +6970,7 @@ phutil_register_library_map(array(
'DiffusionSSHWorkflow' => 'PhabricatorSSHWorkflow',
'DiffusionSearchQueryConduitAPIMethod' => 'DiffusionQueryConduitAPIMethod',
'DiffusionServeController' => 'DiffusionController',
'DiffusionServiceRef' => 'Phobject',
'DiffusionSetPasswordSettingsPanel' => 'PhabricatorSettingsPanel',
'DiffusionSetupException' => 'Exception',
'DiffusionSourceHyperlinkEngineExtension' => 'PhabricatorRemarkupHyperlinkEngineExtension',
@ -10530,6 +10683,7 @@ phutil_register_library_map(array(
),
'PhabricatorPolicyType' => 'PhabricatorPolicyConstants',
'PhabricatorPonderApplication' => 'PhabricatorApplication',
'PhabricatorPreambleTestCase' => 'PhabricatorTestCase',
'PhabricatorPrimaryEmailUserLogType' => 'PhabricatorUserLogType',
'PhabricatorProfileMenuEditEngine' => 'PhabricatorEditEngine',
'PhabricatorProfileMenuEditor' => 'PhabricatorApplicationTransactionEditor',
@ -11095,9 +11249,10 @@ phutil_register_library_map(array(
'PhabricatorSearchResultBucketGroup' => 'Phobject',
'PhabricatorSearchResultView' => 'AphrontView',
'PhabricatorSearchSchemaSpec' => 'PhabricatorConfigSchemaSpec',
'PhabricatorSearchScopeSetting' => 'PhabricatorInternalSetting',
'PhabricatorSearchScopeSetting' => 'PhabricatorSelectSetting',
'PhabricatorSearchSelectField' => 'PhabricatorSearchField',
'PhabricatorSearchService' => 'Phobject',
'PhabricatorSearchSettingsPanel' => 'PhabricatorEditEngineSettingsPanel',
'PhabricatorSearchStringListField' => 'PhabricatorSearchField',
'PhabricatorSearchSubscribersField' => 'PhabricatorSearchTokenizerField',
'PhabricatorSearchTextField' => 'PhabricatorSearchField',
@ -12169,6 +12324,92 @@ phutil_register_library_map(array(
'PhrictionTransactionComment' => 'PhabricatorApplicationTransactionComment',
'PhrictionTransactionEditor' => 'PhabricatorApplicationTransactionEditor',
'PhrictionTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'PhutilAmazonAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilAsanaAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilAuthAdapter' => 'Phobject',
'PhutilAuthConfigurationException' => 'PhutilAuthException',
'PhutilAuthCredentialException' => 'PhutilAuthException',
'PhutilAuthException' => 'Exception',
'PhutilAuthUserAbortedException' => 'PhutilAuthException',
'PhutilBitbucketAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilCLikeCodeSnippetContextFreeGrammar' => 'PhutilCodeSnippetContextFreeGrammar',
'PhutilCalendarAbsoluteDateTime' => 'PhutilCalendarDateTime',
'PhutilCalendarContainerNode' => 'PhutilCalendarNode',
'PhutilCalendarDateTime' => 'Phobject',
'PhutilCalendarDateTimeTestCase' => 'PhutilTestCase',
'PhutilCalendarDocumentNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarDuration' => 'Phobject',
'PhutilCalendarEventNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarNode' => 'Phobject',
'PhutilCalendarProxyDateTime' => 'PhutilCalendarDateTime',
'PhutilCalendarRawNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarRecurrenceList' => 'PhutilCalendarRecurrenceSource',
'PhutilCalendarRecurrenceRule' => 'PhutilCalendarRecurrenceSource',
'PhutilCalendarRecurrenceRuleTestCase' => 'PhutilTestCase',
'PhutilCalendarRecurrenceSet' => 'Phobject',
'PhutilCalendarRecurrenceSource' => 'Phobject',
'PhutilCalendarRecurrenceTestCase' => 'PhutilTestCase',
'PhutilCalendarRelativeDateTime' => 'PhutilCalendarProxyDateTime',
'PhutilCalendarRootNode' => 'PhutilCalendarContainerNode',
'PhutilCalendarUserNode' => 'PhutilCalendarNode',
'PhutilCodeSnippetContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilDisqusAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilEmptyAuthAdapter' => 'PhutilAuthAdapter',
'PhutilFacebookAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilGitHubAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilGoogleAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilICSParser' => 'Phobject',
'PhutilICSParserException' => 'Exception',
'PhutilICSParserTestCase' => 'PhutilTestCase',
'PhutilICSWriter' => 'Phobject',
'PhutilICSWriterTestCase' => 'PhutilTestCase',
'PhutilJIRAAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilJavaCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar',
'PhutilLDAPAuthAdapter' => 'PhutilAuthAdapter',
'PhutilLipsumContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilOAuth1AuthAdapter' => 'PhutilAuthAdapter',
'PhutilOAuthAuthAdapter' => 'PhutilAuthAdapter',
'PhutilPHPCodeSnippetContextFreeGrammar' => 'PhutilCLikeCodeSnippetContextFreeGrammar',
'PhutilPhabricatorAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilQueryString' => 'Phobject',
'PhutilRealNameContextFreeGrammar' => 'PhutilContextFreeGrammar',
'PhutilRemarkupBlockInterpreter' => 'Phobject',
'PhutilRemarkupBlockRule' => 'Phobject',
'PhutilRemarkupBlockStorage' => 'Phobject',
'PhutilRemarkupBoldRule' => 'PhutilRemarkupRule',
'PhutilRemarkupCodeBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupDefaultBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupDelRule' => 'PhutilRemarkupRule',
'PhutilRemarkupDocumentLinkRule' => 'PhutilRemarkupRule',
'PhutilRemarkupEngine' => 'PhutilMarkupEngine',
'PhutilRemarkupEngineTestCase' => 'PhutilTestCase',
'PhutilRemarkupEscapeRemarkupRule' => 'PhutilRemarkupRule',
'PhutilRemarkupHeaderBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupHighlightRule' => 'PhutilRemarkupRule',
'PhutilRemarkupHorizontalRuleBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupHyperlinkEngineExtension' => 'Phobject',
'PhutilRemarkupHyperlinkRef' => 'Phobject',
'PhutilRemarkupHyperlinkRule' => 'PhutilRemarkupRule',
'PhutilRemarkupInlineBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupInterpreterBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupItalicRule' => 'PhutilRemarkupRule',
'PhutilRemarkupLinebreaksRule' => 'PhutilRemarkupRule',
'PhutilRemarkupListBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupLiteralBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupMonospaceRule' => 'PhutilRemarkupRule',
'PhutilRemarkupNoteBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupQuotedBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupQuotesBlockRule' => 'PhutilRemarkupQuotedBlockRule',
'PhutilRemarkupReplyBlockRule' => 'PhutilRemarkupQuotedBlockRule',
'PhutilRemarkupRule' => 'Phobject',
'PhutilRemarkupSimpleTableBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupTableBlockRule' => 'PhutilRemarkupBlockRule',
'PhutilRemarkupTestInterpreterRule' => 'PhutilRemarkupBlockInterpreter',
'PhutilRemarkupUnderlineRule' => 'PhutilRemarkupRule',
'PhutilSlackAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilTwitchAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PhutilTwitterAuthAdapter' => 'PhutilOAuth1AuthAdapter',
'PhutilWordPressAuthAdapter' => 'PhutilOAuthAuthAdapter',
'PolicyLockOptionType' => 'PhabricatorConfigJSONOptionType',
'PonderAddAnswerView' => 'AphrontView',
'PonderAnswer' => array(
@ -12265,6 +12506,7 @@ phutil_register_library_map(array(
'ProjectReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler',
'ProjectSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod',
'QueryFormattingTestCase' => 'PhabricatorTestCase',
'QueryFuture' => 'Future',
'ReleephAuthorFieldSpecification' => 'ReleephFieldSpecification',
'ReleephBranch' => array(
'ReleephDAO',

View file

@ -0,0 +1,80 @@
<?php
/**
* Authentication adapter for Amazon OAuth2.
*/
final class PhutilAmazonAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'amazon';
}
public function getAdapterDomain() {
return 'amazon.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('user_id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return null;
}
public function getAccountImageURI() {
return null;
}
public function getAccountURI() {
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://www.amazon.com/ap/oa';
}
protected function getTokenBaseURI() {
return 'https://api.amazon.com/auth/o2/token';
}
public function getScope() {
return 'profile';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://api.amazon.com/user/profile');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
list($body) = $future->resolvex();
try {
return phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Amazon account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,86 @@
<?php
/**
* Authentication adapter for Asana OAuth2.
*/
final class PhutilAsanaAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'asana';
}
public function getAdapterDomain() {
return 'asana.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return null;
}
public function getAccountImageURI() {
$photo = $this->getOAuthAccountData('photo', array());
if (is_array($photo)) {
return idx($photo, 'image_128x128');
} else {
return null;
}
}
public function getAccountURI() {
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://app.asana.com/-/oauth_authorize';
}
protected function getTokenBaseURI() {
return 'https://app.asana.com/-/oauth_token';
}
public function getScope() {
return null;
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
public function getExtraRefreshParameters() {
return array(
'grant_type' => 'refresh_token',
);
}
public function supportsTokenRefresh() {
return true;
}
protected function loadOAuthAccountData() {
return id(new PhutilAsanaFuture())
->setAccessToken($this->getAccessToken())
->setRawAsanaQuery('users/me')
->resolve();
}
}

View file

@ -0,0 +1,123 @@
<?php
/**
* Abstract interface to an identity provider or authentication source, like
* Twitter, Facebook or Google.
*
* Generally, adapters are handed some set of credentials particular to the
* provider they adapt, and they turn those credentials into standard
* information about the user's identity. For example, the LDAP adapter is given
* a username and password (and some other configuration information), uses them
* to talk to the LDAP server, and produces a username, email, and so forth.
*
* Since the credentials a provider requires are specific to each provider, the
* base adapter does not specify how an adapter should be constructed or
* configured -- only what information it is expected to be able to provide once
* properly configured.
*/
abstract class PhutilAuthAdapter extends Phobject {
/**
* Get a unique identifier associated with the identity. For most providers,
* this is an account ID.
*
* The account ID needs to be unique within this adapter's configuration, such
* that `<adapterKey, accountID>` is globally unique and always identifies the
* same identity.
*
* If the adapter was unable to authenticate an identity, it should return
* `null`.
*
* @return string|null Unique account identifier, or `null` if authentication
* failed.
*/
abstract public function getAccountID();
/**
* Get a string identifying this adapter, like "ldap". This string should be
* unique to the adapter class.
*
* @return string Unique adapter identifier.
*/
abstract public function getAdapterType();
/**
* Get a string identifying the domain this adapter is acting on. This allows
* an adapter (like LDAP) to act against different identity domains without
* conflating credentials. For providers like Facebook or Google, the adapters
* just return the relevant domain name.
*
* @return string Domain the adapter is associated with.
*/
abstract public function getAdapterDomain();
/**
* Generate a string uniquely identifying this adapter configuration. Within
* the scope of a given key, all account IDs must uniquely identify exactly
* one identity.
*
* @return string Unique identifier for this adapter configuration.
*/
public function getAdapterKey() {
return $this->getAdapterType().':'.$this->getAdapterDomain();
}
/**
* Optionally, return an email address associated with this account.
*
* @return string|null An email address associated with the account, or
* `null` if data is not available.
*/
public function getAccountEmail() {
return null;
}
/**
* Optionally, return a human readable username associated with this account.
*
* @return string|null Account username, or `null` if data isn't available.
*/
public function getAccountName() {
return null;
}
/**
* Optionally, return a URI corresponding to a human-viewable profile for
* this account.
*
* @return string|null A profile URI associated with this account, or
* `null` if the data isn't available.
*/
public function getAccountURI() {
return null;
}
/**
* Optionally, return a profile image URI associated with this account.
*
* @return string|null URI for an account profile image, or `null` if one is
* not available.
*/
public function getAccountImageURI() {
return null;
}
/**
* Optionally, return a real name associated with this account.
*
* @return string|null A human real name, or `null` if this data is not
* available.
*/
public function getAccountRealName() {
return null;
}
}

View file

@ -0,0 +1,73 @@
<?php
final class PhutilBitbucketAuthAdapter extends PhutilOAuth1AuthAdapter {
private $userInfo;
public function getAccountID() {
return idx($this->getUserInfo(), 'username');
}
public function getAccountName() {
return idx($this->getUserInfo(), 'display_name');
}
public function getAccountURI() {
$name = $this->getAccountID();
if (strlen($name)) {
return 'https://bitbucket.org/'.$name;
}
return null;
}
public function getAccountImageURI() {
return idx($this->getUserInfo(), 'avatar');
}
public function getAccountRealName() {
$parts = array(
idx($this->getUserInfo(), 'first_name'),
idx($this->getUserInfo(), 'last_name'),
);
$parts = array_filter($parts);
return implode(' ', $parts);
}
public function getAdapterType() {
return 'bitbucket';
}
public function getAdapterDomain() {
return 'bitbucket.org';
}
protected function getRequestTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/request_token';
}
protected function getAuthorizeTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/authenticate';
}
protected function getValidateTokenURI() {
return 'https://bitbucket.org/api/1.0/oauth/access_token';
}
private function getUserInfo() {
if ($this->userInfo === null) {
// We don't need any of the data in the handshake, but do need to
// finish the process. This makes sure we've completed the handshake.
$this->getHandshakeData();
$uri = new PhutilURI('https://bitbucket.org/api/1.0/user');
$data = $this->newOAuth1Future($uri)
->setMethod('GET')
->resolveJSON();
$this->userInfo = idx($data, 'user', array());
}
return $this->userInfo;
}
}

View file

@ -0,0 +1,84 @@
<?php
/**
* Authentication adapter for Disqus OAuth2.
*/
final class PhutilDisqusAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'disqus';
}
public function getAdapterDomain() {
return 'disqus.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('username');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar', 'permalink');
}
public function getAccountURI() {
return $this->getOAuthAccountData('profileUrl');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://disqus.com/api/oauth/2.0/authorize/';
}
protected function getTokenBaseURI() {
return 'https://disqus.com/api/oauth/2.0/access_token/';
}
public function getScope() {
return 'read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://disqus.com/api/3.0/users/details.json');
$uri->replaceQueryParam('api_key', $this->getClientID());
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$uri = (string)$uri;
$future = new HTTPSFuture($uri);
$future->setMethod('GET');
list($body) = $future->resolvex();
try {
$data = phutil_json_decode($body);
return $data['response'];
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Disqus account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,42 @@
<?php
/**
* Empty authentication adapter with no logic.
*
* This adapter can be used when you need an adapter for some technical reason
* but it doesn't make sense to put logic inside it.
*/
final class PhutilEmptyAuthAdapter extends PhutilAuthAdapter {
private $accountID;
private $adapterType;
private $adapterDomain;
public function setAdapterDomain($adapter_domain) {
$this->adapterDomain = $adapter_domain;
return $this;
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterType($adapter_type) {
$this->adapterType = $adapter_type;
return $this;
}
public function getAdapterType() {
return $this->adapterType;
}
public function setAccountID($account_id) {
$this->accountID = $account_id;
return $this;
}
public function getAccountID() {
return $this->accountID;
}
}

View file

@ -0,0 +1,114 @@
<?php
/**
* Authentication adapter for Facebook OAuth2.
*/
final class PhutilFacebookAuthAdapter extends PhutilOAuthAuthAdapter {
private $requireSecureBrowsing;
public function setRequireSecureBrowsing($require_secure_browsing) {
$this->requireSecureBrowsing = $require_secure_browsing;
return $this;
}
public function getAdapterType() {
return 'facebook';
}
public function getAdapterDomain() {
return 'facebook.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
$link = $this->getOAuthAccountData('link');
if (!$link) {
return null;
}
$matches = null;
if (!preg_match('@/([^/]+)$@', $link, $matches)) {
return null;
}
return $matches[1];
}
public function getAccountImageURI() {
$picture = $this->getOAuthAccountData('picture');
if ($picture) {
$picture_data = idx($picture, 'data');
if ($picture_data) {
return idx($picture_data, 'url');
}
}
return null;
}
public function getAccountURI() {
return $this->getOAuthAccountData('link');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
public function getAccountSecuritySettings() {
return $this->getOAuthAccountData('security_settings');
}
protected function getAuthenticateBaseURI() {
return 'https://www.facebook.com/dialog/oauth';
}
protected function getTokenBaseURI() {
return 'https://graph.facebook.com/oauth/access_token';
}
protected function loadOAuthAccountData() {
$fields = array(
'id',
'name',
'email',
'link',
'security_settings',
'picture',
);
$uri = new PhutilURI('https://graph.facebook.com/me');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$uri->replaceQueryParam('fields', implode(',', $fields));
list($body) = id(new HTTPSFuture($uri))->resolvex();
$data = null;
try {
$data = phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Facebook account data request.'),
$ex);
}
if ($this->requireSecureBrowsing) {
if (empty($data['security_settings']['secure_browsing']['enabled'])) {
throw new Exception(
pht(
'This Phabricator install requires you to enable Secure Browsing '.
'on your Facebook account in order to use it to log in to '.
'Phabricator. For more information, see %s',
'https://www.facebook.com/help/156201551113407/'));
}
}
return $data;
}
}

View file

@ -0,0 +1,72 @@
<?php
/**
* Authentication adapter for Github OAuth2.
*/
final class PhutilGitHubAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'github';
}
public function getAdapterDomain() {
return 'github.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('login');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar_url');
}
public function getAccountURI() {
$name = $this->getAccountName();
if (strlen($name)) {
return 'https://github.com/'.$name;
}
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://github.com/login/oauth/authorize';
}
protected function getTokenBaseURI() {
return 'https://github.com/login/oauth/access_token';
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://api.github.com/user');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
// NOTE: GitHub requires a User-Agent string.
$future->addHeader('User-Agent', __CLASS__);
list($body) = $future->resolvex();
try {
return phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from GitHub account data request.'),
$ex);
}
}
}

View file

@ -0,0 +1,105 @@
<?php
/**
* Authentication adapter for Google OAuth2.
*/
final class PhutilGoogleAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'google';
}
public function getAdapterDomain() {
return 'google.com';
}
public function getAccountID() {
return $this->getAccountEmail();
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
// Guess account name from email address, this is just a hint anyway.
$email = $this->getAccountEmail();
$email = explode('@', $email);
$email = head($email);
return $email;
}
public function getAccountImageURI() {
$uri = $this->getOAuthAccountData('picture');
// Change the "sz" parameter ("size") from the default to 100 to ask for
// a 100x100px image.
if ($uri !== null) {
$uri = new PhutilURI($uri);
$uri->replaceQueryParam('sz', 100);
$uri = (string)$uri;
}
return $uri;
}
public function getAccountURI() {
return $this->getOAuthAccountData('link');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('name');
}
protected function getAuthenticateBaseURI() {
return 'https://accounts.google.com/o/oauth2/auth';
}
protected function getTokenBaseURI() {
return 'https://accounts.google.com/o/oauth2/token';
}
public function getScope() {
$scopes = array(
'email',
'profile',
);
return implode(' ', $scopes);
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = new PhutilURI('https://www.googleapis.com/userinfo/v2/me');
$uri->replaceQueryParam('access_token', $this->getAccessToken());
$future = new HTTPSFuture($uri);
list($status, $body) = $future->resolve();
if ($status->isError()) {
throw $status;
}
try {
$result = phutil_json_decode($body);
} catch (PhutilJSONParserException $ex) {
throw new PhutilProxyException(
pht('Expected valid JSON response from Google account data request.'),
$ex);
}
return $result;
}
}

View file

@ -0,0 +1,164 @@
<?php
/**
* Authentication adapter for JIRA OAuth1.
*/
final class PhutilJIRAAuthAdapter extends PhutilOAuth1AuthAdapter {
// TODO: JIRA tokens expire (after 5 years) and we could surface and store
// that.
private $jiraBaseURI;
private $adapterDomain;
private $currentSession;
private $userInfo;
public function setJIRABaseURI($jira_base_uri) {
$this->jiraBaseURI = $jira_base_uri;
return $this;
}
public function getJIRABaseURI() {
return $this->jiraBaseURI;
}
public function getAccountID() {
// Make sure the handshake is finished; this method is used for its
// side effect by Auth providers.
$this->getHandshakeData();
return idx($this->getUserInfo(), 'key');
}
public function getAccountName() {
return idx($this->getUserInfo(), 'name');
}
public function getAccountImageURI() {
$avatars = idx($this->getUserInfo(), 'avatarUrls');
if ($avatars) {
return idx($avatars, '48x48');
}
return null;
}
public function getAccountRealName() {
return idx($this->getUserInfo(), 'displayName');
}
public function getAccountEmail() {
return idx($this->getUserInfo(), 'emailAddress');
}
public function getAdapterType() {
return 'jira';
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterDomain($domain) {
$this->adapterDomain = $domain;
return $this;
}
protected function getSignatureMethod() {
return 'RSA-SHA1';
}
protected function getRequestTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/request-token');
}
protected function getAuthorizeTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/authorize');
}
protected function getValidateTokenURI() {
return $this->getJIRAURI('plugins/servlet/oauth/access-token');
}
private function getJIRAURI($path) {
return rtrim($this->jiraBaseURI, '/').'/'.ltrim($path, '/');
}
private function getUserInfo() {
if ($this->userInfo === null) {
$this->currentSession = $this->newJIRAFuture('rest/auth/1/session', 'GET')
->resolveJSON();
// The session call gives us the username, but not the user key or other
// information. Make a second call to get additional information.
$params = array(
'username' => $this->currentSession['name'],
);
$this->userInfo = $this->newJIRAFuture('rest/api/2/user', 'GET', $params)
->resolveJSON();
}
return $this->userInfo;
}
public static function newJIRAKeypair() {
$config = array(
'digest_alg' => 'sha512',
'private_key_bits' => 4096,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
);
$res = openssl_pkey_new($config);
if (!$res) {
throw new Exception(pht('%s failed!', 'openssl_pkey_new()'));
}
$private_key = null;
$ok = openssl_pkey_export($res, $private_key);
if (!$ok) {
throw new Exception(pht('%s failed!', 'openssl_pkey_export()'));
}
$public_key = openssl_pkey_get_details($res);
if (!$ok || empty($public_key['key'])) {
throw new Exception(pht('%s failed!', 'openssl_pkey_get_details()'));
}
$public_key = $public_key['key'];
return array($public_key, $private_key);
}
/**
* JIRA indicates that the user has clicked the "Deny" button by passing a
* well known `oauth_verifier` value ("denied"), which we check for here.
*/
protected function willFinishOAuthHandshake() {
$jira_magic_word = 'denied';
if ($this->getVerifier() == $jira_magic_word) {
throw new PhutilAuthUserAbortedException();
}
}
public function newJIRAFuture($path, $method, $params = array()) {
if ($method == 'GET') {
$uri_params = $params;
$body_params = array();
} else {
// For other types of requests, JIRA expects the request body to be
// JSON encoded.
$uri_params = array();
$body_params = phutil_json_encode($params);
}
$uri = new PhutilURI($this->getJIRAURI($path), $uri_params);
// JIRA returns a 415 error if we don't provide a Content-Type header.
return $this->newOAuth1Future($uri, $body_params)
->setMethod($method)
->addHeader('Content-Type', 'application/json');
}
}

View file

@ -0,0 +1,505 @@
<?php
/**
* Retrieve identify information from LDAP accounts.
*/
final class PhutilLDAPAuthAdapter extends PhutilAuthAdapter {
private $hostname;
private $port = 389;
private $baseDistinguishedName;
private $searchAttributes = array();
private $usernameAttribute;
private $realNameAttributes = array();
private $ldapVersion = 3;
private $ldapReferrals;
private $ldapStartTLS;
private $anonymousUsername;
private $anonymousPassword;
private $activeDirectoryDomain;
private $alwaysSearch;
private $loginUsername;
private $loginPassword;
private $ldapUserData;
private $ldapConnection;
public function getAdapterType() {
return 'ldap';
}
public function setHostname($host) {
$this->hostname = $host;
return $this;
}
public function setPort($port) {
$this->port = $port;
return $this;
}
public function getAdapterDomain() {
return 'self';
}
public function setBaseDistinguishedName($base_distinguished_name) {
$this->baseDistinguishedName = $base_distinguished_name;
return $this;
}
public function setSearchAttributes(array $search_attributes) {
$this->searchAttributes = $search_attributes;
return $this;
}
public function setUsernameAttribute($username_attribute) {
$this->usernameAttribute = $username_attribute;
return $this;
}
public function setRealNameAttributes(array $attributes) {
$this->realNameAttributes = $attributes;
return $this;
}
public function setLDAPVersion($ldap_version) {
$this->ldapVersion = $ldap_version;
return $this;
}
public function setLDAPReferrals($ldap_referrals) {
$this->ldapReferrals = $ldap_referrals;
return $this;
}
public function setLDAPStartTLS($ldap_start_tls) {
$this->ldapStartTLS = $ldap_start_tls;
return $this;
}
public function setAnonymousUsername($anonymous_username) {
$this->anonymousUsername = $anonymous_username;
return $this;
}
public function setAnonymousPassword(
PhutilOpaqueEnvelope $anonymous_password) {
$this->anonymousPassword = $anonymous_password;
return $this;
}
public function setLoginUsername($login_username) {
$this->loginUsername = $login_username;
return $this;
}
public function setLoginPassword(PhutilOpaqueEnvelope $login_password) {
$this->loginPassword = $login_password;
return $this;
}
public function setActiveDirectoryDomain($domain) {
$this->activeDirectoryDomain = $domain;
return $this;
}
public function setAlwaysSearch($always_search) {
$this->alwaysSearch = $always_search;
return $this;
}
public function getAccountID() {
return $this->readLDAPRecordAccountID($this->getLDAPUserData());
}
public function getAccountName() {
return $this->readLDAPRecordAccountName($this->getLDAPUserData());
}
public function getAccountRealName() {
return $this->readLDAPRecordRealName($this->getLDAPUserData());
}
public function getAccountEmail() {
return $this->readLDAPRecordEmail($this->getLDAPUserData());
}
public function readLDAPRecordAccountID(array $record) {
$key = $this->usernameAttribute;
if (!strlen($key)) {
$key = head($this->searchAttributes);
}
return $this->readLDAPData($record, $key);
}
public function readLDAPRecordAccountName(array $record) {
return $this->readLDAPRecordAccountID($record);
}
public function readLDAPRecordRealName(array $record) {
$parts = array();
foreach ($this->realNameAttributes as $attribute) {
$parts[] = $this->readLDAPData($record, $attribute);
}
$parts = array_filter($parts);
if ($parts) {
return implode(' ', $parts);
}
return null;
}
public function readLDAPRecordEmail(array $record) {
return $this->readLDAPData($record, 'mail');
}
private function getLDAPUserData() {
if ($this->ldapUserData === null) {
$this->ldapUserData = $this->loadLDAPUserData();
}
return $this->ldapUserData;
}
private function readLDAPData(array $data, $key, $default = null) {
$list = idx($data, $key);
if ($list === null) {
// At least in some cases (and maybe in all cases) the results from
// ldap_search() are keyed in lowercase. If we missed on the first
// try, retry with a lowercase key.
$list = idx($data, phutil_utf8_strtolower($key));
}
// NOTE: In most cases, the property is an array, like:
//
// array(
// 'count' => 1,
// 0 => 'actual-value-we-want',
// )
//
// However, in at least the case of 'dn', the property is a bare string.
if (is_scalar($list) && strlen($list)) {
return $list;
} else if (is_array($list)) {
return $list[0];
} else {
return $default;
}
}
private function formatLDAPAttributeSearch($attribute, $login_user) {
// If the attribute contains the literal token "${login}", treat it as a
// query and substitute the user's login name for the token.
if (strpos($attribute, '${login}') !== false) {
$escaped_user = ldap_sprintf('%S', $login_user);
$attribute = str_replace('${login}', $escaped_user, $attribute);
return $attribute;
}
// Otherwise, treat it as a simple attribute search.
return ldap_sprintf(
'%Q=%S',
$attribute,
$login_user);
}
private function loadLDAPUserData() {
$conn = $this->establishConnection();
$login_user = $this->loginUsername;
$login_pass = $this->loginPassword;
if ($this->shouldBindWithoutIdentity()) {
$distinguished_name = null;
$search_query = null;
foreach ($this->searchAttributes as $attribute) {
$search_query = $this->formatLDAPAttributeSearch(
$attribute,
$login_user);
$record = $this->searchLDAPForRecord($search_query);
if ($record) {
$distinguished_name = $this->readLDAPData($record, 'dn');
break;
}
}
if ($distinguished_name === null) {
throw new PhutilAuthCredentialException();
}
} else {
$search_query = $this->formatLDAPAttributeSearch(
head($this->searchAttributes),
$login_user);
if ($this->activeDirectoryDomain) {
$distinguished_name = ldap_sprintf(
'%s@%Q',
$login_user,
$this->activeDirectoryDomain);
} else {
$distinguished_name = ldap_sprintf(
'%Q,%Q',
$search_query,
$this->baseDistinguishedName);
}
}
$this->bindLDAP($conn, $distinguished_name, $login_pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
// This is unusual (since the bind succeeded) but we've seen it at least
// once in the wild, where the anonymous user is allowed to search but
// the credentialed user is not.
// If we don't have anonymous credentials, raise an explicit exception
// here since we'll fail a typehint if we don't return an array anyway
// and this is a more useful error.
// If we do have anonymous credentials, we'll rebind and try the search
// again below. Doing this automatically means things work correctly more
// often without requiring additional configuration.
if (!$this->shouldBindWithoutIdentity()) {
// No anonymous credentials, so we just fail here.
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching. '.
'Credentialed users may not be able to search your LDAP server. '.
'Try configuring anonymous credentials or fully anonymous binds.',
$login_user));
} else {
// Rebind as anonymous and try the search again.
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
$result = $this->searchLDAPForRecord($search_query);
if (!$result) {
throw new Exception(
pht(
'LDAP: Failed to retrieve record for user "%s" when searching '.
'with both user and anonymous credentials.',
$login_user));
}
}
}
return $result;
}
private function establishConnection() {
if (!$this->ldapConnection) {
$host = $this->hostname;
$port = $this->port;
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'connect',
'host' => $host,
'port' => $this->port,
));
$conn = @ldap_connect($host, $this->port);
$profiler->endServiceCall(
$call_id,
array(
'ok' => (bool)$conn,
));
if (!$conn) {
throw new Exception(
pht('Unable to connect to LDAP server (%s:%d).', $host, $port));
}
$options = array(
LDAP_OPT_PROTOCOL_VERSION => (int)$this->ldapVersion,
LDAP_OPT_REFERRALS => (int)$this->ldapReferrals,
);
foreach ($options as $name => $value) {
$ok = @ldap_set_option($conn, $name, $value);
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht(
"Unable to set LDAP option '%s' to value '%s'!",
$name,
$value));
}
}
if ($this->ldapStartTLS) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'start-tls',
));
// NOTE: This boils down to a function call to ldap_start_tls_s() in
// C, which is a service call.
$ok = @ldap_start_tls($conn);
$profiler->endServiceCall(
$call_id,
array());
if (!$ok) {
$this->raiseConnectionException(
$conn,
pht('Unable to start TLS connection when connecting to LDAP.'));
}
}
if ($this->shouldBindWithoutIdentity()) {
$user = $this->anonymousUsername;
$pass = $this->anonymousPassword;
$this->bindLDAP($conn, $user, $pass);
}
$this->ldapConnection = $conn;
}
return $this->ldapConnection;
}
private function searchLDAPForRecord($dn) {
$conn = $this->establishConnection();
$results = $this->searchLDAP('%Q', $dn);
if (!$results) {
return null;
}
if (count($results) > 1) {
throw new Exception(
pht(
'LDAP record query returned more than one result. The query must '.
'uniquely identify a record.'));
}
return head($results);
}
public function searchLDAP($pattern /* ... */) {
$args = func_get_args();
$query = call_user_func_array('ldap_sprintf', $args);
$conn = $this->establishConnection();
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'search',
'dn' => $this->baseDistinguishedName,
'query' => $query,
));
$result = @ldap_search($conn, $this->baseDistinguishedName, $query);
$profiler->endServiceCall($call_id, array());
if (!$result) {
$this->raiseConnectionException(
$conn,
pht('LDAP search failed.'));
}
$entries = @ldap_get_entries($conn, $result);
if (!$entries) {
$this->raiseConnectionException(
$conn,
pht('Failed to get LDAP entries from search result.'));
}
$results = array();
for ($ii = 0; $ii < $entries['count']; $ii++) {
$results[] = $entries[$ii];
}
return $results;
}
private function raiseConnectionException($conn, $message) {
$errno = @ldap_errno($conn);
$error = @ldap_error($conn);
// This is `LDAP_INVALID_CREDENTIALS`.
if ($errno == 49) {
throw new PhutilAuthCredentialException();
}
if ($errno || $error) {
$full_message = pht(
"LDAP Exception: %s\nLDAP Error #%d: %s",
$message,
$errno,
$error);
} else {
$full_message = pht(
'LDAP Exception: %s',
$message);
}
throw new Exception($full_message);
}
private function bindLDAP($conn, $user, PhutilOpaqueEnvelope $pass) {
$profiler = PhutilServiceProfiler::getInstance();
$call_id = $profiler->beginServiceCall(
array(
'type' => 'ldap',
'call' => 'bind',
'user' => $user,
));
// NOTE: ldap_bind() dumps cleartext passwords into logs by default. Keep
// it quiet.
if (strlen($user)) {
$ok = @ldap_bind($conn, $user, $pass->openEnvelope());
} else {
$ok = @ldap_bind($conn);
}
$profiler->endServiceCall($call_id, array());
if (!$ok) {
if (strlen($user)) {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (as user "%s").', $user));
} else {
$this->raiseConnectionException(
$conn,
pht('Failed to bind to LDAP server (without username).'));
}
}
}
/**
* Determine if this adapter should attempt to bind to the LDAP server
* without a user identity.
*
* Generally, we can bind directly if we have a username/password, or if the
* "Always Search" flag is set, indicating that the empty username and
* password are sufficient.
*
* @return bool True if the adapter should perform binds without identity.
*/
private function shouldBindWithoutIdentity() {
return $this->alwaysSearch || strlen($this->anonymousUsername);
}
}

View file

@ -0,0 +1,211 @@
<?php
/**
* Abstract adapter for OAuth1 providers.
*/
abstract class PhutilOAuth1AuthAdapter extends PhutilAuthAdapter {
private $consumerKey;
private $consumerSecret;
private $token;
private $tokenSecret;
private $verifier;
private $handshakeData;
private $callbackURI;
private $privateKey;
public function setPrivateKey(PhutilOpaqueEnvelope $private_key) {
$this->privateKey = $private_key;
return $this;
}
public function getPrivateKey() {
return $this->privateKey;
}
public function setCallbackURI($callback_uri) {
$this->callbackURI = $callback_uri;
return $this;
}
public function getCallbackURI() {
return $this->callbackURI;
}
public function setVerifier($verifier) {
$this->verifier = $verifier;
return $this;
}
public function getVerifier() {
return $this->verifier;
}
public function setConsumerSecret(PhutilOpaqueEnvelope $consumer_secret) {
$this->consumerSecret = $consumer_secret;
return $this;
}
public function getConsumerSecret() {
return $this->consumerSecret;
}
public function setConsumerKey($consumer_key) {
$this->consumerKey = $consumer_key;
return $this;
}
public function getConsumerKey() {
return $this->consumerKey;
}
public function setTokenSecret($token_secret) {
$this->tokenSecret = $token_secret;
return $this;
}
public function getTokenSecret() {
return $this->tokenSecret;
}
public function setToken($token) {
$this->token = $token;
return $this;
}
public function getToken() {
return $this->token;
}
protected function getHandshakeData() {
if ($this->handshakeData === null) {
$this->finishOAuthHandshake();
}
return $this->handshakeData;
}
abstract protected function getRequestTokenURI();
abstract protected function getAuthorizeTokenURI();
abstract protected function getValidateTokenURI();
protected function getSignatureMethod() {
return 'HMAC-SHA1';
}
public function getContentSecurityPolicyFormActions() {
return array(
$this->getAuthorizeTokenURI(),
);
}
protected function newOAuth1Future($uri, $data = array()) {
$future = id(new PhutilOAuth1Future($uri, $data))
->setMethod('POST')
->setSignatureMethod($this->getSignatureMethod());
$consumer_key = $this->getConsumerKey();
if (strlen($consumer_key)) {
$future->setConsumerKey($consumer_key);
} else {
throw new Exception(
pht(
'%s is required!',
'setConsumerKey()'));
}
$consumer_secret = $this->getConsumerSecret();
if ($consumer_secret) {
$future->setConsumerSecret($consumer_secret);
}
if (strlen($this->getToken())) {
$future->setToken($this->getToken());
}
if (strlen($this->getTokenSecret())) {
$future->setTokenSecret($this->getTokenSecret());
}
if ($this->getPrivateKey()) {
$future->setPrivateKey($this->getPrivateKey());
}
return $future;
}
public function getClientRedirectURI() {
$request_token_uri = $this->getRequestTokenURI();
$future = $this->newOAuth1Future($request_token_uri);
if (strlen($this->getCallbackURI())) {
$future->setCallbackURI($this->getCallbackURI());
}
list($body) = $future->resolvex();
$data = id(new PhutilQueryStringParser())->parseQueryString($body);
// NOTE: Per the spec, this value MUST be the string 'true'.
$confirmed = idx($data, 'oauth_callback_confirmed');
if ($confirmed !== 'true') {
throw new Exception(
pht("Expected '%s' to be '%s'!", 'oauth_callback_confirmed', 'true'));
}
$this->readTokenAndTokenSecret($data);
$authorize_token_uri = new PhutilURI($this->getAuthorizeTokenURI());
$authorize_token_uri->replaceQueryParam('oauth_token', $this->getToken());
return (string)$authorize_token_uri;
}
protected function finishOAuthHandshake() {
$this->willFinishOAuthHandshake();
if (!$this->getToken()) {
throw new Exception(pht('Expected token to finish OAuth handshake!'));
}
if (!$this->getVerifier()) {
throw new Exception(pht('Expected verifier to finish OAuth handshake!'));
}
$validate_uri = $this->getValidateTokenURI();
$params = array(
'oauth_verifier' => $this->getVerifier(),
);
list($body) = $this->newOAuth1Future($validate_uri, $params)->resolvex();
$data = id(new PhutilQueryStringParser())->parseQueryString($body);
$this->readTokenAndTokenSecret($data);
$this->handshakeData = $data;
}
private function readTokenAndTokenSecret(array $data) {
$token = idx($data, 'oauth_token');
if (!$token) {
throw new Exception(pht("Expected '%s' in response!", 'oauth_token'));
}
$token_secret = idx($data, 'oauth_token_secret');
if (!$token_secret) {
throw new Exception(
pht("Expected '%s' in response!", 'oauth_token_secret'));
}
$this->setToken($token);
$this->setTokenSecret($token_secret);
return $this;
}
/**
* Hook that allows subclasses to take actions before the OAuth handshake
* is completed.
*/
protected function willFinishOAuthHandshake() {
return;
}
}

View file

@ -0,0 +1,228 @@
<?php
/**
* Abstract adapter for OAuth2 providers.
*/
abstract class PhutilOAuthAuthAdapter extends PhutilAuthAdapter {
private $clientID;
private $clientSecret;
private $redirectURI;
private $scope;
private $state;
private $code;
private $accessTokenData;
private $oauthAccountData;
abstract protected function getAuthenticateBaseURI();
abstract protected function getTokenBaseURI();
abstract protected function loadOAuthAccountData();
public function getAuthenticateURI() {
$params = array(
'client_id' => $this->getClientID(),
'scope' => $this->getScope(),
'redirect_uri' => $this->getRedirectURI(),
'state' => $this->getState(),
) + $this->getExtraAuthenticateParameters();
$uri = new PhutilURI($this->getAuthenticateBaseURI(), $params);
return phutil_string_cast($uri);
}
public function getAdapterType() {
$this_class = get_class($this);
$type_name = str_replace('PhutilAuthAdapterOAuth', '', $this_class);
return strtolower($type_name);
}
public function setState($state) {
$this->state = $state;
return $this;
}
public function getState() {
return $this->state;
}
public function setCode($code) {
$this->code = $code;
return $this;
}
public function getCode() {
return $this->code;
}
public function setRedirectURI($redirect_uri) {
$this->redirectURI = $redirect_uri;
return $this;
}
public function getRedirectURI() {
return $this->redirectURI;
}
public function getExtraAuthenticateParameters() {
return array();
}
public function getExtraTokenParameters() {
return array();
}
public function getExtraRefreshParameters() {
return array();
}
public function setScope($scope) {
$this->scope = $scope;
return $this;
}
public function getScope() {
return $this->scope;
}
public function setClientSecret(PhutilOpaqueEnvelope $client_secret) {
$this->clientSecret = $client_secret;
return $this;
}
public function getClientSecret() {
return $this->clientSecret;
}
public function setClientID($client_id) {
$this->clientID = $client_id;
return $this;
}
public function getClientID() {
return $this->clientID;
}
public function getAccessToken() {
return $this->getAccessTokenData('access_token');
}
public function getAccessTokenExpires() {
return $this->getAccessTokenData('expires_epoch');
}
public function getRefreshToken() {
return $this->getAccessTokenData('refresh_token');
}
protected function getAccessTokenData($key, $default = null) {
if ($this->accessTokenData === null) {
$this->accessTokenData = $this->loadAccessTokenData();
}
return idx($this->accessTokenData, $key, $default);
}
public function supportsTokenRefresh() {
return false;
}
public function refreshAccessToken($refresh_token) {
$this->accessTokenData = $this->loadRefreshTokenData($refresh_token);
return $this;
}
protected function loadRefreshTokenData($refresh_token) {
$params = array(
'refresh_token' => $refresh_token,
) + $this->getExtraRefreshParameters();
// NOTE: Make sure we return the refresh_token so that subsequent
// calls to getRefreshToken() return it; providers normally do not echo
// it back for token refresh requests.
return $this->makeTokenRequest($params) + array(
'refresh_token' => $refresh_token,
);
}
protected function loadAccessTokenData() {
$code = $this->getCode();
if (!$code) {
throw new PhutilInvalidStateException('setCode');
}
$params = array(
'code' => $this->getCode(),
) + $this->getExtraTokenParameters();
return $this->makeTokenRequest($params);
}
private function makeTokenRequest(array $params) {
$uri = $this->getTokenBaseURI();
$query_data = array(
'client_id' => $this->getClientID(),
'client_secret' => $this->getClientSecret()->openEnvelope(),
'redirect_uri' => $this->getRedirectURI(),
) + $params;
$future = new HTTPSFuture($uri, $query_data);
$future->setMethod('POST');
list($body) = $future->resolvex();
$data = $this->readAccessTokenResponse($body);
if (isset($data['expires_in'])) {
$data['expires_epoch'] = $data['expires_in'];
} else if (isset($data['expires'])) {
$data['expires_epoch'] = $data['expires'];
}
// If we got some "expires" value back, interpret it as an epoch timestamp
// if it's after the year 2010 and as a relative number of seconds
// otherwise.
if (isset($data['expires_epoch'])) {
if ($data['expires_epoch'] < (60 * 60 * 24 * 365 * 40)) {
$data['expires_epoch'] += time();
}
}
if (isset($data['error'])) {
throw new Exception(pht('Access token error: %s', $data['error']));
}
return $data;
}
protected function readAccessTokenResponse($body) {
// NOTE: Most providers either return JSON or HTTP query strings, so try
// both mechanisms. If your provider does something else, override this
// method.
$data = json_decode($body, true);
if (!is_array($data)) {
$data = array();
parse_str($body, $data);
}
if (empty($data['access_token']) &&
empty($data['error'])) {
throw new Exception(
pht('Failed to decode OAuth access token response: %s', $body));
}
return $data;
}
protected function getOAuthAccountData($key, $default = null) {
if ($this->oauthAccountData === null) {
$this->oauthAccountData = $this->loadOAuthAccountData();
}
return idx($this->oauthAccountData, $key, $default);
}
}

View file

@ -0,0 +1,102 @@
<?php
/**
* Authentication adapter for Phabricator OAuth2.
*/
final class PhutilPhabricatorAuthAdapter extends PhutilOAuthAuthAdapter {
private $phabricatorBaseURI;
private $adapterDomain;
public function setPhabricatorBaseURI($uri) {
$this->phabricatorBaseURI = $uri;
return $this;
}
public function getPhabricatorBaseURI() {
return $this->phabricatorBaseURI;
}
public function getAdapterDomain() {
return $this->adapterDomain;
}
public function setAdapterDomain($domain) {
$this->adapterDomain = $domain;
return $this;
}
public function getAdapterType() {
return 'phabricator';
}
public function getAccountID() {
return $this->getOAuthAccountData('phid');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('primaryEmail');
}
public function getAccountName() {
return $this->getOAuthAccountData('userName');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('image');
}
public function getAccountURI() {
return $this->getOAuthAccountData('uri');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('realName');
}
protected function getAuthenticateBaseURI() {
return $this->getPhabricatorURI('oauthserver/auth/');
}
protected function getTokenBaseURI() {
return $this->getPhabricatorURI('oauthserver/token/');
}
public function getScope() {
return '';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
$uri = id(new PhutilURI($this->getPhabricatorURI('api/user.whoami')))
->replaceQueryParam('access_token', $this->getAccessToken());
list($body) = id(new HTTPSFuture($uri))->resolvex();
try {
$data = phutil_json_decode($body);
return $data['result'];
} catch (PhutilJSONParserException $ex) {
throw new Exception(
pht(
'Expected valid JSON response from Phabricator %s request.',
'user.whoami'),
$ex);
}
}
private function getPhabricatorURI($path) {
return rtrim($this->phabricatorBaseURI, '/').'/'.ltrim($path, '/');
}
}

View file

@ -0,0 +1,61 @@
<?php
/**
* Authentication adapter for Slack OAuth2.
*/
final class PhutilSlackAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'Slack';
}
public function getAdapterDomain() {
return 'slack.com';
}
public function getAccountID() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'id');
}
public function getAccountEmail() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'email');
}
public function getAccountImageURI() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'image_512');
}
public function getAccountRealName() {
$user = $this->getOAuthAccountData('user');
return idx($user, 'name');
}
protected function getAuthenticateBaseURI() {
return 'https://slack.com/oauth/authorize';
}
protected function getTokenBaseURI() {
return 'https://slack.com/api/oauth.access';
}
public function getScope() {
return 'identity.basic,identity.team,identity.avatar';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilSlackFuture())
->setAccessToken($this->getAccessToken())
->setRawSlackQuery('users.identity')
->resolve();
}
}

View file

@ -0,0 +1,76 @@
<?php
/**
* Authentication adapter for Twitch.tv OAuth2.
*/
final class PhutilTwitchAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'twitch';
}
public function getAdapterDomain() {
return 'twitch.tv';
}
public function getAccountID() {
return $this->getOAuthAccountData('_id');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('name');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('logo');
}
public function getAccountURI() {
$name = $this->getAccountName();
if ($name) {
return 'http://www.twitch.tv/'.$name;
}
return null;
}
public function getAccountRealName() {
return $this->getOAuthAccountData('display_name');
}
protected function getAuthenticateBaseURI() {
return 'https://api.twitch.tv/kraken/oauth2/authorize';
}
protected function getTokenBaseURI() {
return 'https://api.twitch.tv/kraken/oauth2/token';
}
public function getScope() {
return 'user_read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilTwitchFuture())
->setClientID($this->getClientID())
->setAccessToken($this->getAccessToken())
->setRawTwitchQuery('user')
->resolve();
}
}

View file

@ -0,0 +1,75 @@
<?php
/**
* Authentication adapter for Twitter OAuth1.
*/
final class PhutilTwitterAuthAdapter extends PhutilOAuth1AuthAdapter {
private $userInfo;
public function getAccountID() {
return idx($this->getHandshakeData(), 'user_id');
}
public function getAccountName() {
return idx($this->getHandshakeData(), 'screen_name');
}
public function getAccountURI() {
$name = $this->getAccountName();
if (strlen($name)) {
return 'https://twitter.com/'.$name;
}
return null;
}
public function getAccountImageURI() {
$info = $this->getUserInfo();
return idx($info, 'profile_image_url');
}
public function getAccountRealName() {
$info = $this->getUserInfo();
return idx($info, 'name');
}
public function getAdapterType() {
return 'twitter';
}
public function getAdapterDomain() {
return 'twitter.com';
}
protected function getRequestTokenURI() {
return 'https://api.twitter.com/oauth/request_token';
}
protected function getAuthorizeTokenURI() {
return 'https://api.twitter.com/oauth/authorize';
}
protected function getValidateTokenURI() {
return 'https://api.twitter.com/oauth/access_token';
}
private function getUserInfo() {
if ($this->userInfo === null) {
$params = array(
'user_id' => $this->getAccountID(),
);
$uri = new PhutilURI(
'https://api.twitter.com/1.1/users/show.json',
$params);
$data = $this->newOAuth1Future($uri)
->setMethod('GET')
->resolveJSON();
$this->userInfo = $data;
}
return $this->userInfo;
}
}

View file

@ -0,0 +1,73 @@
<?php
/**
* Authentication adapter for WordPress.com OAuth2.
*/
final class PhutilWordPressAuthAdapter extends PhutilOAuthAuthAdapter {
public function getAdapterType() {
return 'wordpress';
}
public function getAdapterDomain() {
return 'wordpress.com';
}
public function getAccountID() {
return $this->getOAuthAccountData('ID');
}
public function getAccountEmail() {
return $this->getOAuthAccountData('email');
}
public function getAccountName() {
return $this->getOAuthAccountData('username');
}
public function getAccountImageURI() {
return $this->getOAuthAccountData('avatar_URL');
}
public function getAccountURI() {
return $this->getOAuthAccountData('profile_URL');
}
public function getAccountRealName() {
return $this->getOAuthAccountData('display_name');
}
protected function getAuthenticateBaseURI() {
return 'https://public-api.wordpress.com/oauth2/authorize';
}
protected function getTokenBaseURI() {
return 'https://public-api.wordpress.com/oauth2/token';
}
public function getScope() {
return 'user_read';
}
public function getExtraAuthenticateParameters() {
return array(
'response_type' => 'code',
'blog_id' => 0,
);
}
public function getExtraTokenParameters() {
return array(
'grant_type' => 'authorization_code',
);
}
protected function loadOAuthAccountData() {
return id(new PhutilWordPressFuture())
->setClientID($this->getClientID())
->setAccessToken($this->getAccessToken())
->setRawWordPressQuery('/me/')
->resolve();
}
}

View file

@ -68,12 +68,42 @@ final class PhabricatorLogoutController
->setURI('/auth/loggedout/');
}
if ($viewer->getPHID()) {
return $this->newDialog()
$dialog = $this->newDialog()
->setTitle(pht('Log Out?'))
->appendChild(pht('Are you sure you want to log out?'))
->addSubmitButton(pht('Log Out'))
->appendParagraph(pht('Are you sure you want to log out?'))
->addCancelButton('/');
$configs = id(new PhabricatorAuthProviderConfigQuery())
->setViewer(PhabricatorUser::getOmnipotentUser())
->execute();
if (!$configs) {
$dialog
->appendRemarkup(
pht(
'WARNING: You have not configured any authentication providers '.
'yet, so your account has no login credentials. If you log out '.
'now, you will not be able to log back in normally.'))
->appendParagraph(
pht(
'To enable the login flow, follow setup guidance and configure '.
'at least one authentication provider, then associate '.
'credentials with your account. After completing these steps, '.
'you will be able to log out and log back in normally.'))
->appendParagraph(
pht(
'If you log out now, you can still regain access to your '.
'account later by using the account recovery workflow. The '.
'login screen will prompt you with recovery instructions.'));
$button = pht('Log Out Anyway');
} else {
$button = pht('Log Out');
}
$dialog->addSubmitButton($button);
return $dialog;
}
return id(new AphrontRedirectResponse())->setURI('/');

View file

@ -64,7 +64,7 @@ final class PhabricatorAuthListController
array(
'href' => $this->getApplicationURI('config/new/'),
),
pht('Add Authentication Provider'))));
pht('Add Provider'))));
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(pht('Login and Registration'));

View file

@ -0,0 +1,6 @@
<?php
/**
* Authentication is not configured correctly.
*/
final class PhutilAuthConfigurationException extends PhutilAuthException {}

View file

@ -0,0 +1,6 @@
<?php
/**
* The user provided invalid credentials.
*/
final class PhutilAuthCredentialException extends PhutilAuthException {}

View file

@ -0,0 +1,7 @@
<?php
/**
* Abstract exception class for errors encountered during authentication
* workflows.
*/
abstract class PhutilAuthException extends Exception {}

View file

@ -0,0 +1,14 @@
<?php
/**
* The user aborted the authentication workflow, by clicking "Cancel" or "Deny"
* or taking some similar action.
*
* For example, in OAuth/OAuth2 workflows, the authentication provider
* generally presents the user with a confirmation dialog with two options,
* "Approve" and "Deny".
*
* If an adapter detects that the user has explicitly bailed out of the
* workflow, it should throw this exception.
*/
final class PhutilAuthUserAbortedException extends PhutilAuthException {}

View file

@ -0,0 +1,287 @@
<?php
final class PhutilCalendarAbsoluteDateTime
extends PhutilCalendarDateTime {
private $year;
private $month;
private $day;
private $hour = 0;
private $minute = 0;
private $second = 0;
private $timezone;
public static function newFromISO8601($value, $timezone = 'UTC') {
$pattern =
'/^'.
'(?P<y>\d{4})(?P<m>\d{2})(?P<d>\d{2})'.
'(?:'.
'T(?P<h>\d{2})(?P<i>\d{2})(?P<s>\d{2})(?<z>Z)?'.
')?'.
'\z/';
$matches = null;
$ok = preg_match($pattern, $value, $matches);
if (!$ok) {
throw new Exception(
pht(
'Expected ISO8601 datetime in the format "19990105T112233Z", '.
'found "%s".',
$value));
}
if (isset($matches['z'])) {
if ($timezone != 'UTC') {
throw new Exception(
pht(
'ISO8601 date ends in "Z" indicating UTC, but a timezone other '.
'than UTC ("%s") was specified.',
$timezone));
}
}
$datetime = id(new self())
->setYear((int)$matches['y'])
->setMonth((int)$matches['m'])
->setDay((int)$matches['d'])
->setTimezone($timezone);
if (isset($matches['h'])) {
$datetime
->setHour((int)$matches['h'])
->setMinute((int)$matches['i'])
->setSecond((int)$matches['s']);
} else {
$datetime
->setIsAllDay(true);
}
return $datetime;
}
public static function newFromEpoch($epoch, $timezone = 'UTC') {
$date = new DateTime('@'.$epoch);
$zone = new DateTimeZone($timezone);
$date->setTimezone($zone);
return id(new self())
->setYear((int)$date->format('Y'))
->setMonth((int)$date->format('m'))
->setDay((int)$date->format('d'))
->setHour((int)$date->format('H'))
->setMinute((int)$date->format('i'))
->setSecond((int)$date->format('s'))
->setTimezone($timezone);
}
public static function newFromDictionary(array $dict) {
static $keys;
if ($keys === null) {
$keys = array_fuse(
array(
'kind',
'year',
'month',
'day',
'hour',
'minute',
'second',
'timezone',
'isAllDay',
));
}
foreach ($dict as $key => $value) {
if (!isset($keys[$key])) {
throw new Exception(
pht(
'Unexpected key "%s" in datetime dictionary, expected keys: %s.',
$key,
implode(', ', array_keys($keys))));
}
}
if (idx($dict, 'kind') !== 'absolute') {
throw new Exception(
pht(
'Expected key "%s" with value "%s" in datetime dictionary.',
'kind',
'absolute'));
}
if (!isset($dict['year'])) {
throw new Exception(
pht(
'Expected key "%s" in datetime dictionary.',
'year'));
}
$datetime = id(new self())
->setYear(idx($dict, 'year'))
->setMonth(idx($dict, 'month', 1))
->setDay(idx($dict, 'day', 1))
->setHour(idx($dict, 'hour', 0))
->setMinute(idx($dict, 'minute', 0))
->setSecond(idx($dict, 'second', 0))
->setTimezone(idx($dict, 'timezone'))
->setIsAllDay((bool)idx($dict, 'isAllDay', false));
return $datetime;
}
public function newRelativeDateTime($duration) {
if (is_string($duration)) {
$duration = PhutilCalendarDuration::newFromISO8601($duration);
}
if (!($duration instanceof PhutilCalendarDuration)) {
throw new Exception(
pht(
'Expected "PhutilCalendarDuration" object or ISO8601 duration '.
'string.'));
}
return id(new PhutilCalendarRelativeDateTime())
->setOrigin($this)
->setDuration($duration);
}
public function toDictionary() {
return array(
'kind' => 'absolute',
'year' => (int)$this->getYear(),
'month' => (int)$this->getMonth(),
'day' => (int)$this->getDay(),
'hour' => (int)$this->getHour(),
'minute' => (int)$this->getMinute(),
'second' => (int)$this->getSecond(),
'timezone' => $this->getTimezone(),
'isAllDay' => (bool)$this->getIsAllDay(),
);
}
public function setYear($year) {
$this->year = $year;
return $this;
}
public function getYear() {
return $this->year;
}
public function setMonth($month) {
$this->month = $month;
return $this;
}
public function getMonth() {
return $this->month;
}
public function setDay($day) {
$this->day = $day;
return $this;
}
public function getDay() {
return $this->day;
}
public function setHour($hour) {
$this->hour = $hour;
return $this;
}
public function getHour() {
return $this->hour;
}
public function setMinute($minute) {
$this->minute = $minute;
return $this;
}
public function getMinute() {
return $this->minute;
}
public function setSecond($second) {
$this->second = $second;
return $this;
}
public function getSecond() {
return $this->second;
}
public function setTimezone($timezone) {
$this->timezone = $timezone;
return $this;
}
public function getTimezone() {
return $this->timezone;
}
private function getEffectiveTimezone() {
$date_timezone = $this->getTimezone();
$viewer_timezone = $this->getViewerTimezone();
// Because all-day events are always "floating", the effective timezone
// is the viewer timezone if it is available. Otherwise, we'll return a
// DateTime object with the correct values, but it will be incorrectly
// adjusted forward or backward to the viewer's zone later.
$zones = array();
if ($this->getIsAllDay()) {
$zones[] = $viewer_timezone;
$zones[] = $date_timezone;
} else {
$zones[] = $date_timezone;
$zones[] = $viewer_timezone;
}
$zones = array_filter($zones);
if (!$zones) {
throw new Exception(
pht(
'Datetime has no timezone or viewer timezone.'));
}
return head($zones);
}
public function newPHPDateTimeZone() {
$zone = $this->getEffectiveTimezone();
return new DateTimeZone($zone);
}
public function newPHPDateTime() {
$zone = $this->newPHPDateTimeZone();
$y = $this->getYear();
$m = $this->getMonth();
$d = $this->getDay();
if ($this->getIsAllDay()) {
$h = 0;
$i = 0;
$s = 0;
} else {
$h = $this->getHour();
$i = $this->getMinute();
$s = $this->getSecond();
}
$format = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $y, $m, $d, $h, $i, $s);
return new DateTime($format, $zone);
}
public function newAbsoluteDateTime() {
return clone $this;
}
}

View file

@ -0,0 +1,30 @@
<?php
abstract class PhutilCalendarContainerNode
extends PhutilCalendarNode {
private $children = array();
final public function getChildren() {
return $this->children;
}
final public function getChildrenOfType($type) {
$result = array();
foreach ($this->getChildren() as $key => $child) {
if ($child->getNodeType() != $type) {
continue;
}
$result[$key] = $child;
}
return $result;
}
final public function appendChild(PhutilCalendarNode $node) {
$this->children[] = $node;
return $this;
}
}

View file

@ -0,0 +1,54 @@
<?php
abstract class PhutilCalendarDateTime
extends Phobject {
private $viewerTimezone;
private $isAllDay = false;
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function setIsAllDay($is_all_day) {
$this->isAllDay = $is_all_day;
return $this;
}
public function getIsAllDay() {
return $this->isAllDay;
}
public function getEpoch() {
$datetime = $this->newPHPDateTime();
return (int)$datetime->format('U');
}
public function getISO8601() {
$datetime = $this->newPHPDateTime();
if ($this->getIsAllDay()) {
return $datetime->format('Ymd');
} else if ($this->getTimezone()) {
// With a timezone, the event occurs at a specific second universally.
// We return the UTC representation of that point in time.
$datetime->setTimezone(new DateTimeZone('UTC'));
return $datetime->format('Ymd\\THis\\Z');
} else {
// With no timezone, events are "floating" and occur at local time.
// We return a representation without the "Z".
return $datetime->format('Ymd\\THis');
}
}
abstract public function newPHPDateTimeZone();
abstract public function newPHPDateTime();
abstract public function newAbsoluteDateTime();
abstract public function getTimezone();
}

View file

@ -0,0 +1,12 @@
<?php
final class PhutilCalendarDocumentNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'document';
public function getEvents() {
return $this->getChildrenOfType(PhutilCalendarEventNode::NODETYPE);
}
}

View file

@ -0,0 +1,181 @@
<?php
final class PhutilCalendarDuration extends Phobject {
private $isNegative = false;
private $weeks = 0;
private $days = 0;
private $hours = 0;
private $minutes = 0;
private $seconds = 0;
public static function newFromDictionary(array $dict) {
static $keys;
if ($keys === null) {
$keys = array_fuse(
array(
'isNegative',
'weeks',
'days',
'hours',
'minutes',
'seconds',
));
}
foreach ($dict as $key => $value) {
if (!isset($keys[$key])) {
throw new Exception(
pht(
'Unexpected key "%s" in duration dictionary, expected keys: %s.',
$key,
implode(', ', array_keys($keys))));
}
}
$duration = id(new self())
->setIsNegative(idx($dict, 'isNegative', false))
->setWeeks(idx($dict, 'weeks', 0))
->setDays(idx($dict, 'days', 0))
->setHours(idx($dict, 'hours', 0))
->setMinutes(idx($dict, 'minutes', 0))
->setSeconds(idx($dict, 'seconds', 0));
return $duration;
}
public function toDictionary() {
return array(
'isNegative' => $this->getIsNegative(),
'weeks' => $this->getWeeks(),
'days' => $this->getDays(),
'hours' => $this->getHours(),
'minutes' => $this->getMinutes(),
'seconds' => $this->getSeconds(),
);
}
public static function newFromISO8601($value) {
$pattern =
'/^'.
'(?P<sign>[+-])?'.
'P'.
'(?:'.
'(?P<W>\d+)W'.
'|'.
'(?:(?:(?P<D>\d+)D)?'.
'(?:T(?:(?P<H>\d+)H)?(?:(?P<M>\d+)M)?(?:(?P<S>\d+)S)?)?'.
')'.
')'.
'\z/';
$matches = null;
$ok = preg_match($pattern, $value, $matches);
if (!$ok) {
throw new Exception(
pht(
'Expected ISO8601 duration in the format "P12DT3H4M5S", found '.
'"%s".',
$value));
}
$is_negative = (idx($matches, 'sign') == '-');
return id(new self())
->setIsNegative($is_negative)
->setWeeks((int)idx($matches, 'W', 0))
->setDays((int)idx($matches, 'D', 0))
->setHours((int)idx($matches, 'H', 0))
->setMinutes((int)idx($matches, 'M', 0))
->setSeconds((int)idx($matches, 'S', 0));
}
public function toISO8601() {
$parts = array();
$parts[] = 'P';
$weeks = $this->getWeeks();
if ($weeks) {
$parts[] = $weeks.'W';
} else {
$days = $this->getDays();
if ($days) {
$parts[] = $days.'D';
}
$parts[] = 'T';
$hours = $this->getHours();
if ($hours) {
$parts[] = $hours.'H';
}
$minutes = $this->getMinutes();
if ($minutes) {
$parts[] = $minutes.'M';
}
$seconds = $this->getSeconds();
if ($seconds) {
$parts[] = $seconds.'S';
}
}
return implode('', $parts);
}
public function setIsNegative($is_negative) {
$this->isNegative = $is_negative;
return $this;
}
public function getIsNegative() {
return $this->isNegative;
}
public function setWeeks($weeks) {
$this->weeks = $weeks;
return $this;
}
public function getWeeks() {
return $this->weeks;
}
public function setDays($days) {
$this->days = $days;
return $this;
}
public function getDays() {
return $this->days;
}
public function setHours($hours) {
$this->hours = $hours;
return $this;
}
public function getHours() {
return $this->hours;
}
public function setMinutes($minutes) {
$this->minutes = $minutes;
return $this;
}
public function getMinutes() {
return $this->minutes;
}
public function setSeconds($seconds) {
$this->seconds = $seconds;
return $this;
}
public function getSeconds() {
return $this->seconds;
}
}

View file

@ -0,0 +1,172 @@
<?php
final class PhutilCalendarEventNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'event';
private $uid;
private $name;
private $description;
private $startDateTime;
private $endDateTime;
private $duration;
private $createdDateTime;
private $modifiedDateTime;
private $organizer;
private $attendees = array();
private $recurrenceRule;
private $recurrenceExceptions = array();
private $recurrenceDates = array();
private $recurrenceID;
public function setUID($uid) {
$this->uid = $uid;
return $this;
}
public function getUID() {
return $this->uid;
}
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setDescription($description) {
$this->description = $description;
return $this;
}
public function getDescription() {
return $this->description;
}
public function setStartDateTime(PhutilCalendarDateTime $start) {
$this->startDateTime = $start;
return $this;
}
public function getStartDateTime() {
return $this->startDateTime;
}
public function setEndDateTime(PhutilCalendarDateTime $end) {
$this->endDateTime = $end;
return $this;
}
public function getEndDateTime() {
$end = $this->endDateTime;
if ($end) {
return $end;
}
$start = $this->getStartDateTime();
$duration = $this->getDuration();
if ($start && $duration) {
return id(new PhutilCalendarRelativeDateTime())
->setOrigin($start)
->setDuration($duration);
}
// If no end date or duration are specified, the event is instantaneous.
return $start;
}
public function setDuration(PhutilCalendarDuration $duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function setCreatedDateTime(PhutilCalendarDateTime $created) {
$this->createdDateTime = $created;
return $this;
}
public function getCreatedDateTime() {
return $this->createdDateTime;
}
public function setModifiedDateTime(PhutilCalendarDateTime $modified) {
$this->modifiedDateTime = $modified;
return $this;
}
public function getModifiedDateTime() {
return $this->modifiedDateTime;
}
public function setOrganizer(PhutilCalendarUserNode $organizer) {
$this->organizer = $organizer;
return $this;
}
public function getOrganizer() {
return $this->organizer;
}
public function setAttendees(array $attendees) {
assert_instances_of($attendees, 'PhutilCalendarUserNode');
$this->attendees = $attendees;
return $this;
}
public function getAttendees() {
return $this->attendees;
}
public function addAttendee(PhutilCalendarUserNode $attendee) {
$this->attendees[] = $attendee;
return $this;
}
public function setRecurrenceRule(
PhutilCalendarRecurrenceRule $recurrence_rule) {
$this->recurrenceRule = $recurrence_rule;
return $this;
}
public function getRecurrenceRule() {
return $this->recurrenceRule;
}
public function setRecurrenceExceptions(array $recurrence_exceptions) {
assert_instances_of($recurrence_exceptions, 'PhutilCalendarDateTime');
$this->recurrenceExceptions = $recurrence_exceptions;
return $this;
}
public function getRecurrenceExceptions() {
return $this->recurrenceExceptions;
}
public function setRecurrenceDates(array $recurrence_dates) {
assert_instances_of($recurrence_dates, 'PhutilCalendarDateTime');
$this->recurrenceDates = $recurrence_dates;
return $this;
}
public function getRecurrenceDates() {
return $this->recurrenceDates;
}
public function setRecurrenceID($recurrence_id) {
$this->recurrenceID = $recurrence_id;
return $this;
}
public function getRecurrenceID() {
return $this->recurrenceID;
}
}

View file

@ -0,0 +1,20 @@
<?php
abstract class PhutilCalendarNode extends Phobject {
private $attributes = array();
final public function getNodeType() {
return $this->getPhobjectClassConstant('NODETYPE');
}
final public function setAttribute($key, $value) {
$this->attributes[$key] = $value;
return $this;
}
final public function getAttribute($key, $default = null) {
return idx($this->attributes, $key, $default);
}
}

View file

@ -0,0 +1,51 @@
<?php
abstract class PhutilCalendarProxyDateTime
extends PhutilCalendarDateTime {
private $proxy;
final protected function setProxy(PhutilCalendarDateTime $proxy) {
$this->proxy = $proxy;
return $this;
}
final protected function getProxy() {
return $this->proxy;
}
public function __clone() {
$this->proxy = clone $this->proxy;
}
public function setViewerTimezone($timezone) {
$this->getProxy()->setViewerTimezone($timezone);
return $this;
}
public function getViewerTimezone() {
return $this->getProxy()->getViewerTimezone();
}
public function setIsAllDay($is_all_day) {
$this->getProxy()->setIsAllDay($is_all_day);
return $this;
}
public function getIsAllDay() {
return $this->getProxy()->getIsAllDay();
}
public function newPHPDateTimezone() {
return $this->getProxy()->newPHPDateTimezone();
}
public function newPHPDateTime() {
return $this->getProxy()->newPHPDateTime();
}
public function getTimezone() {
return $this->getProxy()->getTimezone();
}
}

View file

@ -0,0 +1,8 @@
<?php
final class PhutilCalendarRawNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'raw';
}

View file

@ -0,0 +1,43 @@
<?php
final class PhutilCalendarRecurrenceList
extends PhutilCalendarRecurrenceSource {
private $dates = array();
private $order;
public function setDates(array $dates) {
assert_instances_of($dates, 'PhutilCalendarDateTime');
$this->dates = $dates;
return $this;
}
public function getDates() {
return $this->dates;
}
public function resetSource() {
foreach ($this->getDates() as $date) {
$date->setViewerTimezone($this->getViewerTimezone());
}
$order = msort($this->getDates(), 'getEpoch');
$order = array_reverse($order);
$this->order = $order;
return $this;
}
public function getNextEvent($cursor) {
while ($this->order) {
$next = array_pop($this->order);
if ($next->getEpoch() >= $cursor) {
return $next;
}
}
return null;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,162 @@
<?php
final class PhutilCalendarRecurrenceSet
extends Phobject {
private $sources = array();
private $viewerTimezone = 'UTC';
public function addSource(PhutilCalendarRecurrenceSource $source) {
$this->sources[] = $source;
return $this;
}
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function getEventsBetween(
PhutilCalendarDateTime $start = null,
PhutilCalendarDateTime $end = null,
$limit = null) {
if ($end === null && $limit === null) {
throw new Exception(
pht(
'Recurring event range queries must have an end date, a limit, or '.
'both.'));
}
$timezone = $this->getViewerTimezone();
$sources = array();
foreach ($this->sources as $source) {
$source = clone $source;
$source->setViewerTimezone($timezone);
$source->resetSource();
$sources[] = array(
'source' => $source,
'state' => null,
'epoch' => null,
);
}
if ($start) {
$start = clone $start;
$start->setViewerTimezone($timezone);
$min_epoch = $start->getEpoch();
} else {
$min_epoch = 0;
}
if ($end) {
$end = clone $end;
$end->setViewerTimezone($timezone);
$end_epoch = $end->getEpoch();
} else {
$end_epoch = null;
}
$results = array();
$index = 0;
$cursor = 0;
while (true) {
// Get the next event for each source which we don't have a future
// event for.
foreach ($sources as $key => $source) {
$state = $source['state'];
$epoch = $source['epoch'];
if ($state !== null && $epoch >= $cursor) {
// We have an event for this source, and it's a future event, so
// we don't need to do anything.
continue;
}
$next = $source['source']->getNextEvent($cursor);
if ($next === null) {
// This source doesn't have any more events, so we're all done.
unset($sources[$key]);
continue;
}
$next_epoch = $next->getEpoch();
if ($end_epoch !== null && $next_epoch > $end_epoch) {
// We have an end time and the next event from this source is
// past that end, so we know there are no more relevant events
// coming from this source.
unset($sources[$key]);
continue;
}
$sources[$key]['state'] = $next;
$sources[$key]['epoch'] = $next_epoch;
}
if (!$sources) {
// We've run out of sources which can produce valid events in the
// window, so we're all done.
break;
}
// Find the minimum event time across all sources.
$next_epoch = null;
foreach ($sources as $source) {
if ($next_epoch === null) {
$next_epoch = $source['epoch'];
} else {
$next_epoch = min($next_epoch, $source['epoch']);
}
}
$is_exception = false;
$next_source = null;
foreach ($sources as $source) {
if ($source['epoch'] == $next_epoch) {
if ($source['source']->getIsExceptionSource()) {
$is_exception = true;
} else {
$next_source = $source;
}
}
}
// If this is an exception, it means the event does NOT occur. We
// skip it and move on. If it's not an exception, it does occur, so
// we record it.
if (!$is_exception) {
// Only actually include this event in the results if it starts after
// any specified start time. We increment the index regardless, so we
// return results with proper offsets.
if ($next_source['epoch'] >= $min_epoch) {
$results[$index] = $next_source['state'];
}
$index++;
if ($limit !== null && (count($results) >= $limit)) {
break;
}
}
$cursor = $next_epoch + 1;
// If we have an end of the window and we've reached it, we're done.
if ($end_epoch) {
if ($cursor > $end_epoch) {
break;
}
}
}
return $results;
}
}

View file

@ -0,0 +1,34 @@
<?php
abstract class PhutilCalendarRecurrenceSource
extends Phobject {
private $isExceptionSource;
private $viewerTimezone;
public function setIsExceptionSource($is_exception_source) {
$this->isExceptionSource = $is_exception_source;
return $this;
}
public function getIsExceptionSource() {
return $this->isExceptionSource;
}
public function setViewerTimezone($viewer_timezone) {
$this->viewerTimezone = $viewer_timezone;
return $this;
}
public function getViewerTimezone() {
return $this->viewerTimezone;
}
public function resetSource() {
return;
}
abstract public function getNextEvent($cursor);
}

View file

@ -0,0 +1,74 @@
<?php
final class PhutilCalendarRelativeDateTime
extends PhutilCalendarProxyDateTime {
private $duration;
public function setOrigin(PhutilCalendarDateTime $origin) {
return $this->setProxy($origin);
}
public function getOrigin() {
return $this->getProxy();
}
public function setDuration(PhutilCalendarDuration $duration) {
$this->duration = $duration;
return $this;
}
public function getDuration() {
return $this->duration;
}
public function newPHPDateTime() {
$datetime = parent::newPHPDateTime();
$duration = $this->getDuration();
if ($duration->getIsNegative()) {
$sign = '-';
} else {
$sign = '+';
}
$map = array(
'weeks' => $duration->getWeeks(),
'days' => $duration->getDays(),
'hours' => $duration->getHours(),
'minutes' => $duration->getMinutes(),
'seconds' => $duration->getSeconds(),
);
foreach ($map as $unit => $value) {
if (!$value) {
continue;
}
$datetime->modify("{$sign}{$value} {$unit}");
}
return $datetime;
}
public function newAbsoluteDateTime() {
$clone = clone $this;
if ($clone->getTimezone()) {
$clone->setViewerTimezone(null);
}
$datetime = $clone->newPHPDateTime();
return id(new PhutilCalendarAbsoluteDateTime())
->setYear((int)$datetime->format('Y'))
->setMonth((int)$datetime->format('m'))
->setDay((int)$datetime->format('d'))
->setHour((int)$datetime->format('H'))
->setMinute((int)$datetime->format('i'))
->setSecond((int)$datetime->format('s'))
->setIsAllDay($clone->getIsAllDay())
->setTimezone($clone->getTimezone())
->setViewerTimezone($this->getViewerTimezone());
}
}

View file

@ -0,0 +1,12 @@
<?php
final class PhutilCalendarRootNode
extends PhutilCalendarContainerNode {
const NODETYPE = 'root';
public function getDocuments() {
return $this->getChildrenOfType(PhutilCalendarDocumentNode::NODETYPE);
}
}

View file

@ -0,0 +1,40 @@
<?php
final class PhutilCalendarUserNode extends PhutilCalendarNode {
private $name;
private $uri;
private $status;
const STATUS_INVITED = 'invited';
const STATUS_ACCEPTED = 'accepted';
const STATUS_DECLINED = 'declined';
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setURI($uri) {
$this->uri = $uri;
return $this;
}
public function getURI() {
return $this->uri;
}
public function setStatus($status) {
$this->status = $status;
return $this;
}
public function getStatus() {
return $this->status;
}
}

View file

@ -0,0 +1,49 @@
<?php
final class PhutilCalendarDateTimeTestCase extends PhutilTestCase {
public function testDateTimeDuration() {
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128T090000Z')
->setTimezone('America/Los_Angeles')
->setViewerTimezone('America/Chicago')
->setIsAllDay(true);
$this->assertEqual(
'20161128',
$start->getISO8601());
$end = $start
->newAbsoluteDateTime()
->setHour(0)
->setMinute(0)
->setSecond(0)
->newRelativeDateTime('P1D')
->newAbsoluteDateTime();
$this->assertEqual(
'20161129',
$end->getISO8601());
// This is a date which explicitly has no specified timezone.
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20161128', null)
->setViewerTimezone('UTC');
$this->assertEqual(
'20161128',
$start->getISO8601());
$end = $start
->newAbsoluteDateTime()
->setHour(0)
->setMinute(0)
->setSecond(0)
->newRelativeDateTime('P1D')
->newAbsoluteDateTime();
$this->assertEqual(
'20161129',
$end->getISO8601());
}
}

View file

@ -0,0 +1,196 @@
<?php
final class PhutilCalendarRecurrenceTestCase extends PhutilTestCase {
public function testCalendarRecurrenceLists() {
$set = id(new PhutilCalendarRecurrenceSet());
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
array(),
$result,
pht('Set with no sources.'));
$set = id(new PhutilCalendarRecurrenceSet())
->addSource(new PhutilCalendarRecurrenceList());
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
array(),
$result,
pht('Set with empty list source.'));
$list = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source = id(new PhutilCalendarRecurrenceList())
->setDates($list);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
);
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Simple date list.'));
$list_a = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
);
$list_b = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source_a = id(new PhutilCalendarRecurrenceList())
->setDates($list_a);
$source_b = id(new PhutilCalendarRecurrenceList())
->setDates($list_b);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source_a)
->addSource($source_b);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
);
$result = $set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple date lists.'));
$list_a = array(
// This is Jan 1, 3, 5, 7, 8 and 10, but listed out-of-order.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'),
);
$list_b = array(
// This is Jan 2, 4, 5, 8, but listed out of order.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
);
$list_c = array(
// We're going to use this as an exception list.
// This is Jan 7 (listed in one other source), 8 (listed in two)
// and 9 (listed in none).
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160107T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160108T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160109T120000Z'),
);
$expect = array(
// From source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
// From source B.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
// From source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
// From source B.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
// From source A and B. Should appear only once.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160105T120000Z'),
// The 6th appears in no source.
// The 7th, 8th and 9th are excluded.
// The 10th is from source A.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160110T120000Z'),
);
$list_a = id(new PhutilCalendarRecurrenceList())
->setDates($list_a);
$list_b = id(new PhutilCalendarRecurrenceList())
->setDates($list_b);
$list_c = id(new PhutilCalendarRecurrenceList())
->setDates($list_c)
->setIsExceptionSource(true);
$date_set = id(new PhutilCalendarRecurrenceSet())
->addSource($list_b)
->addSource($list_c)
->addSource($list_a);
$date_set->setViewerTimezone('UTC');
$result = $date_set->getEventsBetween(null, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Set of all results in multiple lists with exclusions.'));
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
);
$result = $date_set->getEventsBetween(null, null, 1);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple lists, one result.'));
$expect = array(
2 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
3 => PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'),
);
$result = $date_set->getEventsBetween(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160104T120000Z'));
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Multiple lists, time window.'));
}
public function testCalendarRecurrenceOffsets() {
$list = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160101T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120000Z'),
);
$source = id(new PhutilCalendarRecurrenceList())
->setDates($list);
$set = id(new PhutilCalendarRecurrenceSet())
->addSource($source);
$t1 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160102T120001Z');
$t2 = PhutilCalendarAbsoluteDateTime::newFromISO8601('20160103T120000Z');
$expect = array(
2 => $t2,
);
$result = $set->getEventsBetween($t1, null, 0xFFFF);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Correct event indexes with start date.'));
}
}

View file

@ -0,0 +1,919 @@
<?php
final class PhutilICSParser extends Phobject {
private $stack;
private $node;
private $document;
private $lines;
private $cursor;
private $warnings;
const PARSE_MISSING_END = 'missing-end';
const PARSE_INITIAL_UNFOLD = 'initial-unfold';
const PARSE_UNEXPECTED_CHILD = 'unexpected-child';
const PARSE_EXTRA_END = 'extra-end';
const PARSE_MISMATCHED_SECTIONS = 'mismatched-sections';
const PARSE_ROOT_PROPERTY = 'root-property';
const PARSE_BAD_BASE64 = 'bad-base64';
const PARSE_BAD_BOOLEAN = 'bad-boolean';
const PARSE_UNEXPECTED_TEXT = 'unexpected-text';
const PARSE_MALFORMED_DOUBLE_QUOTE = 'malformed-double-quote';
const PARSE_MALFORMED_PARAMETER_NAME = 'malformed-parameter';
const PARSE_MALFORMED_PROPERTY = 'malformed-property';
const PARSE_MISSING_VALUE = 'missing-value';
const PARSE_UNESCAPED_BACKSLASH = 'unescaped-backslash';
const PARSE_MULTIPLE_PARAMETERS = 'multiple-parameters';
const PARSE_EMPTY_DATETIME = 'empty-datetime';
const PARSE_MANY_DATETIME = 'many-datetime';
const PARSE_BAD_DATETIME = 'bad-datetime';
const PARSE_EMPTY_DURATION = 'empty-duration';
const PARSE_MANY_DURATION = 'many-duration';
const PARSE_BAD_DURATION = 'bad-duration';
const WARN_TZID_UTC = 'warn-tzid-utc';
const WARN_TZID_GUESS = 'warn-tzid-guess';
const WARN_TZID_IGNORED = 'warn-tzid-ignored';
public function parseICSData($data) {
$this->stack = array();
$this->node = null;
$this->cursor = null;
$this->warnings = array();
$lines = $this->unfoldICSLines($data);
$this->lines = $lines;
$root = $this->newICSNode('<ROOT>');
$this->stack[] = $root;
$this->node = $root;
foreach ($lines as $key => $line) {
$this->cursor = $key;
$matches = null;
if (preg_match('(^BEGIN:(.*)\z)', $line, $matches)) {
$this->beginParsingNode($matches[1]);
} else if (preg_match('(^END:(.*)\z)', $line, $matches)) {
$this->endParsingNode($matches[1]);
} else {
if (count($this->stack) < 2) {
$this->raiseParseFailure(
self::PARSE_ROOT_PROPERTY,
pht(
'Found unexpected property at ICS document root.'));
}
$this->parseICSProperty($line);
}
}
if (count($this->stack) > 1) {
$this->raiseParseFailure(
self::PARSE_MISSING_END,
pht(
'Expected all "BEGIN:" sections in ICS document to have '.
'corresponding "END:" sections.'));
}
$this->node = null;
$this->lines = null;
$this->cursor = null;
return $root;
}
private function getNode() {
return $this->node;
}
private function unfoldICSLines($data) {
$lines = phutil_split_lines($data, $retain_endings = false);
$this->lines = $lines;
// ICS files are wrapped at 75 characters, with overlong lines continued
// on the following line with an initial space or tab. Unwrap all of the
// lines in the file.
// This unwrapping is specifically byte-oriented, not character oriented,
// and RFC5545 anticipates that simple implementations may even split UTF8
// characters in the middle.
$last = null;
foreach ($lines as $idx => $line) {
$this->cursor = $idx;
if (!preg_match('/^[ \t]/', $line)) {
$last = $idx;
continue;
}
if ($last === null) {
$this->raiseParseFailure(
self::PARSE_INITIAL_UNFOLD,
pht(
'First line of ICS file begins with a space or tab, but this '.
'marks a line which should be unfolded.'));
}
$lines[$last] = $lines[$last].substr($line, 1);
unset($lines[$idx]);
}
return $lines;
}
private function beginParsingNode($type) {
$node = $this->getNode();
$new_node = $this->newICSNode($type);
if ($node instanceof PhutilCalendarContainerNode) {
$node->appendChild($new_node);
} else {
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_CHILD,
pht(
'Found unexpected node "%s" inside node "%s".',
$new_node->getAttribute('ics.type'),
$node->getAttribute('ics.type')));
}
$this->stack[] = $new_node;
$this->node = $new_node;
return $this;
}
private function newICSNode($type) {
switch ($type) {
case '<ROOT>':
$node = new PhutilCalendarRootNode();
break;
case 'VCALENDAR':
$node = new PhutilCalendarDocumentNode();
break;
case 'VEVENT':
$node = new PhutilCalendarEventNode();
break;
default:
$node = new PhutilCalendarRawNode();
break;
}
$node->setAttribute('ics.type', $type);
return $node;
}
private function endParsingNode($type) {
$node = $this->getNode();
if ($node instanceof PhutilCalendarRootNode) {
$this->raiseParseFailure(
self::PARSE_EXTRA_END,
pht(
'Found unexpected "END" without a "BEGIN".'));
}
$old_type = $node->getAttribute('ics.type');
if ($old_type != $type) {
$this->raiseParseFailure(
self::PARSE_MISMATCHED_SECTIONS,
pht(
'Found mismatched "BEGIN" ("%s") and "END" ("%s") sections.',
$old_type,
$type));
}
array_pop($this->stack);
$this->node = last($this->stack);
return $this;
}
private function parseICSProperty($line) {
$matches = null;
// Properties begin with an alphanumeric name with no escaping, followed
// by either a ";" (to begin a list of parameters) or a ":" (to begin
// the actual field body).
$ok = preg_match('(^([A-Za-z0-9-]+)([;:])(.*)\z)', $line, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PROPERTY,
pht(
'Found malformed property in ICS document.'));
}
$name = $matches[1];
$body = $matches[3];
$has_parameters = ($matches[2] == ';');
$parameters = array();
if ($has_parameters) {
// Parameters are a sensible name, a literal "=", a pile of magic,
// and then maybe a comma and another parameter.
while (true) {
// We're going to get the first couple of parts first.
$ok = preg_match('(^([^=]+)=)', $body, $matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_PARAMETER_NAME,
pht(
'Found malformed property in ICS document: %s',
$body));
}
$param_name = $matches[1];
$body = substr($body, strlen($matches[0]));
// Now we're going to match zero or more values.
$param_values = array();
while (true) {
// The value can either be a double-quoted string or an unquoted
// string, with some characters forbidden.
if (strlen($body) && $body[0] == '"') {
$is_quoted = true;
$ok = preg_match(
'(^"([^\x00-\x08\x10-\x19"]*)")',
$body,
$matches);
if (!$ok) {
$this->raiseParseFailure(
self::PARSE_MALFORMED_DOUBLE_QUOTE,
pht(
'Found malformed double-quoted string in ICS document '.
'parameter value.'));
}
} else {
$is_quoted = false;
// It's impossible for this not to match since it can match
// nothing, and it's valid for it to match nothing.
preg_match('(^([^\x00-\x08\x10-\x19";:,]*))', $body, $matches);
}
// NOTE: RFC5545 says "Property parameter values that are not in
// quoted-strings are case-insensitive." -- that is, the quoted and
// unquoted representations are not equivalent. Thus, preserve the
// original formatting in case we ever need to respect this.
$param_values[] = array(
'value' => $this->unescapeParameterValue($matches[1]),
'quoted' => $is_quoted,
);
$body = substr($body, strlen($matches[0]));
if (!strlen($body)) {
$this->raiseParseFailure(
self::PARSE_MISSING_VALUE,
pht(
'Expected ":" after parameters in ICS document property.'));
}
// If we have a comma now, we're going to read another value. Strip
// it off and keep going.
if ($body[0] == ',') {
$body = substr($body, 1);
continue;
}
// If we have a semicolon, we're going to read another parameter.
if ($body[0] == ';') {
break;
}
// If we have a colon, this is the last value and also the last
// property. Break, then handle the colon below.
if ($body[0] == ':') {
break;
}
$short_body = id(new PhutilUTF8StringTruncator())
->setMaximumGlyphs(32)
->truncateString($body);
// We aren't expecting anything else.
$this->raiseParseFailure(
self::PARSE_UNEXPECTED_TEXT,
pht(
'Found unexpected text ("%s") after reading parameter value.',
$short_body));
}
$parameters[] = array(
'name' => $param_name,
'values' => $param_values,
);
if ($body[0] == ';') {
$body = substr($body, 1);
continue;
}
if ($body[0] == ':') {
$body = substr($body, 1);
break;
}
}
}
$value = $this->unescapeFieldValue($name, $parameters, $body);
$node = $this->getNode();
$raw = $node->getAttribute('ics.properties', array());
$raw[] = array(
'name' => $name,
'parameters' => $parameters,
'value' => $value,
);
$node->setAttribute('ics.properties', $raw);
switch ($node->getAttribute('ics.type')) {
case 'VEVENT':
$this->didParseEventProperty($node, $name, $parameters, $value);
break;
}
}
private function unescapeParameterValue($data) {
// The parameter grammar is adjusted by RFC6868 to permit escaping with
// carets. Remove that escaping.
// This escaping is a bit weird because it's trying to be backwards
// compatible and the original spec didn't think about this and didn't
// provide much room to fix things.
$out = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c != '^') {
$out .= $c;
} else {
$esc = true;
}
} else {
switch ($c) {
case 'n':
$out .= "\n";
break;
case '^':
$out .= '^';
break;
case "'":
// NOTE: This is "<caret> <single quote>" being decoded into a
// double quote!
$out .= '"';
break;
default:
// NOTE: The caret is NOT an escape for any other characters.
// This is a "MUST" requirement of RFC6868.
$out .= '^'.$c;
break;
}
}
}
// NOTE: Because caret on its own just means "caret" for backward
// compatibility, we don't warn if we're still in escaped mode once we
// reach the end of the string.
return $out;
}
private function unescapeFieldValue($name, array $parameters, $data) {
// NOTE: The encoding of the field value data is dependent on the field
// name (which defines a default encoding) and the parameters (which may
// include "VALUE", specifying a type of the data.
$default_types = array(
'CALSCALE' => 'TEXT',
'METHOD' => 'TEXT',
'PRODID' => 'TEXT',
'VERSION' => 'TEXT',
'ATTACH' => 'URI',
'CATEGORIES' => 'TEXT',
'CLASS' => 'TEXT',
'COMMENT' => 'TEXT',
'DESCRIPTION' => 'TEXT',
// TODO: The spec appears to contradict itself: it says that the value
// type is FLOAT, but it also says that this property value is actually
// two semicolon-separated values, which is not what FLOAT is defined as.
'GEO' => 'TEXT',
'LOCATION' => 'TEXT',
'PERCENT-COMPLETE' => 'INTEGER',
'PRIORITY' => 'INTEGER',
'RESOURCES' => 'TEXT',
'STATUS' => 'TEXT',
'SUMMARY' => 'TEXT',
'COMPLETED' => 'DATE-TIME',
'DTEND' => 'DATE-TIME',
'DUE' => 'DATE-TIME',
'DTSTART' => 'DATE-TIME',
'DURATION' => 'DURATION',
'FREEBUSY' => 'PERIOD',
'TRANSP' => 'TEXT',
'TZID' => 'TEXT',
'TZNAME' => 'TEXT',
'TZOFFSETFROM' => 'UTC-OFFSET',
'TZOFFSETTO' => 'UTC-OFFSET',
'TZURL' => 'URI',
'ATTENDEE' => 'CAL-ADDRESS',
'CONTACT' => 'TEXT',
'ORGANIZER' => 'CAL-ADDRESS',
'RECURRENCE-ID' => 'DATE-TIME',
'RELATED-TO' => 'TEXT',
'URL' => 'URI',
'UID' => 'TEXT',
'EXDATE' => 'DATE-TIME',
'RDATE' => 'DATE-TIME',
'RRULE' => 'RECUR',
'ACTION' => 'TEXT',
'REPEAT' => 'INTEGER',
'TRIGGER' => 'DURATION',
'CREATED' => 'DATE-TIME',
'DTSTAMP' => 'DATE-TIME',
'LAST-MODIFIED' => 'DATE-TIME',
'SEQUENCE' => 'INTEGER',
'REQUEST-STATUS' => 'TEXT',
);
$value_type = idx($default_types, $name, 'TEXT');
foreach ($parameters as $parameter) {
if ($parameter['name'] == 'VALUE') {
$value_type = idx(head($parameter['values']), 'value');
}
}
switch ($value_type) {
case 'BINARY':
$result = base64_decode($data, true);
if ($result === false) {
$this->raiseParseFailure(
self::PARSE_BAD_BASE64,
pht(
'Unable to decode base64 data: %s',
$data));
}
break;
case 'BOOLEAN':
$map = array(
'true' => true,
'false' => false,
);
$result = phutil_utf8_strtolower($data);
if (!isset($map[$result])) {
$this->raiseParseFailure(
self::PARSE_BAD_BOOLEAN,
pht(
'Unexpected BOOLEAN value "%s".',
$data));
}
$result = $map[$result];
break;
case 'CAL-ADDRESS':
$result = $data;
break;
case 'DATE':
// This is a comma-separated list of "YYYYMMDD" values.
$result = explode(',', $data);
break;
case 'DATE-TIME':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'DURATION':
if (!strlen($data)) {
$result = array();
} else {
$result = explode(',', $data);
}
break;
case 'FLOAT':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (float)$v;
}
break;
case 'INTEGER':
$result = explode(',', $data);
foreach ($result as $k => $v) {
$result[$k] = (int)$v;
}
break;
case 'PERIOD':
$result = explode(',', $data);
break;
case 'RECUR':
$result = $data;
break;
case 'TEXT':
$result = $this->unescapeTextValue($data);
break;
case 'TIME':
$result = explode(',', $data);
break;
case 'URI':
$result = $data;
break;
case 'UTC-OFFSET':
$result = $data;
break;
default:
// RFC5545 says we MUST preserve the data for any types we don't
// recognize.
$result = $data;
break;
}
return array(
'type' => $value_type,
'value' => $result,
'raw' => $data,
);
}
private function unescapeTextValue($data) {
$result = array();
$buf = '';
$esc = false;
foreach (phutil_utf8v($data) as $c) {
if (!$esc) {
if ($c == '\\') {
$esc = true;
} else if ($c == ',') {
$result[] = $buf;
$buf = '';
} else {
$buf .= $c;
}
} else {
switch ($c) {
case 'n':
case 'N':
$buf .= "\n";
break;
default:
$buf .= $c;
break;
}
$esc = false;
}
}
if ($esc) {
$this->raiseParseFailure(
self::PARSE_UNESCAPED_BACKSLASH,
pht(
'ICS document contains TEXT value ending with unescaped '.
'backslash.'));
}
$result[] = $buf;
return $result;
}
private function raiseParseFailure($code, $message) {
if ($this->lines && isset($this->lines[$this->cursor])) {
$message = pht(
"ICS Parse Error near line %s:\n\n>>> %s\n\n%s",
$this->cursor + 1,
$this->lines[$this->cursor],
$message);
} else {
$message = pht(
'ICS Parse Error: %s',
$message);
}
throw id(new PhutilICSParserException($message))
->setParserFailureCode($code);
}
private function raiseWarning($code, $message) {
$this->warnings[] = array(
'code' => $code,
'line' => $this->cursor,
'text' => $this->lines[$this->cursor],
'message' => $message,
);
return $this;
}
public function getWarnings() {
return $this->warnings;
}
private function didParseEventProperty(
PhutilCalendarEventNode $node,
$name,
array $parameters,
array $value) {
switch ($name) {
case 'UID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setUID($text);
break;
case 'CREATED':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setCreatedDateTime($datetime);
break;
case 'DTSTAMP':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setModifiedDateTime($datetime);
break;
case 'SUMMARY':
$text = $this->newTextFromProperty($parameters, $value);
$node->setName($text);
break;
case 'DESCRIPTION':
$text = $this->newTextFromProperty($parameters, $value);
$node->setDescription($text);
break;
case 'DTSTART':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setStartDateTime($datetime);
break;
case 'DTEND':
$datetime = $this->newDateTimeFromProperty($parameters, $value);
$node->setEndDateTime($datetime);
break;
case 'DURATION':
$duration = $this->newDurationFromProperty($parameters, $value);
$node->setDuration($duration);
break;
case 'RRULE':
$rrule = $this->newRecurrenceRuleFromProperty($parameters, $value);
$node->setRecurrenceRule($rrule);
break;
case 'RECURRENCE-ID':
$text = $this->newTextFromProperty($parameters, $value);
$node->setRecurrenceID($text);
break;
case 'ATTENDEE':
$attendee = $this->newAttendeeFromProperty($parameters, $value);
$node->addAttendee($attendee);
break;
}
}
private function newTextFromProperty(array $parameters, array $value) {
$value = $value['value'];
return implode("\n\n", $value);
}
private function newAttendeeFromProperty(array $parameters, array $value) {
$uri = $value['value'];
switch (idx($parameters, 'PARTSTAT')) {
case 'ACCEPTED':
$status = PhutilCalendarUserNode::STATUS_ACCEPTED;
break;
case 'DECLINED':
$status = PhutilCalendarUserNode::STATUS_DECLINED;
break;
case 'NEEDS-ACTION':
default:
$status = PhutilCalendarUserNode::STATUS_INVITED;
break;
}
$name = $this->getScalarParameterValue($parameters, 'CN');
return id(new PhutilCalendarUserNode())
->setURI($uri)
->setName($name)
->setStatus($status);
}
private function newDateTimeFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DATETIME,
pht(
'Expected DATE-TIME to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
$tzid = $this->getScalarParameterValue($parameters, 'TZID');
if (preg_match('/Z\z/', $value)) {
if ($tzid) {
$this->raiseWarning(
self::WARN_TZID_UTC,
pht(
'DATE-TIME "%s" uses "Z" to specify UTC, but also has a TZID '.
'parameter with value "%s". This violates RFC5545. The TZID '.
'will be ignored, and the value will be interpreted as UTC.',
$value,
$tzid));
}
$tzid = 'UTC';
} else if ($tzid !== null) {
$tzid = $this->guessTimezone($tzid);
}
try {
$datetime = PhutilCalendarAbsoluteDateTime::newFromISO8601(
$value,
$tzid);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DATETIME,
pht(
'Error parsing DATE-TIME: %s',
$ex->getMessage()));
}
return $datetime;
}
private function newDurationFromProperty(array $parameters, array $value) {
$value = $value['value'];
if (!$value) {
$this->raiseParseFailure(
self::PARSE_EMPTY_DURATION,
pht(
'Expected DURATION to have exactly one value, found none.'));
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MANY_DURATION,
pht(
'Expected DURATION to have exactly one value, found more than '.
'one.'));
}
$value = head($value);
try {
$duration = PhutilCalendarDuration::newFromISO8601($value);
} catch (Exception $ex) {
$this->raiseParseFailure(
self::PARSE_BAD_DURATION,
pht(
'Invalid DURATION: %s',
$ex->getMessage()));
}
return $duration;
}
private function newRecurrenceRuleFromProperty(array $parameters, $value) {
return PhutilCalendarRecurrenceRule::newFromRRULE($value['value']);
}
private function getScalarParameterValue(
array $parameters,
$name,
$default = null) {
$match = null;
foreach ($parameters as $parameter) {
if ($parameter['name'] == $name) {
$match = $parameter;
}
}
if ($match === null) {
return $default;
}
$value = $match['values'];
if (!$value) {
// Parameter is specified, but with no value, like "KEY=". Just return
// the default, as though the parameter was not specified.
return $default;
}
if (count($value) > 1) {
$this->raiseParseFailure(
self::PARSE_MULTIPLE_PARAMETERS,
pht(
'Expected parameter "%s" to have at most one value, but found '.
'more than one.',
$name));
}
return idx(head($value), 'value');
}
private function guessTimezone($tzid) {
$map = DateTimeZone::listIdentifiers();
$map = array_fuse($map);
if (isset($map[$tzid])) {
// This is a real timezone we recognize, so just use it as provided.
return $tzid;
}
// These are alternate names for timezones.
static $aliases;
if ($aliases === null) {
$aliases = array(
'Etc/GMT' => 'UTC',
);
// Load the map of Windows timezones.
$root_path = dirname(phutil_get_library_root('phutil'));
$windows_path = $root_path.'/resources/timezones/windows_timezones.json';
$windows_data = Filesystem::readFile($windows_path);
$windows_zones = phutil_json_decode($windows_data);
$aliases = $aliases + $windows_zones;
}
if (isset($aliases[$tzid])) {
return $aliases[$tzid];
}
// Look for something that looks like "UTC+3" or "GMT -05.00". If we find
// anything, pick a timezone with that offset.
$offset_pattern =
'/'.
'(?:UTC|GMT)'.
'\s*'.
'(?P<sign>[+-])'.
'\s*'.
'(?P<h>\d+)'.
'(?:'.
'[:.](?P<m>\d+)'.
')?'.
'/i';
$matches = null;
if (preg_match($offset_pattern, $tzid, $matches)) {
$hours = (int)$matches['h'];
$minutes = (int)idx($matches, 'm');
$offset = ($hours * 60 * 60) + ($minutes * 60);
if (idx($matches, 'sign') == '-') {
$offset = -$offset;
}
// NOTE: We could possibly do better than this, by using the event start
// time to guess a timezone. However, that won't work for recurring
// events and would require us to do this work after finishing initial
// parsing. Since these unusual offset-based timezones appear to be rare,
// the benefit may not be worth the complexity.
$now = new DateTime('@'.time());
foreach ($map as $identifier) {
$zone = new DateTimeZone($identifier);
if ($zone->getOffset($now) == $offset) {
$this->raiseWarning(
self::WARN_TZID_GUESS,
pht(
'TZID "%s" is unknown, guessing "%s" based on pattern "%s".',
$tzid,
$identifier,
$matches[0]));
return $identifier;
}
}
}
$this->raiseWarning(
self::WARN_TZID_IGNORED,
pht(
'TZID "%s" is unknown, using UTC instead.',
$tzid));
return 'UTC';
}
}

View file

@ -0,0 +1,16 @@
<?php
final class PhutilICSParserException extends Exception {
private $parserFailureCode;
public function setParserFailureCode($code) {
$this->parserFailureCode = $code;
return $this;
}
public function getParserFailureCode() {
return $this->parserFailureCode;
}
}

View file

@ -0,0 +1,387 @@
<?php
final class PhutilICSWriter extends Phobject {
public function writeICSDocument(PhutilCalendarRootNode $node) {
$out = array();
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
return implode('', $out);
}
private function writeNode(PhutilCalendarNode $node) {
if (!$this->getICSNodeType($node)) {
return null;
}
$out = array();
$out[] = $this->writeBeginNode($node);
$out[] = $this->writeNodeProperties($node);
if ($node instanceof PhutilCalendarContainerNode) {
foreach ($node->getChildren() as $child) {
$out[] = $this->writeNode($child);
}
}
$out[] = $this->writeEndNode($node);
return implode('', $out);
}
private function writeBeginNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("BEGIN:{$type}");
}
private function writeEndNode(PhutilCalendarNode $node) {
$type = $this->getICSNodeType($node);
return $this->wrapICSLine("END:{$type}");
}
private function writeNodeProperties(PhutilCalendarNode $node) {
$properties = $this->getNodeProperties($node);
$out = array();
foreach ($properties as $property) {
$propname = $property['name'];
$propvalue = $property['value'];
$propline = array();
$propline[] = $propname;
foreach ($property['parameters'] as $parameter) {
$paramname = $parameter['name'];
$paramvalue = $parameter['value'];
$propline[] = ";{$paramname}={$paramvalue}";
}
$propline[] = ":{$propvalue}";
$propline = implode('', $propline);
$out[] = $this->wrapICSLine($propline);
}
return implode('', $out);
}
private function getICSNodeType(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return 'VCALENDAR';
case PhutilCalendarEventNode::NODETYPE:
return 'VEVENT';
default:
return null;
}
}
private function wrapICSLine($line) {
$out = array();
$buf = '';
// NOTE: The line may contain sequences of combining characters which are
// more than 80 bytes in length. If it does, we'll split them in the
// middle of the sequence. This is okay and generally anticipated by
// RFC5545, which even allows implementations to split multibyte
// characters. The sequence will be stitched back together properly by
// whatever is parsing things.
foreach (phutil_utf8v($line) as $character) {
// If adding this character would bring the line over 75 bytes, start
// a new line.
if (strlen($buf) + strlen($character) > 75) {
$out[] = $buf."\r\n";
$buf = ' ';
}
$buf .= $character;
}
$out[] = $buf."\r\n";
return implode('', $out);
}
private function getNodeProperties(PhutilCalendarNode $node) {
switch ($node->getNodeType()) {
case PhutilCalendarDocumentNode::NODETYPE:
return $this->getDocumentNodeProperties($node);
case PhutilCalendarEventNode::NODETYPE:
return $this->getEventNodeProperties($node);
default:
return array();
}
}
private function getDocumentNodeProperties(
PhutilCalendarDocumentNode $event) {
$properties = array();
$properties[] = $this->newTextProperty(
'VERSION',
'2.0');
$properties[] = $this->newTextProperty(
'PRODID',
'-//Phacility//Phabricator//EN');
return $properties;
}
private function getEventNodeProperties(PhutilCalendarEventNode $event) {
$properties = array();
$uid = $event->getUID();
if (!strlen($uid)) {
throw new Exception(
pht(
'Unable to write ICS document: event has no UID, but each event '.
'MUST have a UID.'));
}
$properties[] = $this->newTextProperty(
'UID',
$uid);
$created = $event->getCreatedDateTime();
if ($created) {
$properties[] = $this->newDateTimeProperty(
'CREATED',
$event->getCreatedDateTime());
}
$dtstamp = $event->getModifiedDateTime();
if (!$dtstamp) {
throw new Exception(
pht(
'Unable to write ICS document: event has no modified time, but '.
'each event MUST have a modified time.'));
}
$properties[] = $this->newDateTimeProperty(
'DTSTAMP',
$dtstamp);
$dtstart = $event->getStartDateTime();
if ($dtstart) {
$properties[] = $this->newDateTimeProperty(
'DTSTART',
$dtstart);
}
$dtend = $event->getEndDateTime();
if ($dtend) {
$properties[] = $this->newDateTimeProperty(
'DTEND',
$event->getEndDateTime());
}
$name = $event->getName();
if (strlen($name)) {
$properties[] = $this->newTextProperty(
'SUMMARY',
$name);
}
$description = $event->getDescription();
if (strlen($description)) {
$properties[] = $this->newTextProperty(
'DESCRIPTION',
$description);
}
$organizer = $event->getOrganizer();
if ($organizer) {
$properties[] = $this->newUserProperty(
'ORGANIZER',
$organizer);
}
$attendees = $event->getAttendees();
if ($attendees) {
foreach ($attendees as $attendee) {
$properties[] = $this->newUserProperty(
'ATTENDEE',
$attendee);
}
}
$rrule = $event->getRecurrenceRule();
if ($rrule) {
$properties[] = $this->newRRULEProperty(
'RRULE',
$rrule);
}
$recurrence_id = $event->getRecurrenceID();
if ($recurrence_id) {
$properties[] = $this->newTextProperty(
'RECURRENCE-ID',
$recurrence_id);
}
$exdates = $event->getRecurrenceExceptions();
if ($exdates) {
$properties[] = $this->newDateTimesProperty(
'EXDATE',
$exdates);
}
$rdates = $event->getRecurrenceDates();
if ($rdates) {
$properties[] = $this->newDateTimesProperty(
'RDATE',
$rdates);
}
return $properties;
}
private function newTextProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'\\' => '\\\\',
',' => '\\,',
"\n" => '\\n',
);
$value = (array)$value;
foreach ($value as $k => $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
$value[$k] = $v;
}
$value = implode(',', $value);
return $this->newProperty($name, $value, $parameters);
}
private function newDateTimeProperty(
$name,
PhutilCalendarDateTime $value,
array $parameters = array()) {
return $this->newDateTimesProperty($name, array($value), $parameters);
}
private function newDateTimesProperty(
$name,
array $values,
array $parameters = array()) {
assert_instances_of($values, 'PhutilCalendarDateTime');
if (head($values)->getIsAllDay()) {
$parameters[] = array(
'name' => 'VALUE',
'values' => array(
'DATE',
),
);
}
$datetimes = array();
foreach ($values as $value) {
$datetimes[] = $value->getISO8601();
}
$datetimes = implode(';', $datetimes);
return $this->newProperty($name, $datetimes, $parameters);
}
private function newUserProperty(
$name,
PhutilCalendarUserNode $value,
array $parameters = array()) {
$parameters[] = array(
'name' => 'CN',
'values' => array(
$value->getName(),
),
);
$partstat = null;
switch ($value->getStatus()) {
case PhutilCalendarUserNode::STATUS_INVITED:
$partstat = 'NEEDS-ACTION';
break;
case PhutilCalendarUserNode::STATUS_ACCEPTED:
$partstat = 'ACCEPTED';
break;
case PhutilCalendarUserNode::STATUS_DECLINED:
$partstat = 'DECLINED';
break;
}
if ($partstat !== null) {
$parameters[] = array(
'name' => 'PARTSTAT',
'values' => array(
$partstat,
),
);
}
// TODO: We could reasonably fill in "ROLE" and "RSVP" here too, but it
// isn't clear if these are important to external programs or not.
return $this->newProperty($name, $value->getURI(), $parameters);
}
private function newRRULEProperty(
$name,
PhutilCalendarRecurrenceRule $rule,
array $parameters = array()) {
$value = $rule->toRRULE();
return $this->newProperty($name, $value, $parameters);
}
private function newProperty(
$name,
$value,
array $parameters = array()) {
$map = array(
'^' => '^^',
"\n" => '^n',
'"' => "^'",
);
$writable_params = array();
foreach ($parameters as $k => $parameter) {
$value_list = array();
foreach ($parameter['values'] as $v) {
$v = str_replace(array_keys($map), array_values($map), $v);
// If the parameter value isn't a very simple one, quote it.
// RFC5545 says that we MUST quote it if it has a colon, a semicolon,
// or a comma, and that we MUST quote it if it's a URI.
if (!preg_match('/^[A-Za-z0-9-]*\z/', $v)) {
$v = '"'.$v.'"';
}
$value_list[] = $v;
}
$writable_params[] = array(
'name' => $parameter['name'],
'value' => implode(',', $value_list),
);
}
return array(
'name' => $name,
'value' => $value,
'parameters' => $writable_params,
);
}
}

View file

@ -0,0 +1,341 @@
<?php
final class PhutilICSParserTestCase extends PhutilTestCase {
public function testICSParser() {
$event = $this->parseICSSingleEvent('simple.ics');
$this->assertEqual(
array(
array(
'name' => 'CREATED',
'parameters' => array(),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160908T172702Z',
),
'raw' => '20160908T172702Z',
),
),
array(
'name' => 'UID',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
'raw' => '1CEB57AF-0C9C-402D-B3BD-D75BD4843F68',
),
),
array(
'name' => 'DTSTART',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T090000',
),
'raw' => '20160915T090000',
),
),
array(
'name' => 'DTEND',
'parameters' => array(
array(
'name' => 'TZID',
'values' => array(
array(
'value' => 'America/Los_Angeles',
'quoted' => false,
),
),
),
),
'value' => array(
'type' => 'DATE-TIME',
'value' => array(
'20160915T100000',
),
'raw' => '20160915T100000',
),
),
array(
'name' => 'SUMMARY',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'Simple Event',
),
'raw' => 'Simple Event',
),
),
array(
'name' => 'DESCRIPTION',
'parameters' => array(),
'value' => array(
'type' => 'TEXT',
'value' => array(
'This is a simple event.',
),
'raw' => 'This is a simple event.',
),
),
),
$event->getAttribute('ics.properties'));
$this->assertEqual(
'Simple Event',
$event->getName());
$this->assertEqual(
'This is a simple event.',
$event->getDescription());
$this->assertEqual(
1473955200,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
1473955200 + phutil_units('1 hour in seconds'),
$event->getEndDateTime()->getEpoch());
}
public function testICSOddTimezone() {
$event = $this->parseICSSingleEvent('zimbra-timezone.ics');
$start = $event->getStartDateTime();
$this->assertEqual(
'20170303T140000Z',
$start->getISO8601());
}
public function testICSFloatingTime() {
// This tests "floating" event times, which have no absolute time and are
// supposed to be interpreted using the viewer's timezone. It also uses
// a duration, and the duration needs to float along with the viewer
// timezone.
$event = $this->parseICSSingleEvent('floating.ics');
$start = $event->getStartDateTime();
$caught = null;
try {
$start->getEpoch();
} catch (Exception $ex) {
$caught = $ex;
}
$this->assertTrue(
($caught instanceof Exception),
pht('Expected exception for floating time with no viewer timezone.'));
$newyears_utc = strtotime('2015-01-01 00:00:00 UTC');
$this->assertEqual(1420070400, $newyears_utc);
$start->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc,
$start->getEpoch());
$start->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('8 hours in seconds'),
$start->getEpoch());
$start->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('5 hours in seconds'),
$start->getEpoch());
$end = $event->getEndDateTime();
$end->setViewerTimezone('UTC');
$this->assertEqual(
$newyears_utc + phutil_units('24 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/Los_Angeles');
$this->assertEqual(
$newyears_utc + phutil_units('32 hours in seconds'),
$end->getEpoch());
$end->setViewerTimezone('America/New_York');
$this->assertEqual(
$newyears_utc + phutil_units('29 hours in seconds'),
$end->getEpoch());
}
public function testICSVALARM() {
$event = $this->parseICSSingleEvent('valarm.ics');
// For now, we parse but ignore VALARM sections. This test just makes
// sure they survive parsing.
$start_epoch = strtotime('2016-10-19 22:00:00 UTC');
$this->assertEqual(1476914400, $start_epoch);
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
}
public function testICSDuration() {
$event = $this->parseICSSingleEvent('duration.ics');
// Raw value is "20160719T095722Z".
$start_epoch = strtotime('2016-07-19 09:57:22 UTC');
$this->assertEqual(1468922242, $start_epoch);
// Raw value is "P1DT17H4M23S".
$duration =
phutil_units('1 day in seconds') +
phutil_units('17 hours in seconds') +
phutil_units('4 minutes in seconds') +
phutil_units('23 seconds in seconds');
$this->assertEqual(
$start_epoch,
$event->getStartDateTime()->getEpoch());
$this->assertEqual(
$start_epoch + $duration,
$event->getEndDateTime()->getEpoch());
}
public function testICSWeeklyEvent() {
$event = $this->parseICSSingleEvent('weekly.ics');
$start = $event->getStartDateTime();
$start->setViewerTimezone('UTC');
$rrule = $event->getRecurrenceRule()
->setStartDateTime($start);
$rset = id(new PhutilCalendarRecurrenceSet())
->addSource($rrule);
$result = $rset->getEventsBetween(null, null, 3);
$expect = array(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150811'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150818'),
PhutilCalendarAbsoluteDateTime::newFromISO8601('20150825'),
);
$this->assertEqual(
mpull($expect, 'getISO8601'),
mpull($result, 'getISO8601'),
pht('Weekly recurring event.'));
}
public function testICSParserErrors() {
$map = array(
'err-missing-end.ics' => PhutilICSParser::PARSE_MISSING_END,
'err-bad-base64.ics' => PhutilICSParser::PARSE_BAD_BASE64,
'err-bad-boolean.ics' => PhutilICSParser::PARSE_BAD_BOOLEAN,
'err-extra-end.ics' => PhutilICSParser::PARSE_EXTRA_END,
'err-initial-unfold.ics' => PhutilICSParser::PARSE_INITIAL_UNFOLD,
'err-malformed-double-quote.ics' =>
PhutilICSParser::PARSE_MALFORMED_DOUBLE_QUOTE,
'err-malformed-parameter.ics' =>
PhutilICSParser::PARSE_MALFORMED_PARAMETER_NAME,
'err-malformed-property.ics' =>
PhutilICSParser::PARSE_MALFORMED_PROPERTY,
'err-missing-value.ics' => PhutilICSParser::PARSE_MISSING_VALUE,
'err-mixmatched-sections.ics' =>
PhutilICSParser::PARSE_MISMATCHED_SECTIONS,
'err-root-property.ics' => PhutilICSParser::PARSE_ROOT_PROPERTY,
'err-unescaped-backslash.ics' =>
PhutilICSParser::PARSE_UNESCAPED_BACKSLASH,
'err-unexpected-text.ics' => PhutilICSParser::PARSE_UNEXPECTED_TEXT,
'err-multiple-parameters.ics' =>
PhutilICSParser::PARSE_MULTIPLE_PARAMETERS,
'err-empty-datetime.ics' =>
PhutilICSParser::PARSE_EMPTY_DATETIME,
'err-many-datetime.ics' =>
PhutilICSParser::PARSE_MANY_DATETIME,
'err-bad-datetime.ics' =>
PhutilICSParser::PARSE_BAD_DATETIME,
'err-empty-duration.ics' =>
PhutilICSParser::PARSE_EMPTY_DURATION,
'err-many-duration.ics' =>
PhutilICSParser::PARSE_MANY_DURATION,
'err-bad-duration.ics' =>
PhutilICSParser::PARSE_BAD_DURATION,
'simple.ics' => null,
'good-boolean.ics' => null,
'multiple-vcalendars.ics' => null,
);
foreach ($map as $test_file => $expect) {
$caught = null;
try {
$this->parseICSDocument($test_file);
} catch (PhutilICSParserException $ex) {
$caught = $ex;
}
if ($expect === null) {
$this->assertTrue(
($caught === null),
pht(
'Expected no exception parsing "%s", got: %s',
$test_file,
(string)$ex));
} else {
if ($caught) {
$code = $ex->getParserFailureCode();
$explain = pht(
'Expected one exception parsing "%s", got a different '.
'one: %s',
$test_file,
(string)$ex);
} else {
$code = null;
$explain = pht(
'Expected exception parsing "%s", got none.',
$test_file);
}
$this->assertEqual($expect, $code, $explain);
}
}
}
private function parseICSSingleEvent($name) {
$root = $this->parseICSDocument($name);
$documents = $root->getDocuments();
$this->assertEqual(1, count($documents));
$document = head($documents);
$events = $document->getEvents();
$this->assertEqual(1, count($events));
return head($events);
}
private function parseICSDocument($name) {
$path = dirname(__FILE__).'/data/'.$name;
$data = Filesystem::readFile($path);
return id(new PhutilICSParser())
->parseICSData($data);
}
}

View file

@ -0,0 +1,144 @@
<?php
final class PhutilICSWriterTestCase extends PhutilTestCase {
public function testICSWriterTeaTime() {
$teas = array(
'earl grey tea',
'English breakfast tea',
'black tea',
'green tea',
't-rex',
'oolong tea',
'mint tea',
'tea with milk',
);
$teas = implode(', ', $teas);
$event = id(new PhutilCalendarEventNode())
->setUID('tea-time')
->setName('Tea Time')
->setDescription(
"Tea and, perhaps, crumpets.\n".
"Your presence is requested!\n".
"This is a long list of types of tea to test line wrapping: {$teas}.")
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160915T070000Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T150000Z'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160916T160000Z'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-tea-time.ics', $ics_data);
}
public function testICSWriterChristmas() {
$start = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001225T000000Z');
$end = PhutilCalendarAbsoluteDateTime::newFromISO8601('20001226T000000Z');
$rrule = id(new PhutilCalendarRecurrenceRule())
->setFrequency(PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY)
->setByMonth(array(12))
->setByMonthDay(array(25));
$event = id(new PhutilCalendarEventNode())
->setUID('recurring-christmas')
->setName('Christmas')
->setDescription('Festival holiday first occurring in the year 2000.')
->setStartDateTime($start)
->setEndDateTime($end)
->setCreatedDateTime($start)
->setModifiedDateTime($start)
->setRecurrenceRule($rrule)
->setRecurrenceExceptions(
array(
// In 2007, Christmas was cancelled.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20071225T000000Z'),
))
->setRecurrenceDates(
array(
// We had an extra early Christmas in 2009.
PhutilCalendarAbsoluteDateTime::newFromISO8601('20091125T000000Z'),
));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-recurring-christmas.ics', $ics_data);
}
public function testICSWriterAllDay() {
$event = id(new PhutilCalendarEventNode())
->setUID('christmas-day')
->setName('Christmas 2016')
->setDescription('A minor religious holiday.')
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20160901T232425Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161225'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161226'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-christmas.ics', $ics_data);
}
public function testICSWriterUsers() {
$event = id(new PhutilCalendarEventNode())
->setUID('office-party')
->setName('Office Party')
->setCreatedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z'))
->setModifiedDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161001T120000Z'))
->setStartDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T200000Z'))
->setEndDateTime(
PhutilCalendarAbsoluteDateTime::newFromISO8601('20161215T230000Z'))
->setOrganizer(
id(new PhutilCalendarUserNode())
->setName('Big Boss')
->setURI('mailto:big.boss@example.com'))
->addAttendee(
id(new PhutilCalendarUserNode())
->setName('Milton')
->setStatus(PhutilCalendarUserNode::STATUS_INVITED)
->setURI('mailto:milton@example.com'))
->addAttendee(
id(new PhutilCalendarUserNode())
->setName('Nancy')
->setStatus(PhutilCalendarUserNode::STATUS_ACCEPTED)
->setURI('mailto:nancy@example.com'));
$ics_data = $this->writeICSSingleEvent($event);
$this->assertICS('writer-office-party.ics', $ics_data);
}
private function writeICSSingleEvent(PhutilCalendarEventNode $event) {
$calendar = id(new PhutilCalendarDocumentNode())
->appendChild($event);
$root = id(new PhutilCalendarRootNode())
->appendChild($calendar);
return $this->writeICS($root);
}
private function writeICS(PhutilCalendarRootNode $root) {
return id(new PhutilICSWriter())
->writeICSDocument($root);
}
private function assertICS($name, $actual) {
$path = dirname(__FILE__).'/data/'.$name;
$data = Filesystem::readFile($path);
$this->assertEqual($data, $actual, pht('ICS: %s', $name));
}
}

View file

@ -0,0 +1,8 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20160719T095722Z
DURATION:P1DT17H4M23S
SUMMARY:Duration Event
DESCRIPTION:This is an event with a complex duration.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DATA;VALUE=BINARY;ENCODING=BASE64:<QUACK! QUACK!>
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DUCK;VALUE=BOOLEAN:QUACK
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:quack
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:quack
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1 @@
END:VCALENDAR

View file

@ -0,0 +1,2 @@
BEGIN:VCALENDAR
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
A;B="C:D
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
A;B:C
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
PEANUTBUTTER&JELLY:sandwich
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20130101,20130101
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DURATION:P1W,P2W
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,2 @@
BEGIN:VCALENDAR
BEGIN:VEVENT

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
TRIANGLE;color=red
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,4 @@
BEGIN:A
BEGIN:B
END:A
END:B

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART;TZID=A,B:20160915T090000
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1 @@
NAME:value

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
STORY:The duck coughed up an unescaped backslash: \
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
SQUARE;color=red"
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,8 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DTSTART:20150101T000000
DURATION:P1D
SUMMARY:New Year's 2015
DESCRIPTION:This is an event with a floating start time.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,5 @@
BEGIN:VCALENDAR
BEGIN:VEVENT
DUCK;VALUE=BOOLEAN:TRUE
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,4 @@
BEGIN:VCALENDAR
END:VCALENDAR
BEGIN:VCALENDAR
END:VCALENDAR

View file

@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20160908T172702Z
UID:1CEB57AF-0C9C-402D-B3BD-D75BD4843F68
DTSTART;TZID=America/Los_Angeles:20160915T090000
DTEND;TZID=America/Los_Angeles:20160915T100000
SUMMARY:Simple Event
DESCRIPTION:This is a simple event.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
CREATED:20161027T173727
DTSTAMP:20161027T173727
LAST-MODIFIED:20161027T173727
UID:aic4zm86mg
SUMMARY:alarm event
DTSTART;TZID=Europe/Berlin:20161020T000000
DTEND;TZID=Europe/Berlin:20161020T010000
BEGIN:VALARM
ACTION:AUDIO
TRIGGER:-PT15M
END:VALARM
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,14 @@
BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
TRANSP:OPAQUE
DTEND;VALUE=DATE:20150812
LAST-MODIFIED:20160822T130015Z
UID:4AE69E91-4A51-4B77-8849-85981E037A83
DTSTAMP:20161129T152151Z
SUMMARY:Weekly Event
DTSTART;VALUE=DATE:20150811
CREATED:20141109T163445Z
RRULE:FREQ=WEEKLY
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,13 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:christmas-day
CREATED:20160901T232425Z
DTSTAMP:20160901T232425Z
DTSTART;VALUE=DATE:20161225
DTEND;VALUE=DATE:20161226
SUMMARY:Christmas 2016
DESCRIPTION:A minor religious holiday.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,15 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:office-party
CREATED:20161001T120000Z
DTSTAMP:20161001T120000Z
DTSTART:20161215T200000Z
DTEND:20161215T230000Z
SUMMARY:Office Party
ORGANIZER;CN="Big Boss":mailto:big.boss@example.com
ATTENDEE;CN=Milton;PARTSTAT=NEEDS-ACTION:mailto:milton@example.com
ATTENDEE;CN=Nancy;PARTSTAT=ACCEPTED:mailto:nancy@example.com
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:recurring-christmas
CREATED:20001225T000000Z
DTSTAMP:20001225T000000Z
DTSTART:20001225T000000Z
DTEND:20001226T000000Z
SUMMARY:Christmas
DESCRIPTION:Festival holiday first occurring in the year 2000.
RRULE:FREQ=YEARLY;BYMONTH=12;BYMONTHDAY=25
EXDATE:20071225T000000Z
RDATE:20091125T000000Z
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,16 @@
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Phacility//Phabricator//EN
BEGIN:VEVENT
UID:tea-time
CREATED:20160915T070000Z
DTSTAMP:20160915T070000Z
DTSTART:20160916T150000Z
DTEND:20160916T160000Z
SUMMARY:Tea Time
DESCRIPTION:Tea and\, perhaps\, crumpets.\nYour presence is requested!\nThi
s is a long list of types of tea to test line wrapping: earl grey tea\, En
glish breakfast tea\, black tea\, green tea\, t-rex\, oolong tea\, mint te
a\, tea with milk.
END:VEVENT
END:VCALENDAR

View file

@ -0,0 +1,12 @@
BEGIN:VCALENDAR
VERSION:2.0
CALSCALE:GREGORIAN
BEGIN:VEVENT
CREATED:20161104T220244Z
UID:zimbra-timezone
SUMMARY:Zimbra Timezone
DTSTART;TZID="(GMT-05.00) Auto-Detected":20170303T090000
DTSTAMP:20161104T220244Z
SEQUENCE:0
END:VEVENT
END:VCALENDAR

View file

@ -140,11 +140,20 @@ final class PhabricatorConfigManagementSetWorkflow
'Wrote configuration key "%s" to database storage.',
$key);
} else {
$config_source = id(new PhabricatorConfigLocalSource())
->setKeys(array($key => $value));
$config_source = new PhabricatorConfigLocalSource();
$local_path = $config_source->getReadablePath();
try {
$config_source->setKeys(array($key => $value));
} catch (FilesystemException $ex) {
throw new PhutilArgumentUsageException(
pht(
'Local path "%s" is not writable. This file must be writable '.
'so that "bin/config" can store configuration.',
Filesystem::readablePath($local_path)));
}
$write_message = pht(
'Wrote configuration key "%s" to local storage (in file "%s").',
$key,

View file

@ -0,0 +1,48 @@
<?php
final class DiffusionServiceRef
extends Phobject {
private $uri;
private $protocol;
private $isWritable;
private $devicePHID;
private $deviceName;
private function __construct() {
return;
}
public static function newFromDictionary(array $map) {
$ref = new self();
$ref->uri = $map['uri'];
$ref->isWritable = $map['writable'];
$ref->devicePHID = $map['devicePHID'];
$ref->protocol = $map['protocol'];
$ref->deviceName = $map['device'];
return $ref;
}
public function isWritable() {
return $this->isWritable;
}
public function getDevicePHID() {
return $this->devicePHID;
}
public function getURI() {
return $this->uri;
}
public function getProtocol() {
return $this->protocol;
}
public function getDeviceName() {
return $this->deviceName;
}
}

View file

@ -14,42 +14,33 @@ final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow {
}
protected function executeRepositoryOperations() {
// This is a write, and must have write access.
$this->requireWriteAccess();
$is_proxy = $this->shouldProxy();
if ($is_proxy) {
return $this->executeRepositoryProxyOperations($for_write = true);
}
$host_wait_start = microtime(true);
$repository = $this->getRepository();
$viewer = $this->getSSHUser();
$device = AlmanacKeys::getLiveDevice();
// This is a write, and must have write access.
$this->requireWriteAccess();
$cluster_engine = id(new DiffusionRepositoryClusterEngine())
->setViewer($viewer)
->setRepository($repository)
->setLog($this);
$is_proxy = $this->shouldProxy();
if ($is_proxy) {
$command = $this->getProxyCommand(true);
$did_write = false;
$command = csprintf('git-receive-pack %s', $repository->getLocalPath());
$cluster_engine->synchronizeWorkingCopyBeforeWrite();
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Push received by \"%s\", forwarding to cluster host.\n",
$device->getName()));
}
} else {
$command = csprintf('git-receive-pack %s', $repository->getLocalPath());
$did_write = true;
$cluster_engine->synchronizeWorkingCopyBeforeWrite();
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Ready to receive on cluster host \"%s\".\n",
$device->getName()));
}
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Ready to receive on cluster host \"%s\".\n",
$device->getName()));
}
$log = $this->newProtocolLog($is_proxy);
@ -71,9 +62,7 @@ final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow {
// We've committed the write (or rejected it), so we can release the lock
// without waiting for the client to receive the acknowledgement.
if ($did_write) {
$cluster_engine->synchronizeWorkingCopyAfterWrite();
}
$cluster_engine->synchronizeWorkingCopyAfterWrite();
if ($caught) {
throw $caught;
@ -85,18 +74,16 @@ final class DiffusionGitReceivePackSSHWorkflow extends DiffusionGitSSHWorkflow {
// When a repository is clustered, we reach this cleanup code on both
// the proxy and the actual final endpoint node. Don't do more cleanup
// or logging than we need to.
if ($did_write) {
$repository->writeStatusMessage(
PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,
PhabricatorRepositoryStatusMessage::CODE_OKAY);
$repository->writeStatusMessage(
PhabricatorRepositoryStatusMessage::TYPE_NEEDS_UPDATE,
PhabricatorRepositoryStatusMessage::CODE_OKAY);
$host_wait_end = microtime(true);
$host_wait_end = microtime(true);
$this->updatePushLogWithTimingInformation(
$this->getClusterEngineLogProperty('writeWait'),
$this->getClusterEngineLogProperty('readWait'),
($host_wait_end - $host_wait_start));
}
$this->updatePushLogWithTimingInformation(
$this->getClusterEngineLogProperty('writeWait'),
$this->getClusterEngineLogProperty('readWait'),
($host_wait_end - $host_wait_start));
}
return $err;

View file

@ -8,6 +8,10 @@ abstract class DiffusionGitSSHWorkflow
private $protocolLog;
private $wireProtocol;
private $ioBytesRead = 0;
private $ioBytesWritten = 0;
private $requestAttempts = 0;
private $requestFailures = 0;
protected function writeError($message) {
// Git assumes we'll add our own newlines.
@ -98,6 +102,8 @@ abstract class DiffusionGitSSHWorkflow
PhabricatorSSHPassthruCommand $command,
$message) {
$this->ioBytesWritten += strlen($message);
$log = $this->getProtocolLog();
if ($log) {
$log->didWriteBytes($message);
@ -125,7 +131,131 @@ abstract class DiffusionGitSSHWorkflow
$message = $protocol->willReadBytes($message);
}
// Note that bytes aren't counted until they're emittted by the protocol
// layer. This means the underlying command might emit bytes, but if they
// are buffered by the protocol layer they won't count as read bytes yet.
$this->ioBytesRead += strlen($message);
return $message;
}
final protected function getIOBytesRead() {
return $this->ioBytesRead;
}
final protected function getIOBytesWritten() {
return $this->ioBytesWritten;
}
final protected function executeRepositoryProxyOperations($for_write) {
$device = AlmanacKeys::getLiveDevice();
$refs = $this->getAlmanacServiceRefs($for_write);
$err = 1;
while (true) {
$ref = head($refs);
$command = $this->getProxyCommandForServiceRef($ref);
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Request received by \"%s\", forwarding to cluster ".
"host \"%s\".\n",
$device->getName(),
$ref->getDeviceName()));
}
$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
$future = id(new ExecFuture('%C', $command))
->setEnv($this->getEnvironment());
$this->didBeginRequest();
$err = $this->newPassthruCommand()
->setIOChannel($this->getIOChannel())
->setCommandChannelFromExecFuture($future)
->execute();
// TODO: Currently, when proxying, we do not write an event log on the
// proxy. Perhaps we should write a "proxy log". This is not very useful
// for statistics or auditing, but could be useful for diagnostics.
// Marking the proxy logs as proxied (and recording devicePHID on all
// logs) would make differentiating between these use cases easier.
if (!$err) {
$this->waitForGitClient();
return $err;
}
// Throw away this service: the request failed and we're treating the
// failure as persistent, so we don't want to retry another request to
// the same host.
array_shift($refs);
$should_retry = $this->shouldRetryRequest($refs);
if (!$should_retry) {
return $err;
}
// If we haven't bailed out yet, we'll retry the request with the next
// service.
}
throw new Exception(pht('Reached an unreachable place.'));
}
private function didBeginRequest() {
$this->requestAttempts++;
return $this;
}
private function shouldRetryRequest(array $remaining_refs) {
$this->requestFailures++;
if ($this->requestFailures > $this->requestAttempts) {
throw new Exception(
pht(
"Workflow has recorded more failures than attempts; there is a ".
"missing call to \"didBeginRequest()\".\n"));
}
if (!$remaining_refs) {
$this->writeClusterEngineLogMessage(
pht(
"# All available services failed to serve the request, ".
"giving up.\n"));
return false;
}
$read_len = $this->getIOBytesRead();
if ($read_len) {
$this->writeClusterEngineLogMessage(
pht(
"# Client already read from service (%s bytes), unable to retry.\n",
new PhutilNumber($read_len)));
return false;
}
$write_len = $this->getIOBytesWritten();
if ($write_len) {
$this->writeClusterEngineLogMessage(
pht(
"# Client already wrote to service (%s bytes), unable to retry.\n",
new PhutilNumber($write_len)));
return false;
}
$this->writeClusterEngineLogMessage(
pht(
"# Service request failed, retrying (making attempt %s of %s).\n",
new PhutilNumber($this->requestAttempts + 1),
new PhutilNumber($this->requestAttempts + count($remaining_refs))));
return true;
}
}

View file

@ -1,6 +1,7 @@
<?php
final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow {
final class DiffusionGitUploadPackSSHWorkflow
extends DiffusionGitSSHWorkflow {
protected function didConstruct() {
$this->setName('git-upload-pack');
@ -14,39 +15,33 @@ final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow {
}
protected function executeRepositoryOperations() {
$repository = $this->getRepository();
$is_proxy = $this->shouldProxy();
if ($is_proxy) {
return $this->executeRepositoryProxyOperations($for_write = false);
}
$viewer = $this->getSSHUser();
$repository = $this->getRepository();
$device = AlmanacKeys::getLiveDevice();
$skip_sync = $this->shouldSkipReadSynchronization();
$is_proxy = $this->shouldProxy();
if ($is_proxy) {
$command = $this->getProxyCommand(false);
$command = csprintf('git-upload-pack -- %s', $repository->getLocalPath());
if (!$skip_sync) {
$cluster_engine = id(new DiffusionRepositoryClusterEngine())
->setViewer($viewer)
->setRepository($repository)
->setLog($this)
->synchronizeWorkingCopyBeforeRead();
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Fetch received by \"%s\", forwarding to cluster host.\n",
"# Cleared to fetch on cluster host \"%s\".\n",
$device->getName()));
}
} else {
$command = csprintf('git-upload-pack -- %s', $repository->getLocalPath());
if (!$skip_sync) {
$cluster_engine = id(new DiffusionRepositoryClusterEngine())
->setViewer($viewer)
->setRepository($repository)
->setLog($this)
->synchronizeWorkingCopyBeforeRead();
if ($device) {
$this->writeClusterEngineLogMessage(
pht(
"# Cleared to fetch on cluster host \"%s\".\n",
$device->getName()));
}
}
}
$command = PhabricatorDaemon::sudoCommandAsDaemonUser($command);
$pull_event = $this->newPullEvent();
@ -60,14 +55,12 @@ final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow {
$log->didStartSession($command);
}
if (!$is_proxy) {
if (PhabricatorEnv::getEnvConfig('phabricator.show-prototypes')) {
$protocol = new DiffusionGitUploadPackWireProtocol();
if ($log) {
$protocol->setProtocolLog($log);
}
$this->setWireProtocol($protocol);
if (PhabricatorEnv::getEnvConfig('phabricator.show-prototypes')) {
$protocol = new DiffusionGitUploadPackWireProtocol();
if ($log) {
$protocol->setProtocolLog($log);
}
$this->setWireProtocol($protocol);
}
$err = $this->newPassthruCommand()
@ -89,15 +82,7 @@ final class DiffusionGitUploadPackSSHWorkflow extends DiffusionGitSSHWorkflow {
->setResultCode(0);
}
// TODO: Currently, when proxying, we do not write a log on the proxy.
// Perhaps we should write a "proxy log". This is not very useful for
// statistics or auditing, but could be useful for diagnostics. Marking
// the proxy logs as proxied (and recording devicePHID on all logs) would
// make differentiating between these use cases easier.
if (!$is_proxy) {
$pull_event->save();
}
$pull_event->save();
if (!$err) {
$this->waitForGitClient();

View file

@ -73,13 +73,13 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow {
return $this->shouldProxy;
}
protected function getProxyCommand($for_write) {
final protected function getAlmanacServiceRefs($for_write) {
$viewer = $this->getSSHUser();
$repository = $this->getRepository();
$is_cluster_request = $this->getIsClusterRequest();
$uri = $repository->getAlmanacServiceURI(
$refs = $repository->getAlmanacServiceRefs(
$viewer,
array(
'neverProxy' => $is_cluster_request,
@ -89,14 +89,28 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow {
'writable' => $for_write,
));
if (!$uri) {
if (!$refs) {
throw new Exception(
pht(
'Failed to generate an intracluster proxy URI even though this '.
'request was routed as a proxy request.'));
}
$uri = new PhutilURI($uri);
return $refs;
}
final protected function getProxyCommand($for_write) {
$refs = $this->getAlmanacServiceRefs($for_write);
$ref = head($refs);
return $this->getProxyCommandForServiceRef($ref);
}
final protected function getProxyCommandForServiceRef(
DiffusionServiceRef $ref) {
$uri = new PhutilURI($ref->getURI());
$username = AlmanacKeys::getClusterSSHUser();
if ($username === null) {

View file

@ -1842,6 +1842,20 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
PhabricatorUser $viewer,
array $options) {
$refs = $this->getAlmanacServiceRefs($viewer, $options);
if (!$refs) {
return null;
}
$ref = head($refs);
return $ref->getURI();
}
public function getAlmanacServiceRefs(
PhabricatorUser $viewer,
array $options) {
PhutilTypeSpec::checkMap(
$options,
array(
@ -1856,7 +1870,7 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
$cache_key = $this->getAlmanacServiceCacheKey();
if (!$cache_key) {
return null;
return array();
}
$cache = PhabricatorCaches::getMutableStructureCache();
@ -1869,7 +1883,7 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
}
if ($uris === null) {
return null;
return array();
}
$local_device = AlmanacKeys::getDeviceID();
@ -1893,7 +1907,7 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
if ($local_device && $never_proxy) {
if ($uri['device'] == $local_device) {
return null;
return array();
}
}
@ -1954,15 +1968,20 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
}
}
$refs = array();
foreach ($results as $result) {
$refs[] = DiffusionServiceRef::newFromDictionary($result);
}
// If we require a writable device, remove URIs which aren't writable.
if ($writable) {
foreach ($results as $key => $uri) {
if (!$uri['writable']) {
foreach ($refs as $key => $ref) {
if (!$ref->isWritable()) {
unset($results[$key]);
}
}
if (!$results) {
if (!$refs) {
throw new Exception(
pht(
'This repository ("%s") is not writable with the given '.
@ -1974,23 +1993,30 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
}
if ($writable) {
$results = $this->sortWritableAlmanacServiceURIs($results);
$refs = $this->sortWritableAlmanacServiceRefs($refs);
} else {
shuffle($results);
$refs = $this->sortReadableAlmanacServiceRefs($refs);
}
$result = head($results);
return $result['uri'];
return array_values($refs);
}
private function sortWritableAlmanacServiceURIs(array $results) {
private function sortReadableAlmanacServiceRefs(array $refs) {
assert_instances_of($refs, 'DiffusionServiceRef');
shuffle($refs);
return $refs;
}
private function sortWritableAlmanacServiceRefs(array $refs) {
assert_instances_of($refs, 'DiffusionServiceRef');
// See T13109 for discussion of how this method routes requests.
// In the absence of other rules, we'll send traffic to devices randomly.
// We also want to select randomly among nodes which are equally good
// candidates to receive the write, and accomplish that by shuffling the
// list up front.
shuffle($results);
shuffle($refs);
$order = array();
@ -2002,8 +2028,8 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
$this->getPHID());
if ($writer) {
$device_phid = $writer->getWriteProperty('devicePHID');
foreach ($results as $key => $result) {
if ($result['devicePHID'] === $device_phid) {
foreach ($refs as $key => $ref) {
if ($ref->getDevicePHID() === $device_phid) {
$order[] = $key;
}
}
@ -2025,8 +2051,8 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
}
$max_devices = array_fuse($max_devices);
foreach ($results as $key => $result) {
if (isset($max_devices[$result['devicePHID']])) {
foreach ($refs as $key => $ref) {
if (isset($max_devices[$ref->getDevicePHID()])) {
$order[] = $key;
}
}
@ -2034,9 +2060,9 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO
// Reorder the results, putting any we've selected as preferred targets for
// the write at the head of the list.
$results = array_select_keys($results, $order) + $results;
$refs = array_select_keys($refs, $order) + $refs;
return $results;
return $refs;
}
public function supportsSynchronization() {

View file

@ -0,0 +1,28 @@
<?php
final class PhabricatorSearchSettingsPanel
extends PhabricatorEditEngineSettingsPanel {
const PANELKEY = 'search';
public function getPanelName() {
return pht('Search');
}
public function getPanelMenuIcon() {
return 'fa-search';
}
public function getPanelGroupKey() {
return PhabricatorSettingsApplicationsPanelGroup::PANELGROUPKEY;
}
public function isTemplatePanel() {
return true;
}
public function isUserPanel() {
return false;
}
}

View file

@ -1,7 +1,7 @@
<?php
final class PhabricatorSearchScopeSetting
extends PhabricatorInternalSetting {
extends PhabricatorSelectSetting {
const SETTINGKEY = 'search-scope';
@ -9,8 +9,33 @@ final class PhabricatorSearchScopeSetting
return pht('Search Scope');
}
public function getSettingPanelKey() {
return PhabricatorSearchSettingsPanel::PANELKEY;
}
public function getSettingDefaultValue() {
return 'all';
}
protected function getControlInstructions() {
return pht(
'Choose the default behavior of the global search in the main menu.');
}
protected function getSelectOptions() {
$scopes = PhabricatorMainMenuSearchView::getGlobalSearchScopeItems(
$this->getViewer(),
new PhabricatorSettingsApplication());
$scope_map = array();
foreach ($scopes as $scope) {
if (!isset($scope['value'])) {
continue;
}
$scope_map[$scope['value']] = $scope['name'];
}
return $scope_map;
}
}

View file

@ -100,32 +100,34 @@ final class PhabricatorSystemActionEngine extends Phobject {
$actor_hashes = array();
foreach ($actors as $actor) {
$actor_hashes[] = PhabricatorHash::digestForIndex($actor);
$digest = PhabricatorHash::digestForIndex($actor);
$actor_hashes[$digest] = $actor;
}
$log = new PhabricatorSystemActionLog();
$window = self::getWindow();
$conn_r = $log->establishConnection('r');
$scores = queryfx_all(
$conn_r,
'SELECT actorIdentity, SUM(score) totalScore FROM %T
$conn = $log->establishConnection('r');
$rows = queryfx_all(
$conn,
'SELECT actorHash, SUM(score) totalScore FROM %T
WHERE action = %s AND actorHash IN (%Ls)
AND epoch >= %d GROUP BY actorHash',
$log->getTableName(),
$action->getActionConstant(),
$actor_hashes,
(time() - $window));
array_keys($actor_hashes),
(PhabricatorTime::getNow() - $window));
$scores = ipull($scores, 'totalScore', 'actorIdentity');
$rows = ipull($rows, 'totalScore', 'actorHash');
foreach ($scores as $key => $score) {
$scores[$key] = $score / $window;
$scores = array();
foreach ($actor_hashes as $digest => $actor) {
$score = idx($rows, $digest, 0);
$scores[$actor] = ($score / $window);
}
$scores = $scores + array_fill_keys($actors, 0);
return $scores;
}

View file

@ -15,10 +15,9 @@ You can use a special preamble script to make arbitrary adjustments to the
environment and some parts of Phabricator's configuration in order to fix these
problems and set up the environment which Phabricator expects.
NOTE: This is an advanced feature. Most installs should not need to configure
a preamble script.
= Creating a Preamble Script =
Creating a Preamble Script
==========================
To create a preamble script, write a file to:
@ -37,6 +36,7 @@ If present, this script will be executed at the very beginning of each web
request, allowing you to adjust the environment. For common adjustments and
examples, see the next sections.
Adjusting Client IPs
====================
@ -44,9 +44,15 @@ If your install is behind a load balancer, Phabricator may incorrectly detect
all requests as originating from the load balancer, rather than from the
correct client IPs.
If this is the case and some other header (like `X-Forwarded-For`) is known to
be trustworthy, you can read the header and overwrite the `REMOTE_ADDR` value
so Phabricator can figure out the client IP correctly.
In common cases where networks are configured like this, the `X-Forwarded-For`
header will have trustworthy information about the real client IP. You
can use the function `preamble_trust_x_forwarded_for_header()` in your
preamble to tell Phabricator that you expect to receive requests from a
load balancer or proxy which modifies this header:
```name="Trust X-Forwarded-For Header", lang=php
preamble_trust_x_forwarded_for_header();
```
You should do this //only// if the `X-Forwarded-For` header is known to be
trustworthy. In particular, if users can make requests to the web server
@ -54,30 +60,29 @@ directly, they can provide an arbitrary `X-Forwarded-For` header, and thereby
spoof an arbitrary client IP.
The `X-Forwarded-For` header may also contain a list of addresses if a request
has been forwarded through multiple loadbalancers. Using a snippet like this
will usually handle most situations correctly:
has been forwarded through multiple load balancers. If you know that requests
on your network are routed through `N` trustworthy devices, you can specify
that `N` to tell the function how many layers of `X-Forwarded-For` to discard:
```name="Trust X-Forwarded-For Header, Multiple Layers", lang=php
preamble_trust_x_forwarded_for_header(3);
```
name=Overwrite REMOTE_ADDR with X-Forwarded-For
<?php
// Overwrite REMOTE_ADDR with the value in the "X-Forwarded-For" HTTP header.
If you have an unusual network configuration (for example, the number of
trustworthy devices depends on the network path) you can also implement your
own logic.
// Only do this if you're certain the request is coming from a loadbalancer!
// If the request came directly from a client, doing this will allow them to
// them spoof any remote address.
Note that this is very odd, advanced, and easy to get wrong. If you get it
wrong, users will most likely be able to spoof any client address.
// The header may contain a list of IPs, like "1.2.3.4, 4.5.6.7", if the
// request the load balancer received also had this header.
```name="Custom X-Forwarded-For Handling", lang=php
if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$forwarded_for = $_SERVER['HTTP_X_FORWARDED_FOR'];
if ($forwarded_for) {
$forwarded_for = explode(',', $forwarded_for);
$forwarded_for = end($forwarded_for);
$forwarded_for = trim($forwarded_for);
$_SERVER['REMOTE_ADDR'] = $forwarded_for;
}
$raw_header = $_SERVER['X_FORWARDED_FOR'];
$real_address = your_custom_parsing_function($raw_header);
$_SERVER['REMOTE_ADDR'] = $raw_header;
}
```

View file

@ -221,6 +221,9 @@ final class PhabricatorDatabaseRef
return $this->replicaRefs;
}
public function getDisplayName() {
return $this->getRefKey();
}
public function getRefKey() {
$host = $this->getHost();

View file

@ -135,6 +135,11 @@ final class PhabricatorEnv extends Phobject {
// TODO: Add a "locale.default" config option once we have some reasonable
// defaults which aren't silly nonsense.
self::setLocaleCode('en_US');
// Load the preamble utility library if we haven't already. On web
// requests this loaded earlier, but we want to load it for non-web
// requests so that unit tests can call these functions.
require_once $phabricator_path.'/support/startup/preamble-utils.php';
}
public static function beginScopedLocale($locale_code) {
@ -249,9 +254,17 @@ final class PhabricatorEnv extends Phobject {
}
try {
$stack->pushSource(
id(new PhabricatorConfigDatabaseSource('default'))
->setName(pht('Database')));
// See T13403. If we're starting up in "config optional" mode, suppress
// messages about connection retries.
if ($config_optional) {
$database_source = @new PhabricatorConfigDatabaseSource('default');
} else {
$database_source = new PhabricatorConfigDatabaseSource('default');
}
$database_source->setName(pht('Database'));
$stack->pushSource($database_source);
} catch (AphrontSchemaQueryException $exception) {
// If the database is not available, just skip this configuration
// source. This happens during `bin/storage upgrade`, `bin/conf` before

View file

@ -0,0 +1,107 @@
<?php
final class PhutilLipsumContextFreeGrammar
extends PhutilContextFreeGrammar {
protected function getRules() {
return array(
'start' => array(
'[words].',
'[words].',
'[words].',
'[words]: [word], [word], [word] [word].',
'[words]; [lowerwords].',
'[words]!',
'[words], "[words]."',
'[words] ("[upperword] [upperword] [upperword]") [lowerwords].',
'[words]?',
),
'words' => array(
'[upperword] [lowerwords]',
),
'upperword' => array(
'Lorem',
'Ipsum',
'Dolor',
'Sit',
'Amet',
),
'lowerwords' => array(
'[word]',
'[word] [word]',
'[word] [word] [word]',
'[word] [word] [word] [word]',
'[word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word] [word]',
'[word] [word] [word] [word] [word] [word]',
),
'word' => array(
'ad',
'adipisicing',
'aliqua',
'aliquip',
'amet',
'anim',
'aute',
'cillum',
'commodo',
'consectetur',
'consequat',
'culpa',
'cupidatat',
'deserunt',
'do',
'dolor',
'dolore',
'duis',
'ea',
'eiusmod',
'elit',
'enim',
'esse',
'est',
'et',
'eu',
'ex',
'excepteur',
'exercitation',
'fugiat',
'id',
'in',
'incididunt',
'ipsum',
'irure',
'labore',
'laboris',
'laborum',
'lorem',
'magna',
'minim',
'mollit',
'nisi',
'non',
'nostrud',
'nulla',
'occaecat',
'officia',
'pariatur',
'proident',
'qui',
'quis',
'reprehenderit',
'sed',
'sint',
'sit',
'sunt',
'tempor',
'ullamco',
'ut',
'velit',
'veniam',
'voluptate',
),
);
}
}

View file

@ -0,0 +1,155 @@
<?php
final class PhutilRealNameContextFreeGrammar
extends PhutilContextFreeGrammar {
protected function getRules() {
return array(
'start' => array(
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]',
'[first] [last]-[last]',
'[first] [middle] [last]',
'[first] "[nick]" [last]',
'[first] [particle] [particle] [particle]',
),
'first' => array(
'Mohamed',
'Youssef',
'Ahmed',
'Mahmoud',
'Mustafa',
'Fatma',
'Aya',
'Noam',
'Adam',
'Lucas',
'Noah',
'Jakub',
'Victor',
'Harry',
'Rasmus',
'Nathan',
'Emil',
'Charlie',
'Leon',
'Dylan',
'Alexander',
'Emma',
'Marie',
'Lea',
'Amelia',
'Hanna',
'Emily',
'Sofia',
'Julia',
'Santiago',
'Sebastian',
'Olivia',
'Madison',
'Isabella',
'Esther',
'Anya',
'Camila',
'Jack',
'Oliver',
),
'nick' => array(
'Buzz',
'Juggernaut',
'Haze',
'Hawk',
'Iceman',
'Killer',
'Apex',
'Ocelot',
),
'middle' => array(
'Rose',
'Grace',
'Jane',
'Louise',
'Jade',
'James',
'John',
'William',
'Thomas',
'Alexander',
),
'last' => array(
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'[termlast]',
'O\'[termlast]',
'Mc[termlast]',
),
'termlast' => array(
'Smith',
'Johnson',
'Williams',
'Jones',
'Brown',
'Davis',
'Miller',
'Wilson',
'Moore',
'Taylor',
'Anderson',
'Thomas',
'Jackson',
'White',
'Harris',
'Martin',
'Thompson',
'Garcia',
'Marinez',
'Robinson',
'Clark',
'Rodrigues',
'Lewis',
'Lee',
'Walker',
'Hall',
'Allen',
'Young',
'Hernandex',
'King',
'Wang',
'Li',
'Zhang',
'Liu',
'Chen',
'Yang',
'Huang',
'Zhao',
'Wu',
'Zhou',
'Xu',
'Sun',
'Ma',
),
'particle' => array(
'Wu',
'Xu',
'Ma',
'Li',
'Liu',
'Shao',
'Lin',
'Khan',
),
);
}
}

View file

@ -0,0 +1,254 @@
<?php
/**
* Generates valid context-free code for most programming languages that could
* pass as C. Except for PHP. But includes Java (mostly).
*/
abstract class PhutilCLikeCodeSnippetContextFreeGrammar
extends PhutilCodeSnippetContextFreeGrammar {
protected function buildRuleSet() {
return array(
$this->getStmtTerminationGrammarSet(),
$this->getVarNameGrammarSet(),
$this->getNullExprGrammarSet(),
$this->getNumberGrammarSet(),
$this->getExprGrammarSet(),
$this->getCondGrammarSet(),
$this->getLoopGrammarSet(),
$this->getStmtGrammarSet(),
$this->getAssignmentGrammarSet(),
$this->getArithExprGrammarSet(),
$this->getBoolExprGrammarSet(),
$this->getBoolValGrammarSet(),
$this->getTernaryExprGrammarSet(),
$this->getFuncNameGrammarSet(),
$this->getFuncCallGrammarSet(),
$this->getFuncCallParamGrammarSet(),
$this->getFuncDeclGrammarSet(),
$this->getFuncParamGrammarSet(),
$this->getFuncBodyGrammarSet(),
$this->getFuncReturnGrammarSet(),
);
}
protected function getStartGrammarSet() {
$start_grammar = parent::getStartGrammarSet();
$start_grammar['start'][] = '[funcdecl]';
return $start_grammar;
}
protected function getStmtTerminationGrammarSet() {
return $this->buildGrammarSet('term', array(';'));
}
protected function getFuncCallGrammarSet() {
return $this->buildGrammarSet('funccall',
array(
'[funcname]([funccallparam])',
));
}
protected function getFuncCallParamGrammarSet() {
return $this->buildGrammarSet('funccallparam',
array(
'',
'[expr]',
'[expr], [expr]',
));
}
protected function getFuncDeclGrammarSet() {
return $this->buildGrammarSet('funcdecl',
array(
'function [funcname]([funcparam]) '.
'{[funcbody, indent, block, trim=right]}',
));
}
protected function getFuncParamGrammarSet() {
return $this->buildGrammarSet('funcparam',
array(
'',
'[varname]',
'[varname], [varname]',
'[varname], [varname], [varname]',
));
}
protected function getFuncBodyGrammarSet() {
return $this->buildGrammarSet('funcbody',
array(
"[stmt]\n[stmt]\n[funcreturn]",
"[stmt]\n[stmt]\n[stmt]\n[funcreturn]",
"[stmt]\n[stmt]\n[stmt]\n[stmt]\n[funcreturn]",
));
}
protected function getFuncReturnGrammarSet() {
return $this->buildGrammarSet('funcreturn',
array(
'return [expr][term]',
'',
));
}
// Not really C, but put it here because of the curly braces and mostly shared
// among Java and PHP
protected function getClassDeclGrammarSet() {
return $this->buildGrammarSet('classdecl',
array(
'[classinheritancemod] class [classname] {[classbody, indent, block]}',
'class [classname] {[classbody, indent, block]}',
));
}
protected function getClassNameGrammarSet() {
return $this->buildGrammarSet('classname',
array(
'MuffinHouse',
'MuffinReader',
'MuffinAwesomizer',
'SuperException',
'Librarian',
'Book',
'Ball',
'BallOfCode',
'AliceAndBobsSharedSecret',
'FileInputStream',
'FileOutputStream',
'BufferedReader',
'BufferedWriter',
'Cardigan',
'HouseOfCards',
'UmbrellaClass',
'GenericThing',
));
}
protected function getClassBodyGrammarSet() {
return $this->buildGrammarSet('classbody',
array(
'[methoddecl]',
"[methoddecl]\n\n[methoddecl]",
"[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]",
"[propdecl]\n[propdecl]\n[propdecl]\n\n[methoddecl]\n\n[methoddecl]".
"\n\n[methoddecl]",
));
}
protected function getVisibilityGrammarSet() {
return $this->buildGrammarSet('visibility',
array(
'private',
'protected',
'public',
));
}
protected function getClassInheritanceModGrammarSet() {
return $this->buildGrammarSet('classinheritancemod',
array(
'final',
'abstract',
));
}
// Keeping this separate so we won't give abstract methods a function body
protected function getMethodInheritanceModGrammarSet() {
return $this->buildGrammarSet('methodinheritancemod',
array(
'final',
));
}
protected function getMethodDeclGrammarSet() {
return $this->buildGrammarSet('methoddecl',
array(
'[visibility] [methodfuncdecl]',
'[visibility] [methodfuncdecl]',
'[methodinheritancemod] [visibility] [methodfuncdecl]',
'[abstractmethoddecl]',
));
}
protected function getMethodFuncDeclGrammarSet() {
return $this->buildGrammarSet('methodfuncdecl',
array(
'function [funcname]([funcparam]) '.
'{[methodbody, indent, block, trim=right]}',
));
}
protected function getMethodBodyGrammarSet() {
return $this->buildGrammarSet('methodbody',
array(
"[methodstmt]\n[methodbody]",
"[methodstmt]\n[funcreturn]",
));
}
protected function getMethodStmtGrammarSet() {
$stmts = $this->getStmtGrammarSet();
return $this->buildGrammarSet('methodstmt',
array_merge(
$stmts['stmt'],
array(
'[methodcall][term]',
)));
}
protected function getMethodCallGrammarSet() {
// Java/JavaScript
return $this->buildGrammarSet('methodcall',
array(
'this.[funccall]',
'[varname].[funccall]',
'[classname].[funccall]',
));
}
protected function getAbstractMethodDeclGrammarSet() {
return $this->buildGrammarSet('abstractmethoddecl',
array(
'abstract function [funcname]([funcparam])[term]',
));
}
protected function getPropDeclGrammarSet() {
return $this->buildGrammarSet('propdecl',
array(
'[visibility] [varname][term]',
));
}
protected function getClassRuleSets() {
return array(
$this->getClassInheritanceModGrammarSet(),
$this->getMethodInheritanceModGrammarSet(),
$this->getClassDeclGrammarSet(),
$this->getClassNameGrammarSet(),
$this->getClassBodyGrammarSet(),
$this->getMethodDeclGrammarSet(),
$this->getMethodFuncDeclGrammarSet(),
$this->getMethodBodyGrammarSet(),
$this->getMethodStmtGrammarSet(),
$this->getMethodCallGrammarSet(),
$this->getAbstractMethodDeclGrammarSet(),
$this->getPropDeclGrammarSet(),
$this->getVisibilityGrammarSet(),
);
}
public function generateClass() {
$rules = array_merge($this->getRules(), $this->getClassRuleSets());
$rules['start'] = array('[classdecl]');
$count = 0;
return $this->applyRules('[start]', $count, $rules);
}
}

View file

@ -0,0 +1,205 @@
<?php
/**
* Generates non-sense code snippets according to context-free rules, respecting
* indentation etc.
*
* Also provides a common ruleset shared among many mainstream programming
* languages (that is, not Lisp).
*/
abstract class PhutilCodeSnippetContextFreeGrammar
extends PhutilContextFreeGrammar {
public function generate() {
// A trailing newline is favorable for source code
return trim(parent::generate())."\n";
}
final protected function getRules() {
return array_merge(
$this->getStartGrammarSet(),
$this->getStmtGrammarSet(),
array_mergev($this->buildRuleSet()));
}
abstract protected function buildRuleSet();
protected function buildGrammarSet($name, array $set) {
return array(
$name => $set,
);
}
protected function getStartGrammarSet() {
return $this->buildGrammarSet('start',
array(
"[stmt]\n[stmt]",
"[stmt]\n[stmt]\n[stmt]",
"[stmt]\n[stmt]\n[stmt]\n[stmt]",
));
}
protected function getStmtGrammarSet() {
return $this->buildGrammarSet('stmt',
array(
'[assignment][term]',
'[assignment][term]',
'[assignment][term]',
'[assignment][term]',
'[funccall][term]',
'[funccall][term]',
'[funccall][term]',
'[funccall][term]',
'[cond]',
'[loop]',
));
}
protected function getFuncNameGrammarSet() {
return $this->buildGrammarSet('funcname',
array(
'do_something',
'nonempty',
'noOp',
'call_user_func',
'getenv',
'render',
'super',
'derpify',
'awesomize',
'equals',
'run',
'flee',
'fight',
'notify',
'listen',
'calculate',
'aim',
'open',
));
}
protected function getVarNameGrammarSet() {
return $this->buildGrammarSet('varname',
array(
'is_something',
'object',
'name',
'token',
'label',
'piece_of_the_pie',
'type',
'state',
'param',
'action',
'key',
'timeout',
'result',
));
}
protected function getNullExprGrammarSet() {
return $this->buildGrammarSet('null', array('null'));
}
protected function getNumberGrammarSet() {
return $this->buildGrammarSet('number',
array(
mt_rand(-1, 100),
mt_rand(-100, 1000),
mt_rand(-1000, 5000),
mt_rand(0, 1).'.'.mt_rand(1, 1000),
mt_rand(0, 50).'.'.mt_rand(1, 1000),
));
}
protected function getExprGrammarSet() {
return $this->buildGrammarSet('expr',
array(
'[null]',
'[number]',
'[number]',
'[varname]',
'[varname]',
'[boolval]',
'[boolval]',
'[boolexpr]',
'[boolexpr]',
'[funccall]',
'[arithexpr]',
'[arithexpr]',
// Some random strings
'"'.Filesystem::readRandomCharacters(4).'"',
'"'.Filesystem::readRandomCharacters(5).'"',
));
}
protected function getBoolExprGrammarSet() {
return $this->buildGrammarSet('boolexpr',
array(
'[varname]',
'![varname]',
'[varname] == [boolval]',
'[varname] != [boolval]',
'[ternary]',
));
}
protected function getBoolValGrammarSet() {
return $this->buildGrammarSet('boolval',
array(
'true',
'false',
));
}
protected function getArithExprGrammarSet() {
return $this->buildGrammarSet('arithexpr',
array(
'[varname]++',
'++[varname]',
'[varname] + [number]',
'[varname]--',
'--[varname]',
'[varname] - [number]',
));
}
protected function getAssignmentGrammarSet() {
return $this->buildGrammarSet('assignment',
array(
'[varname] = [expr]',
'[varname] = [arithexpr]',
'[varname] += [expr]',
));
}
protected function getCondGrammarSet() {
return $this->buildGrammarSet('cond',
array(
'if ([boolexpr]) {[stmt, indent, block]}',
'if ([boolexpr]) {[stmt, indent, block]} else {[stmt, indent, block]}',
));
}
protected function getLoopGrammarSet() {
return $this->buildGrammarSet('loop',
array(
'while ([boolexpr]) {[stmt, indent, block]}',
'do {[stmt, indent, block]} while ([boolexpr])[term]',
'for ([assignment]; [boolexpr]; [expr]) {[stmt, indent, block]}',
));
}
protected function getTernaryExprGrammarSet() {
return $this->buildGrammarSet('ternary',
array(
'[boolexpr] ? [expr] : [expr]',
));
}
protected function getStmtTerminationGrammarSet() {
return $this->buildGrammarSet('term', array(''));
}
}

View file

@ -0,0 +1,184 @@
<?php
final class PhutilJavaCodeSnippetContextFreeGrammar
extends PhutilCLikeCodeSnippetContextFreeGrammar {
protected function buildRuleSet() {
$parent_ruleset = parent::buildRuleSet();
$rulesset = array_merge($parent_ruleset, $this->getClassRuleSets());
$rulesset[] = $this->getTypeNameGrammarSet();
$rulesset[] = $this->getNamespaceDeclGrammarSet();
$rulesset[] = $this->getNamespaceNameGrammarSet();
$rulesset[] = $this->getImportGrammarSet();
$rulesset[] = $this->getMethodReturnTypeGrammarSet();
$rulesset[] = $this->getMethodNameGrammarSet();
$rulesset[] = $this->getVarDeclGrammarSet();
$rulesset[] = $this->getClassDerivGrammarSet();
return $rulesset;
}
protected function getStartGrammarSet() {
return $this->buildGrammarSet('start',
array(
'[import, block][nmspdecl, block][classdecl, block]',
));
}
protected function getClassDeclGrammarSet() {
return $this->buildGrammarSet('classdecl',
array(
'[classinheritancemod] [visibility] class [classname][classderiv] '.
'{[classbody, indent, block]}',
'[visibility] class [classname][classderiv] '.
'{[classbody, indent, block]}',
));
}
protected function getClassDerivGrammarSet() {
return $this->buildGrammarSet('classderiv',
array(
' extends [classname]',
'',
'',
));
}
protected function getTypeNameGrammarSet() {
return $this->buildGrammarSet('type',
array(
'int',
'boolean',
'char',
'short',
'long',
'float',
'double',
'[classname]',
'[type][]',
));
}
protected function getMethodReturnTypeGrammarSet() {
return $this->buildGrammarSet('methodreturn',
array(
'[type]',
'void',
));
}
protected function getNamespaceDeclGrammarSet() {
return $this->buildGrammarSet('nmspdecl',
array(
'package [nmspname][term]',
));
}
protected function getNamespaceNameGrammarSet() {
return $this->buildGrammarSet('nmspname',
array(
'java.lang',
'java.io',
'com.example.proj.std',
'derp.example.www',
));
}
protected function getImportGrammarSet() {
return $this->buildGrammarSet('import',
array(
'import [nmspname][term]',
'import [nmspname].*[term]',
'import [nmspname].[classname][term]',
));
}
protected function getExprGrammarSet() {
$expr = parent::getExprGrammarSet();
$expr['expr'][] = 'new [classname]([funccallparam])';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
$expr['expr'][] = '[methodcall]';
// Add some 'char's
for ($ii = 0; $ii < 2; $ii++) {
$expr['expr'][] = "'".Filesystem::readRandomCharacters(1)."'";
}
return $expr;
}
protected function getStmtGrammarSet() {
$stmt = parent::getStmtGrammarSet();
$stmt['stmt'][] = '[vardecl]';
$stmt['stmt'][] = '[vardecl]';
// `try` to `throw` a `Ball`!
$stmt['stmt'][] = 'throw [classname][term]';
return $stmt;
}
protected function getPropDeclGrammarSet() {
return $this->buildGrammarSet('propdecl',
array(
'[visibility] [type] [varname][term]',
));
}
protected function getVarDeclGrammarSet() {
return $this->buildGrammarSet('vardecl',
array(
'[type] [varname][term]',
'[type] [assignment][term]',
));
}
protected function getFuncNameGrammarSet() {
return $this->buildGrammarSet('funcname',
array(
'[methodname]',
'[classname].[methodname]',
// This is just silly (too much recursion)
// '[classname].[funcname]',
// Don't do this for now, it just clutters up output (thanks to rec.)
// '[nmspname].[classname].[methodname]',
));
}
// Renamed from `funcname`
protected function getMethodNameGrammarSet() {
$funcnames = head(parent::getFuncNameGrammarSet());
return $this->buildGrammarSet('methodname', $funcnames);
}
protected function getMethodFuncDeclGrammarSet() {
return $this->buildGrammarSet('methodfuncdecl',
array(
'[methodreturn] [methodname]([funcparam]) '.
'{[methodbody, indent, block, trim=right]}',
));
}
protected function getFuncParamGrammarSet() {
return $this->buildGrammarSet('funcparam',
array(
'',
'[type] [varname]',
'[type] [varname], [type] [varname]',
'[type] [varname], [type] [varname], [type] [varname]',
));
}
protected function getAbstractMethodDeclGrammarSet() {
return $this->buildGrammarSet('abstractmethoddecl',
array(
'abstract [methodreturn] [methodname]([funcparam])[term]',
));
}
}

Some files were not shown because too many files have changed in this diff Show more