diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php index 4062e4e2fe..420d126507 100644 --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -1,670 +1,670 @@ pht('Core Applications'), self::GROUP_UTILITIES => pht('Utilities'), self::GROUP_ADMIN => pht('Administration'), self::GROUP_DEVELOPER => pht('Developer Tools'), ); } final public function getApplicationName() { return 'application'; } final public function getTableName() { return 'application_application'; } final protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, ) + parent::getConfiguration(); } final public function generatePHID() { return $this->getPHID(); } final public function save() { // When "save()" is called on applications, we just return without // actually writing anything to the database. return $this; } /* -( Application Information )-------------------------------------------- */ abstract public function getName(); public function getMenuName() { return $this->getName(); } public function getShortDescription() { return pht('%s Application', $this->getName()); } final public function isInstalled() { if (!$this->canUninstall()) { return true; } $prototypes = PhabricatorEnv::getEnvConfig('phabricator.show-prototypes'); if (!$prototypes && $this->isPrototype()) { return false; } $uninstalled = PhabricatorEnv::getEnvConfig( 'phabricator.uninstalled-applications'); return empty($uninstalled[get_class($this)]); } public function isPrototype() { return false; } /** * Return `true` if this application should never appear in application lists * in the UI. Primarily intended for unit test applications or other * pseudo-applications. * * Few applications should be unlisted. For most applications, use * @{method:isLaunchable} to hide them from main launch views instead. * * @return bool True to remove application from UI lists. */ public function isUnlisted() { return false; } /** * Return `true` if this application is a normal application with a base * URI and a web interface. * * Launchable applications can be pinned to the home page, and show up in the * "Launcher" view of the Applications application. Making an application * unlaunchable prevents pinning and hides it from this view. * * Usually, an application should be marked unlaunchable if: * * - it is available on every page anyway (like search); or * - it does not have a web interface (like subscriptions); or * - it is still pre-release and being intentionally buried. * * To hide applications more completely, use @{method:isUnlisted}. * * @return bool True if the application is launchable. */ public function isLaunchable() { return true; } /** * Return `true` if this application should be pinned by default. * * Users who have not yet set preferences see a default list of applications. * * @param PhabricatorUser User viewing the pinned application list. * @return bool True if this application should be pinned by default. */ public function isPinnedByDefault(PhabricatorUser $viewer) { return false; } /** * Returns true if an application is first-party (developed by Phacility) * and false otherwise. * * @return bool True if this application is developed by Phacility. */ final public function isFirstParty() { $where = id(new ReflectionClass($this))->getFileName(); $root = phutil_get_library_root('phabricator'); if (!Filesystem::isDescendant($where, $root)) { return false; } if (Filesystem::isDescendant($where, $root.'/extensions')) { return false; } return true; } public function canUninstall() { return true; } final public function getPHID() { return 'PHID-APPS-'.get_class($this); } public function getTypeaheadURI() { return $this->isLaunchable() ? $this->getBaseURI() : null; } public function getBaseURI() { return null; } final public function getApplicationURI($path = '') { return $this->getBaseURI().ltrim($path, '/'); } public function getIcon() { return 'fa-puzzle-piece'; } public function getApplicationOrder() { return PHP_INT_MAX; } public function getApplicationGroup() { return self::GROUP_CORE; } public function getTitleGlyph() { return null; } final public function getHelpMenuItems(PhabricatorUser $viewer) { $items = array(); $articles = $this->getHelpDocumentationArticles($viewer); if ($articles) { foreach ($articles as $article) { $item = id(new PhabricatorActionView()) ->setName($article['name']) ->setHref($article['href']) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } $command_specs = $this->getMailCommandObjects(); if ($command_specs) { foreach ($command_specs as $key => $spec) { $object = $spec['object']; $class = get_class($this); $href = '/applications/mailcommands/'.$class.'/'.$key.'/'; $item = id(new PhabricatorActionView()) ->setName($spec['name']) ->setHref($href) ->addSigil('help-item') ->setOpenInNewWindow(true); $items[] = $item; } } if ($items) { $divider = id(new PhabricatorActionView()) ->addSigil('help-item') ->setType(PhabricatorActionView::TYPE_DIVIDER); array_unshift($items, $divider); } return array_values($items); } public function getHelpDocumentationArticles(PhabricatorUser $viewer) { return array(); } public function getOverview() { return null; } public function getEventListeners() { return array(); } public function getRemarkupRules() { return array(); } public function getQuicksandURIPatternBlacklist() { return array(); } public function getMailCommandObjects() { return array(); } /* -( URI Routing )-------------------------------------------------------- */ public function getRoutes() { return array(); } public function getResourceRoutes() { return array(); } /* -( Email Integration )-------------------------------------------------- */ public function supportsEmailIntegration() { return false; } final protected function getInboundEmailSupportLink() { return PhabricatorEnv::getDoclink('Configuring Inbound Email'); } public function getAppEmailBlurb() { throw new PhutilMethodNotImplementedException(); } /* -( Fact Integration )--------------------------------------------------- */ public function getFactObjectsForAnalysis() { return array(); } /* -( UI Integration )----------------------------------------------------- */ /** * You can provide an optional piece of flavor text for the application. This * is currently rendered in application launch views if the application has no * status elements. * * @return string|null Flavor text. * @task ui */ public function getFlavorText() { return null; } /** * Build items for the main menu. * * @param PhabricatorUser The viewing user. * @param AphrontController The current controller. May be null for special * pages like 404, exception handlers, etc. * @return list List of menu items. * @task ui */ public function buildMainMenuItems( PhabricatorUser $user, PhabricatorController $controller = null) { return array(); } /* -( Application Management )--------------------------------------------- */ final public static function getByClass($class_name) { $selected = null; $applications = self::getAllApplications(); foreach ($applications as $application) { if (get_class($application) == $class_name) { $selected = $application; break; } } if (!$selected) { throw new Exception(pht("No application '%s'!", $class_name)); } return $selected; } final public static function getAllApplications() { static $applications; if ($applications === null) { $apps = id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setSortMethod('getApplicationOrder') ->execute(); // Reorder the applications into "application order". Notably, this // ensures their event handlers register in application order. $apps = mgroup($apps, 'getApplicationGroup'); $group_order = array_keys(self::getApplicationGroups()); $apps = array_select_keys($apps, $group_order) + $apps; $apps = array_mergev($apps); $applications = $apps; } return $applications; } final public static function getAllInstalledApplications() { $all_applications = self::getAllApplications(); $apps = array(); foreach ($all_applications as $app) { if (!$app->isInstalled()) { continue; } $apps[] = $app; } return $apps; } /** * Determine if an application is installed, by application class name. * * To check if an application is installed //and// available to a particular * viewer, user @{method:isClassInstalledForViewer}. * * @param string Application class name. * @return bool True if the class is installed. * @task meta */ final public static function isClassInstalled($class) { return self::getByClass($class)->isInstalled(); } /** * Determine if an application is installed and available to a viewer, by * application class name. * * To check if an application is installed at all, use * @{method:isClassInstalled}. * * @param string Application class name. * @param PhabricatorUser Viewing user. * @return bool True if the class is installed for the viewer. * @task meta */ final public static function isClassInstalledForViewer( $class, PhabricatorUser $viewer) { if ($viewer->isOmnipotent()) { return true; } $cache = PhabricatorCaches::getRequestCache(); $viewer_fragment = $viewer->getCacheFragment(); $key = 'app.'.$class.'.installed.'.$viewer_fragment; $result = $cache->getKey($key); if ($result === null) { if (!self::isClassInstalled($class)) { $result = false; } else { $application = self::getByClass($class); if (!$application->canUninstall()) { // If the application can not be uninstalled, always allow viewers // to see it. In particular, this allows logged-out viewers to see // Settings and load global default settings even if the install // does not allow public viewers. $result = true; } else { $result = PhabricatorPolicyFilter::hasCapability( $viewer, self::getByClass($class), PhabricatorPolicyCapability::CAN_VIEW); } } $cache->setKey($key, $result); } return $result; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array_merge( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ), array_keys($this->getCustomCapabilities())); } public function getPolicy($capability) { $default = $this->getCustomPolicySetting($capability); if ($default) { return $default; } switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_ADMIN; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'default', PhabricatorPolicies::POLICY_USER); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( Policies )----------------------------------------------------------- */ protected function getCustomCapabilities() { return array(); } final private function getCustomPolicySetting($capability) { if (!$this->isCapabilityEditable($capability)) { return null; } $policy_locked = PhabricatorEnv::getEnvConfig('policy.locked'); if (isset($policy_locked[$capability])) { return $policy_locked[$capability]; } $config = PhabricatorEnv::getEnvConfig('phabricator.application-settings'); $app = idx($config, $this->getPHID()); if (!$app) { return null; } $policy = idx($app, 'policy'); if (!$policy) { return null; } return idx($policy, $capability); } final private function getCustomCapabilitySpecification($capability) { $custom = $this->getCustomCapabilities(); if (!isset($custom[$capability])) { throw new Exception(pht("Unknown capability '%s'!", $capability)); } return $custom[$capability]; } final public function getCapabilityLabel($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return pht('Can Use Application'); case PhabricatorPolicyCapability::CAN_EDIT: return pht('Can Configure Application'); } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if ($capobj) { return $capobj->getCapabilityName(); } return null; } final public function isCapabilityEditable($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->canUninstall(); case PhabricatorPolicyCapability::CAN_EDIT: return false; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'edit', true); } } final public function getCapabilityCaption($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: if (!$this->canUninstall()) { return pht( 'This application is required for Phabricator to operate, so all '. 'users must have access to it.'); } else { return null; } case PhabricatorPolicyCapability::CAN_EDIT: return null; default: $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'caption'); } } final public function getCapabilityTemplatePHIDType($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: case PhabricatorPolicyCapability::CAN_EDIT: return null; } $spec = $this->getCustomCapabilitySpecification($capability); return idx($spec, 'template'); } final public function getDefaultObjectTypePolicyMap() { $map = array(); foreach ($this->getCustomCapabilities() as $capability => $spec) { if (empty($spec['template'])) { continue; } if (empty($spec['capability'])) { continue; } $default = $this->getPolicy($capability); $map[$spec['template']][$spec['capability']] = $default; } return $map; } public function getApplicationSearchDocumentTypes() { return array(); } protected function getEditRoutePattern($base = null) { return $base.'(?:'. '(?P[0-9]\d*)/)?'. '(?:'. '(?:'. '(?Pparameters|nodefault|nocreate|nomanage|comment)/'. '|'. '(?:form/(?P[^/]+)/)?(?:page/(?P[^/]+)/)?'. ')'. ')?'; } protected function getBulkRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/)?'; } protected function getQueryRoutePattern($base = null) { - return $base.'(?:query/(?P[^/]+)/(?:(?P[^/]+)/))?'; + return $base.'(?:query/(?P[^/]+)/(?:(?P[^/]+)/)?)?'; } protected function getProfileMenuRouting($controller) { $edit_route = $this->getEditRoutePattern(); $mode_route = '(?Pglobal|custom)/'; return array( '(?Pview)/(?P[^/]+)/' => $controller, '(?Phide)/(?P[^/]+)/' => $controller, '(?Pdefault)/(?P[^/]+)/' => $controller, '(?Pconfigure)/' => $controller, '(?Pconfigure)/'.$mode_route => $controller, '(?Preorder)/'.$mode_route => $controller, '(?Pedit)/'.$edit_route => $controller, '(?Pnew)/'.$mode_route.'(?[^/]+)/'.$edit_route => $controller, '(?Pbuiltin)/(?[^/]+)/'.$edit_route => $controller, ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorApplicationEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorApplicationApplicationTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } } diff --git a/src/applications/people/application/PhabricatorPeopleApplication.php b/src/applications/people/application/PhabricatorPeopleApplication.php index dde82f1d3a..28405ca92c 100644 --- a/src/applications/people/application/PhabricatorPeopleApplication.php +++ b/src/applications/people/application/PhabricatorPeopleApplication.php @@ -1,109 +1,109 @@ getIsAdmin(); } public function getFlavorText() { return pht('Sort of a social utility.'); } public function getApplicationGroup() { return self::GROUP_UTILITIES; } public function canUninstall() { return false; } public function getRoutes() { return array( '/people/' => array( - '(query/(?P[^/]+)/)?' => 'PhabricatorPeopleListController', + $this->getQueryRoutePattern() => 'PhabricatorPeopleListController', 'logs/(?:query/(?P[^/]+)/)?' => 'PhabricatorPeopleLogsController', 'invite/' => array( '(?:query/(?P[^/]+)/)?' => 'PhabricatorPeopleInviteListController', 'send/' => 'PhabricatorPeopleInviteSendController', ), 'approve/(?P[1-9]\d*)/' => 'PhabricatorPeopleApproveController', '(?Pdisapprove)/(?P[1-9]\d*)/' => 'PhabricatorPeopleDisableController', '(?Pdisable)/(?P[1-9]\d*)/' => 'PhabricatorPeopleDisableController', 'empower/(?P[1-9]\d*)/' => 'PhabricatorPeopleEmpowerController', 'delete/(?P[1-9]\d*)/' => 'PhabricatorPeopleDeleteController', 'rename/(?P[1-9]\d*)/' => 'PhabricatorPeopleRenameController', 'welcome/(?P[1-9]\d*)/' => 'PhabricatorPeopleWelcomeController', 'create/' => 'PhabricatorPeopleCreateController', 'new/(?P[^/]+)/' => 'PhabricatorPeopleNewController', 'ldap/' => 'PhabricatorPeopleLdapController', 'editprofile/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileEditController', 'badges/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileBadgesController', 'tasks/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileTasksController', 'commits/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileCommitsController', 'revisions/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileRevisionsController', 'picture/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfilePictureController', 'manage/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileManageController', - ), + ), '/p/(?P[\w._-]+)/' => array( '' => 'PhabricatorPeopleProfileViewController', 'item/' => $this->getProfileMenuRouting( 'PhabricatorPeopleProfileMenuController'), ), ); } public function getRemarkupRules() { return array( new PhabricatorMentionRemarkupRule(), ); } protected function getCustomCapabilities() { return array( PeopleCreateUsersCapability::CAPABILITY => array( 'default' => PhabricatorPolicies::POLICY_ADMIN, ), PeopleBrowseUserDirectoryCapability::CAPABILITY => array(), ); } public function getApplicationSearchDocumentTypes() { return array( PhabricatorPeopleUserPHIDType::TYPECONST, ); } } diff --git a/src/applications/people/controller/PhabricatorPeopleListController.php b/src/applications/people/controller/PhabricatorPeopleListController.php index edcfc7ba0f..511899070c 100644 --- a/src/applications/people/controller/PhabricatorPeopleListController.php +++ b/src/applications/people/controller/PhabricatorPeopleListController.php @@ -1,42 +1,42 @@ requireApplicationCapability( PeopleBrowseUserDirectoryCapability::CAPABILITY); $controller = id(new PhabricatorApplicationSearchController()) - ->setQueryKey($request->getURIData('key')) + ->setQueryKey($request->getURIData('queryKey')) ->setSearchEngine(new PhabricatorPeopleSearchEngine()) ->setNavigation($this->buildSideNavView()); return $this->delegateToController($controller); } protected function buildApplicationCrumbs() { $crumbs = parent::buildApplicationCrumbs(); $viewer = $this->getRequest()->getUser(); if ($viewer->getIsAdmin()) { $crumbs->addAction( id(new PHUIListItemView()) ->setName(pht('Create New User')) ->setHref($this->getApplicationURI('create/')) ->setIcon('fa-plus-square')); } return $crumbs; } } diff --git a/src/applications/people/query/PhabricatorPeopleSearchEngine.php b/src/applications/people/query/PhabricatorPeopleSearchEngine.php index 0a4367d367..db2256a8b8 100644 --- a/src/applications/people/query/PhabricatorPeopleSearchEngine.php +++ b/src/applications/people/query/PhabricatorPeopleSearchEngine.php @@ -1,323 +1,360 @@ needPrimaryEmail(true) ->needProfileImage(true); } protected function buildCustomSearchFields() { $fields = array( id(new PhabricatorSearchStringListField()) ->setLabel(pht('Usernames')) ->setKey('usernames') ->setAliases(array('username')) ->setDescription(pht('Find users by exact username.')), id(new PhabricatorSearchTextField()) ->setLabel(pht('Name Contains')) ->setKey('nameLike') ->setDescription( pht('Find users whose usernames contain a substring.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Administrators')) ->setKey('isAdmin') ->setOptions( pht('(Show All)'), pht('Show Only Administrators'), pht('Hide Administrators')) ->setDescription( pht( 'Pass true to find only administrators, or false to omit '. 'administrators.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Disabled')) ->setKey('isDisabled') ->setOptions( pht('(Show All)'), pht('Show Only Disabled Users'), pht('Hide Disabled Users')) ->setDescription( pht( 'Pass true to find only disabled users, or false to omit '. 'disabled users.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Bots')) ->setKey('isBot') ->setAliases(array('isSystemAgent')) ->setOptions( pht('(Show All)'), pht('Show Only Bots'), pht('Hide Bots')) ->setDescription( pht( 'Pass true to find only bots, or false to omit bots.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Mailing Lists')) ->setKey('isMailingList') ->setOptions( pht('(Show All)'), pht('Show Only Mailing Lists'), pht('Hide Mailing Lists')) ->setDescription( pht( 'Pass true to find only mailing lists, or false to omit '. 'mailing lists.')), id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Needs Approval')) ->setKey('needsApproval') ->setOptions( pht('(Show All)'), pht('Show Only Unapproved Users'), pht('Hide Unapproved Users')) ->setDescription( pht( 'Pass true to find only users awaiting administrative approval, '. 'or false to omit these users.')), ); $viewer = $this->requireViewer(); if ($viewer->getIsAdmin()) { $fields[] = id(new PhabricatorSearchThreeStateField()) ->setLabel(pht('Has MFA')) ->setKey('mfa') ->setOptions( pht('(Show All)'), pht('Show Only Users With MFA'), pht('Hide Users With MFA')) ->setDescription( pht( 'Pass true to find only users who are enrolled in MFA, or false '. 'to omit these users.')); } $fields[] = id(new PhabricatorSearchDateField()) ->setKey('createdStart') ->setLabel(pht('Joined After')) ->setDescription( pht('Find user accounts created after a given time.')); $fields[] = id(new PhabricatorSearchDateField()) ->setKey('createdEnd') ->setLabel(pht('Joined Before')) ->setDescription( pht('Find user accounts created before a given time.')); return $fields; } protected function getDefaultFieldOrder() { return array( '...', 'createdStart', 'createdEnd', ); } protected function buildQueryFromParameters(array $map) { $query = $this->newQuery(); $viewer = $this->requireViewer(); // If the viewer can't browse the user directory, restrict the query to // just the user's own profile. This is a little bit silly, but serves to // restrict users from creating a dashboard panel which essentially just // contains a user directory anyway. $can_browse = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getApplication(), PeopleBrowseUserDirectoryCapability::CAPABILITY); if (!$can_browse) { $query->withPHIDs(array($viewer->getPHID())); } if ($map['usernames']) { $query->withUsernames($map['usernames']); } if ($map['nameLike']) { $query->withNameLike($map['nameLike']); } if ($map['isAdmin'] !== null) { $query->withIsAdmin($map['isAdmin']); } if ($map['isDisabled'] !== null) { $query->withIsDisabled($map['isDisabled']); } if ($map['isMailingList'] !== null) { $query->withIsMailingList($map['isMailingList']); } if ($map['isBot'] !== null) { $query->withIsSystemAgent($map['isBot']); } if ($map['needsApproval'] !== null) { $query->withIsApproved(!$map['needsApproval']); } if (idx($map, 'mfa') !== null) { $viewer = $this->requireViewer(); if (!$viewer->getIsAdmin()) { throw new PhabricatorSearchConstraintException( pht( 'The "Has MFA" query constraint may only be used by '. 'administrators, to prevent attackers from using it to target '. 'weak accounts.')); } $query->withIsEnrolledInMultiFactor($map['mfa']); } if ($map['createdStart']) { $query->withDateCreatedAfter($map['createdStart']); } if ($map['createdEnd']) { $query->withDateCreatedBefore($map['createdEnd']); } return $query; } protected function getURI($path) { return '/people/'.$path; } protected function getBuiltinQueryNames() { $names = array( 'active' => pht('Active'), 'all' => pht('All'), ); $viewer = $this->requireViewer(); if ($viewer->getIsAdmin()) { $names['approval'] = pht('Approval Queue'); } return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); switch ($query_key) { case 'all': return $query; case 'active': return $query ->setParameter('isDisabled', false); case 'approval': return $query ->setParameter('needsApproval', true) ->setParameter('isDisabled', false); } return parent::buildSavedQueryFromBuiltin($query_key); } protected function renderResultList( array $users, PhabricatorSavedQuery $query, array $handles) { assert_instances_of($users, 'PhabricatorUser'); $request = $this->getRequest(); $viewer = $this->requireViewer(); $list = new PHUIObjectItemListView(); $is_approval = ($query->getQueryKey() == 'approval'); foreach ($users as $user) { $primary_email = $user->loadPrimaryEmail(); if ($primary_email && $primary_email->getIsVerified()) { $email = pht('Verified'); } else { $email = pht('Unverified'); } $item = new PHUIObjectItemView(); $item->setHeader($user->getFullName()) ->setHref('/p/'.$user->getUsername().'/') ->addAttribute(phabricator_datetime($user->getDateCreated(), $viewer)) ->addAttribute($email) ->setImageURI($user->getProfileImageURI()); if ($is_approval && $primary_email) { $item->addAttribute($primary_email->getAddress()); } if ($user->getIsDisabled()) { $item->addIcon('fa-ban', pht('Disabled')); $item->setDisabled(true); } if (!$is_approval) { if (!$user->getIsApproved()) { $item->addIcon('fa-clock-o', pht('Needs Approval')); } } if ($user->getIsAdmin()) { $item->addIcon('fa-star', pht('Admin')); } if ($user->getIsSystemAgent()) { $item->addIcon('fa-desktop', pht('Bot')); } if ($user->getIsMailingList()) { $item->addIcon('fa-envelope-o', pht('Mailing List')); } if ($viewer->getIsAdmin()) { if ($user->getIsEnrolledInMultiFactor()) { $item->addIcon('fa-lock', pht('Has MFA')); } } if ($viewer->getIsAdmin()) { $user_id = $user->getID(); if ($is_approval) { $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-ban') ->setName(pht('Disable')) ->setWorkflow(true) ->setHref($this->getApplicationURI('disapprove/'.$user_id.'/'))); $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-thumbs-o-up') ->setName(pht('Approve')) ->setWorkflow(true) ->setHref($this->getApplicationURI('approve/'.$user_id.'/'))); } } $list->addItem($item); } $result = new PhabricatorApplicationSearchResultView(); $result->setObjectList($list); $result->setNoDataString(pht('No accounts found.')); return $result; } + protected function newExportFields() { + return array( + id(new PhabricatorIDExportField()) + ->setKey('id') + ->setLabel(pht('ID')), + id(new PhabricatorPHIDExportField()) + ->setKey('phid') + ->setLabel(pht('PHID')), + id(new PhabricatorStringExportField()) + ->setKey('username') + ->setLabel(pht('Username')), + id(new PhabricatorStringExportField()) + ->setKey('realName') + ->setLabel(pht('Real Name')), + id(new PhabricatorEpochExportField()) + ->setKey('created') + ->setLabel(pht('Date Created')), + ); + } + + public function newExport(array $users) { + $viewer = $this->requireViewer(); + + $export = array(); + foreach ($users as $user) { + $export[] = array( + 'id' => $user->getID(), + 'phid' => $user->getPHID(), + 'username' => $user->getUsername(), + 'realName' => $user->getRealName(), + 'created' => $user->getDateCreated(), + ); + } + + return $export; + } + } diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index d241a046a1..d3b05d1e3e 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -1,914 +1,917 @@ preface = $preface; return $this; } public function getPreface() { return $this->preface; } public function setQueryKey($query_key) { $this->queryKey = $query_key; return $this; } protected function getQueryKey() { return $this->queryKey; } public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } protected function getNavigation() { return $this->navigation; } public function setSearchEngine( PhabricatorApplicationSearchEngine $search_engine) { $this->searchEngine = $search_engine; return $this; } protected function getSearchEngine() { return $this->searchEngine; } protected function validateDelegatingController() { $parent = $this->getDelegatingController(); if (!$parent) { throw new Exception( pht('You must delegate to this controller, not invoke it directly.')); } $engine = $this->getSearchEngine(); if (!$engine) { throw new PhutilInvalidStateException('setEngine'); } $engine->setViewer($this->getRequest()->getUser()); $parent = $this->getDelegatingController(); } public function processRequest() { $this->validateDelegatingController(); $query_action = $this->getRequest()->getURIData('queryAction'); if ($query_action == 'export') { return $this->processExportRequest(); } $key = $this->getQueryKey(); if ($key == 'edit') { return $this->processEditRequest(); } else { return $this->processSearchRequest(); } } private function processSearchRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $user = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); if (!$nav) { $nav = $this->buildNavigation(); } if ($request->isFormPost()) { $saved_query = $engine->buildSavedQueryFromRequest($request); $engine->saveQuery($saved_query); return id(new AphrontRedirectResponse())->setURI( $engine->getQueryResultsPageURI($saved_query->getQueryKey()).'#R'); } $named_query = null; $run_query = true; $query_key = $this->queryKey; if ($this->queryKey == 'advanced') { $run_query = false; $query_key = $request->getStr('query'); } else if (!strlen($this->queryKey)) { $found_query_data = false; if ($request->isHTTPGet() || $request->isQuicksand()) { // If this is a GET request and it has some query data, don't // do anything unless it's only before= or after=. We'll build and // execute a query from it below. This allows external tools to build // URIs like "/query/?users=a,b". $pt_data = $request->getPassthroughRequestData(); $exempt = array( 'before' => true, 'after' => true, 'nux' => true, 'overheated' => true, ); foreach ($pt_data as $pt_key => $pt_value) { if (isset($exempt[$pt_key])) { continue; } $found_query_data = true; break; } } if (!$found_query_data) { // Otherwise, there's no query data so just run the user's default // query for this application. $query_key = $engine->getDefaultQueryKey(); } } if ($engine->isBuiltinQuery($query_key)) { $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else if ($query_key) { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($user) ->withQueryKeys(array($query_key)) ->executeOne(); if (!$saved_query) { return new Aphront404Response(); } $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); } else { $saved_query = $engine->buildSavedQueryFromRequest($request); // Save the query to generate a query key, so "Save Custom Query..." and // other features like Maniphest's "Export..." work correctly. $engine->saveQuery($saved_query); } $nav->selectFilter( 'query/'.$saved_query->getQueryKey(), 'query/advanced'); $form = id(new AphrontFormView()) ->setUser($user) ->setAction($request->getPath()); $engine->buildSearchForm($form, $saved_query); $errors = $engine->getErrors(); if ($errors) { $run_query = false; } $submit = id(new AphrontFormSubmitControl()) ->setValue(pht('Search')); if ($run_query && !$named_query && $user->isLoggedIn()) { $save_button = id(new PHUIButtonView()) ->setTag('a') ->setHref('/search/edit/key/'.$saved_query->getQueryKey().'/') ->setText(pht('Save Query')) ->setIcon('fa-floppy-o'); $submit->addButton($save_button); } // TODO: A "Create Dashboard Panel" action goes here somewhere once // we sort out T5307. $form->appendChild($submit); $body = array(); if ($this->getPreface()) { $body[] = $this->getPreface(); } if ($named_query) { $title = $named_query->getQueryName(); } else { $title = pht('Advanced Search'); } $header = id(new PHUIHeaderView()) ->setHeader($title) ->setProfileHeader(true); $box = id(new PHUIObjectBoxView()) ->setHeader($header) ->addClass('application-search-results'); if ($run_query || $named_query) { $box->setShowHide( pht('Edit Query'), pht('Hide Query'), $form, $this->getApplicationURI('query/advanced/?query='.$query_key), (!$named_query ? true : false)); } else { $box->setForm($form); } $body[] = $box; $more_crumbs = null; if ($run_query) { $exec_errors = array(); $box->setAnchor( id(new PhabricatorAnchorView()) ->setAnchorName('R')); try { $engine->setRequest($request); $query = $engine->buildQueryFromSavedQuery($saved_query); $pager = $engine->newPagerForSavedQuery($saved_query); $pager->readFromRequest($request); $objects = $engine->executeQuery($query, $pager); $force_nux = $request->getBool('nux'); if (!$objects || $force_nux) { $nux_view = $this->renderNewUserView($engine, $force_nux); } else { $nux_view = null; } $is_overflowing = $pager->willShowPagingControls() && $engine->getResultBucket($saved_query); $force_overheated = $request->getBool('overheated'); $is_overheated = $query->getIsOverheated() || $force_overheated; if ($nux_view) { $box->appendChild($nux_view); } else { $list = $engine->renderResults($objects, $saved_query); if (!($list instanceof PhabricatorApplicationSearchResultView)) { throw new Exception( pht( 'SearchEngines must render a "%s" object, but this engine '. '(of class "%s") rendered something else.', 'PhabricatorApplicationSearchResultView', get_class($engine))); } if ($list->getObjectList()) { $box->setObjectList($list->getObjectList()); } if ($list->getTable()) { $box->setTable($list->getTable()); } if ($list->getInfoView()) { $box->setInfoView($list->getInfoView()); } if ($is_overflowing) { $box->appendChild($this->newOverflowingView()); } if ($list->getContent()) { $box->appendChild($list->getContent()); } if ($is_overheated) { $box->appendChild($this->newOverheatedView($objects)); } $result_header = $list->getHeader(); if ($result_header) { $box->setHeader($result_header); $header = $result_header; } $actions = $list->getActions(); if ($actions) { foreach ($actions as $action) { $header->addActionLink($action); } } $use_actions = $engine->newUseResultsActions($saved_query); // TODO: Eventually, modularize all this stuff. $builtin_use_actions = $this->newBuiltinUseActions(); if ($builtin_use_actions) { foreach ($builtin_use_actions as $builtin_use_action) { $use_actions[] = $builtin_use_action; } } if ($use_actions) { $use_dropdown = $this->newUseResultsDropdown( $saved_query, $use_actions); $header->addActionLink($use_dropdown); } $more_crumbs = $list->getCrumbs(); if ($pager->willShowPagingControls()) { $pager_box = id(new PHUIBoxView()) ->setColor(PHUIBoxView::GREY) ->addClass('application-search-pager') ->appendChild($pager); $body[] = $pager_box; } } } catch (PhabricatorTypeaheadInvalidTokenException $ex) { $exec_errors[] = pht( 'This query specifies an invalid parameter. Review the '. 'query parameters and correct errors.'); } catch (PhutilSearchQueryCompilerSyntaxException $ex) { $exec_errors[] = $ex->getMessage(); } catch (PhabricatorSearchConstraintException $ex) { $exec_errors[] = $ex->getMessage(); } // The engine may have encountered additional errors during rendering; // merge them in and show everything. foreach ($engine->getErrors() as $error) { $exec_errors[] = $error; } $errors = $exec_errors; } if ($errors) { $box->setFormErrors($errors, pht('Query Errors')); } $crumbs = $parent ->buildApplicationCrumbs() ->setBorder(true); if ($more_crumbs) { $query_uri = $engine->getQueryResultsPageURI($saved_query->getQueryKey()); $crumbs->addTextCrumb($title, $query_uri); foreach ($more_crumbs as $crumb) { $crumbs->addCrumb($crumb); } } else { $crumbs->addTextCrumb($title); } require_celerity_resource('application-search-view-css'); return $this->newPage() ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Query: %s', $title)) ->setCrumbs($crumbs) ->setNavigation($nav) ->addClass('application-search-view') ->appendChild($body); } private function processExportRequest() { $viewer = $this->getViewer(); $engine = $this->getSearchEngine(); $request = $this->getRequest(); if (!$this->canExport()) { return new Aphront404Response(); } $query_key = $this->getQueryKey(); if ($engine->isBuiltinQuery($query_key)) { $saved_query = $engine->buildSavedQueryFromBuiltin($query_key); } else if ($query_key) { $saved_query = id(new PhabricatorSavedQueryQuery()) ->setViewer($viewer) ->withQueryKeys(array($query_key)) ->executeOne(); - if (!$saved_query) { - return new Aphront404Response(); - } + } else { + $saved_query = null; + } + + if (!$saved_query) { + return new Aphront404Response(); } $cancel_uri = $engine->getQueryResultsPageURI($query_key); $named_query = idx($engine->loadEnabledNamedQueries(), $query_key); if ($named_query) { $filename = $named_query->getQueryName(); } else { $filename = $engine->getResultTypeDescription(); } $filename = phutil_utf8_strtolower($filename); $filename = PhabricatorFile::normalizeFileName($filename); $formats = PhabricatorExportFormat::getAllEnabledExportFormats(); $format_options = mpull($formats, 'getExportFormatName'); $errors = array(); $e_format = null; if ($request->isFormPost()) { $format_key = $request->getStr('format'); $format = idx($formats, $format_key); if (!$format) { $e_format = pht('Invalid'); $errors[] = pht('Choose a valid export format.'); } if (!$errors) { $query = $engine->buildQueryFromSavedQuery($saved_query); // NOTE: We aren't reading the pager from the request. Exports always // affect the entire result set. $pager = $engine->newPagerForSavedQuery($saved_query); $pager->setPageSize(0x7FFFFFFF); $objects = $engine->executeQuery($query, $pager); $extension = $format->getFileExtension(); $mime_type = $format->getMIMEContentType(); $filename = $filename.'.'.$extension; $format = clone $format; $format->setViewer($viewer); $export_data = $engine->newExport($objects); if (count($export_data) !== count($objects)) { throw new Exception( pht( 'Search engine exported the wrong number of objects, expected '. '%s but got %s.', phutil_count($objects), phutil_count($export_data))); } $objects = array_values($objects); $export_data = array_values($export_data); $field_list = $engine->newExportFieldList(); $field_list = mpull($field_list, null, 'getKey'); for ($ii = 0; $ii < count($objects); $ii++) { $format->addObject($objects[$ii], $field_list, $export_data[$ii]); } $export_result = $format->newFileData(); $file = PhabricatorFile::newFromFileData( $export_result, array( 'name' => $filename, 'authorPHID' => $viewer->getPHID(), 'ttl.relative' => phutil_units('15 minutes in seconds'), 'viewPolicy' => PhabricatorPolicies::POLICY_NOONE, 'mime-type' => $mime_type, )); return $this->newDialog() ->setTitle(pht('Download Results')) ->appendParagraph( pht('Click the download button to download the exported data.')) ->addCancelButton($cancel_uri, pht('Done')) ->setSubmitURI($file->getDownloadURI()) ->setDisableWorkflowOnSubmit(true) ->addSubmitButton(pht('Download Data')); } } $export_form = id(new AphrontFormView()) ->setViewer($viewer) ->appendControl( id(new AphrontFormSelectControl()) ->setName('format') ->setLabel(pht('Format')) ->setError($e_format) ->setOptions($format_options)); return $this->newDialog() ->setTitle(pht('Export Results')) ->setErrors($errors) ->appendForm($export_form) ->addCancelButton($cancel_uri) ->addSubmitButton(pht('Continue')); } private function processEditRequest() { $parent = $this->getDelegatingController(); $request = $this->getRequest(); $viewer = $request->getUser(); $engine = $this->getSearchEngine(); $nav = $this->getNavigation(); if (!$nav) { $nav = $this->buildNavigation(); } $named_queries = $engine->loadAllNamedQueries(); $can_global = $viewer->getIsAdmin(); $groups = array( 'personal' => array( 'name' => pht('Personal Saved Queries'), 'items' => array(), 'edit' => true, ), 'global' => array( 'name' => pht('Global Saved Queries'), 'items' => array(), 'edit' => $can_global, ), ); foreach ($named_queries as $named_query) { if ($named_query->isGlobal()) { $group = 'global'; } else { $group = 'personal'; } $groups[$group]['items'][] = $named_query; } $default_key = $engine->getDefaultQueryKey(); $lists = array(); foreach ($groups as $group) { $lists[] = $this->newQueryListView( $group['name'], $group['items'], $default_key, $group['edit']); } $crumbs = $parent ->buildApplicationCrumbs() ->addTextCrumb(pht('Saved Queries'), $engine->getQueryManagementURI()) ->setBorder(true); $nav->selectFilter('query/edit'); $header = id(new PHUIHeaderView()) ->setHeader(pht('Saved Queries')) ->setProfileHeader(true); $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setFooter($lists); return $this->newPage() ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Saved Queries')) ->setCrumbs($crumbs) ->setNavigation($nav) ->appendChild($view); } private function newQueryListView( $list_name, array $named_queries, $default_key, $can_edit) { $engine = $this->getSearchEngine(); $viewer = $this->getViewer(); $list = id(new PHUIObjectItemListView()) ->setViewer($viewer); if ($can_edit) { $list_id = celerity_generate_unique_node_id(); $list->setID($list_id); Javelin::initBehavior( 'search-reorder-queries', array( 'listID' => $list_id, 'orderURI' => '/search/order/'.get_class($engine).'/', )); } foreach ($named_queries as $named_query) { $class = get_class($engine); $key = $named_query->getQueryKey(); $item = id(new PHUIObjectItemView()) ->setHeader($named_query->getQueryName()) ->setHref($engine->getQueryResultsPageURI($key)); if ($named_query->getIsDisabled()) { if ($can_edit) { $item->setDisabled(true); } else { // If an item is disabled and you don't have permission to edit it, // just skip it. continue; } } if ($can_edit) { if ($named_query->getIsBuiltin() && $named_query->getIsDisabled()) { $icon = 'fa-plus'; $disable_name = pht('Enable'); } else { $icon = 'fa-times'; if ($named_query->getIsBuiltin()) { $disable_name = pht('Disable'); } else { $disable_name = pht('Delete'); } } if ($named_query->getID()) { $disable_href = '/search/delete/id/'.$named_query->getID().'/'; } else { $disable_href = '/search/delete/key/'.$key.'/'.$class.'/'; } $item->addAction( id(new PHUIListItemView()) ->setIcon($icon) ->setHref($disable_href) ->setRenderNameAsTooltip(true) ->setName($disable_name) ->setWorkflow(true)); } $default_disabled = $named_query->getIsDisabled(); $default_icon = 'fa-thumb-tack'; if ($default_key === $key) { $default_color = 'green'; } else { $default_color = null; } $item->addAction( id(new PHUIListItemView()) ->setIcon("{$default_icon} {$default_color}") ->setHref('/search/default/'.$key.'/'.$class.'/') ->setRenderNameAsTooltip(true) ->setName(pht('Make Default')) ->setWorkflow(true) ->setDisabled($default_disabled)); if ($can_edit) { if ($named_query->getIsBuiltin()) { $edit_icon = 'fa-lock lightgreytext'; $edit_disabled = true; $edit_name = pht('Builtin'); $edit_href = null; } else { $edit_icon = 'fa-pencil'; $edit_disabled = false; $edit_name = pht('Edit'); $edit_href = '/search/edit/id/'.$named_query->getID().'/'; } $item->addAction( id(new PHUIListItemView()) ->setIcon($edit_icon) ->setHref($edit_href) ->setRenderNameAsTooltip(true) ->setName($edit_name) ->setDisabled($edit_disabled)); } $item->setGrippable($can_edit); $item->addSigil('named-query'); $item->setMetadata( array( 'queryKey' => $named_query->getQueryKey(), )); $list->addItem($item); } $list->setNoDataString(pht('No saved queries.')); return id(new PHUIObjectBoxView()) ->setHeaderText($list_name) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); } public function buildApplicationMenu() { $menu = $this->getDelegatingController() ->buildApplicationMenu(); if ($menu instanceof PHUIApplicationMenuView) { $menu->setSearchEngine($this->getSearchEngine()); } return $menu; } private function buildNavigation() { $viewer = $this->getViewer(); $engine = $this->getSearchEngine(); $nav = id(new AphrontSideNavFilterView()) ->setUser($viewer) ->setBaseURI(new PhutilURI($this->getApplicationURI())); $engine->addNavigationItems($nav->getMenu()); return $nav; } private function renderNewUserView( PhabricatorApplicationSearchEngine $engine, $force_nux) { // Don't render NUX if the user has clicked away from the default page. if (strlen($this->getQueryKey())) { return null; } // Don't put NUX in panels because it would be weird. if ($engine->isPanelContext()) { return null; } // Try to render the view itself first, since this should be very cheap // (just returning some text). $nux_view = $engine->renderNewUserView(); if (!$nux_view) { return null; } $query = $engine->newQuery(); if (!$query) { return null; } // Try to load any object at all. If we can, the application has seen some // use so we just render the normal view. if (!$force_nux) { $object = $query ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setLimit(1) ->execute(); if ($object) { return null; } } return $nux_view; } private function newUseResultsDropdown( PhabricatorSavedQuery $query, array $dropdown_items) { $viewer = $this->getViewer(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); foreach ($dropdown_items as $dropdown_item) { $action_list->addAction($dropdown_item); } return id(new PHUIButtonView()) ->setTag('a') ->setHref('#') ->setText(pht('Use Results')) ->setIcon('fa-bars') ->setDropdownMenu($action_list) ->addClass('dropdown'); } private function newOverflowingView() { $message = pht( 'The query matched more than one page of results. Results are '. 'paginated before bucketing, so later pages may contain additional '. 'results in any bucket.'); return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) ->setTitle(pht('Buckets Overflowing')) ->setErrors( array( $message, )); } private function newOverheatedView(array $results) { if ($results) { $message = pht( 'Most objects matching your query are not visible to you, so '. 'filtering results is taking a long time. Only some results are '. 'shown. Refine your query to find results more quickly.'); } else { $message = pht( 'Most objects matching your query are not visible to you, so '. 'filtering results is taking a long time. Refine your query to '. 'find results more quickly.'); } return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) ->setTitle(pht('Query Overheated')) ->setErrors( array( $message, )); } private function newBuiltinUseActions() { $actions = array(); $request = $this->getRequest(); $viewer = $request->getUser(); $is_dev = PhabricatorEnv::getEnvConfig('phabricator.developer-mode'); $engine = $this->getSearchEngine(); $engine_class = get_class($engine); $query_key = $this->getQueryKey(); if (!$query_key) { $query_key = $engine->getDefaultQueryKey(); } $can_use = $engine->canUseInPanelContext(); $is_installed = PhabricatorApplication::isClassInstalledForViewer( 'PhabricatorDashboardApplication', $viewer); if ($can_use && $is_installed) { $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-dashboard') ->setName(pht('Add to Dashboard')) ->setWorkflow(true) ->setHref("/dashboard/panel/install/{$engine_class}/{$query_key}/"); } if ($this->canExport()) { $export_uri = $engine->getExportURI($query_key); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-download') ->setName(pht('Export Data')) ->setWorkflow(true) ->setHref($export_uri); } if ($is_dev) { $engine = $this->getSearchEngine(); $nux_uri = $engine->getQueryBaseURI(); $nux_uri = id(new PhutilURI($nux_uri)) ->setQueryParam('nux', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-user-plus') ->setName(pht('DEV: New User State')) ->setHref($nux_uri); } if ($is_dev) { $overheated_uri = $this->getRequest()->getRequestURI() ->setQueryParam('overheated', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-fire') ->setName(pht('DEV: Overheated State')) ->setHref($overheated_uri); } return $actions; } private function canExport() { $engine = $this->getSearchEngine(); if (!$engine->canExport()) { return false; } // Don't allow logged-out users to perform exports. There's no technical // or policy reason they can't, but we don't normally give them access // to write files or jobs. For now, just err on the side of caution. $viewer = $this->getViewer(); if (!$viewer->getPHID()) { return false; } return true; } }