diff --git a/src/applications/paste/query/PhabricatorPasteQuery.php b/src/applications/paste/query/PhabricatorPasteQuery.php index 9b0d644a37..5c84c91114 100644 --- a/src/applications/paste/query/PhabricatorPasteQuery.php +++ b/src/applications/paste/query/PhabricatorPasteQuery.php @@ -1,267 +1,271 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withAuthorPHIDs(array $phids) { $this->authorPHIDs = $phids; return $this; } public function withParentPHIDs(array $phids) { $this->parentPHIDs = $phids; return $this; } public function needContent($need_content) { $this->needContent = $need_content; return $this; } public function needRawContent($need_raw_content) { $this->needRawContent = $need_raw_content; return $this; } public function withLanguages(array $languages) { $this->includeNoLanguage = false; foreach ($languages as $key => $language) { if ($language === null) { $languages[$key] = ''; continue; } } $this->languages = $languages; return $this; } public function withDateCreatedBefore($date_created_before) { $this->dateCreatedBefore = $date_created_before; return $this; } public function withDateCreatedAfter($date_created_after) { $this->dateCreatedAfter = $date_created_after; return $this; } + protected function newResultObject() { + return new PhabricatorPaste(); + } + protected function loadPage() { $table = new PhabricatorPaste(); $conn_r = $table->establishConnection('r'); $data = queryfx_all( $conn_r, 'SELECT paste.* FROM %T paste %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); $pastes = $table->loadAllFromArray($data); return $pastes; } protected function didFilterPage(array $pastes) { if ($this->needRawContent) { $pastes = $this->loadRawContent($pastes); } if ($this->needContent) { $pastes = $this->loadContent($pastes); } return $pastes; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } if ($this->authorPHIDs) { $where[] = qsprintf( $conn, 'authorPHID IN (%Ls)', $this->authorPHIDs); } if ($this->parentPHIDs) { $where[] = qsprintf( $conn, 'parentPHID IN (%Ls)', $this->parentPHIDs); } if ($this->languages) { $where[] = qsprintf( $conn, 'language IN (%Ls)', $this->languages); } if ($this->dateCreatedAfter) { $where[] = qsprintf( $conn, 'dateCreated >= %d', $this->dateCreatedAfter); } if ($this->dateCreatedBefore) { $where[] = qsprintf( $conn, 'dateCreated <= %d', $this->dateCreatedBefore); } return $where; } private function getContentCacheKey(PhabricatorPaste $paste) { return implode( ':', array( 'P'.$paste->getID(), $paste->getFilePHID(), $paste->getLanguage(), )); } private function loadRawContent(array $pastes) { $file_phids = mpull($pastes, 'getFilePHID'); $files = id(new PhabricatorFileQuery()) ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); foreach ($pastes as $key => $paste) { $file = idx($files, $paste->getFilePHID()); if (!$file) { unset($pastes[$key]); continue; } try { $paste->attachRawContent($file->loadFileData()); } catch (Exception $ex) { // We can hit various sorts of file storage issues here. Just drop the // paste if the file is dead. unset($pastes[$key]); continue; } } return $pastes; } private function loadContent(array $pastes) { $cache = new PhabricatorKeyValueDatabaseCache(); $cache = new PhutilKeyValueCacheProfiler($cache); $cache->setProfiler(PhutilServiceProfiler::getInstance()); $keys = array(); foreach ($pastes as $paste) { $keys[] = $this->getContentCacheKey($paste); } $caches = $cache->getKeys($keys); $need_raw = array(); $have_cache = array(); foreach ($pastes as $paste) { $key = $this->getContentCacheKey($paste); if (isset($caches[$key])) { $paste->attachContent(phutil_safe_html($caches[$key])); $have_cache[$paste->getPHID()] = true; } else { $need_raw[$key] = $paste; } } if (!$need_raw) { return $pastes; } $write_data = array(); $have_raw = $this->loadRawContent($need_raw); $have_raw = mpull($have_raw, null, 'getPHID'); foreach ($pastes as $key => $paste) { $paste_phid = $paste->getPHID(); if (isset($have_cache[$paste_phid])) { continue; } if (empty($have_raw[$paste_phid])) { unset($pastes[$key]); continue; } $content = $this->buildContent($paste); $paste->attachContent($content); $write_data[$this->getContentCacheKey($paste)] = (string)$content; } if ($write_data) { $cache->setKeys($write_data); } return $pastes; } private function buildContent(PhabricatorPaste $paste) { $language = $paste->getLanguage(); $source = $paste->getRawContent(); if (empty($language)) { return PhabricatorSyntaxHighlighter::highlightWithFilename( $paste->getTitle(), $source); } else { return PhabricatorSyntaxHighlighter::highlightWithLanguage( $language, $source); } } public function getQueryApplicationClass() { return 'PhabricatorPasteApplication'; } } diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php index 865d5da155..46cb9352f7 100644 --- a/src/applications/policy/filter/PhabricatorPolicyFilter.php +++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php @@ -1,679 +1,747 @@ setViewer($user) ->requireCapabilities(array($capability)) ->raisePolicyExceptions(true) ->apply(array($object)); } /** * Perform a capability check, acting as though an object had a specific * policy. This is primarily used to check if a policy is valid (for example, * to prevent users from editing away their ability to edit an object). * * Specifically, a check like this: * * PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy( * $viewer, * $object, * PhabricatorPolicyCapability::CAN_EDIT, * $potential_new_policy); * * ...will throw a @{class:PhabricatorPolicyException} if the new policy would * remove the user's ability to edit the object. * * @param PhabricatorUser The viewer to perform a policy check for. * @param PhabricatorPolicyInterface The object to perform a policy check on. * @param string Capability to test. * @param string Perform the test as though the object has this * policy instead of the policy it actually has. * @return void */ public static function requireCapabilityWithForcedPolicy( PhabricatorUser $viewer, PhabricatorPolicyInterface $object, $capability, $forced_policy) { id(new PhabricatorPolicyFilter()) ->setViewer($viewer) ->requireCapabilities(array($capability)) ->raisePolicyExceptions(true) ->forcePolicy($forced_policy) ->apply(array($object)); } public static function hasCapability( PhabricatorUser $user, PhabricatorPolicyInterface $object, $capability) { $filter = new PhabricatorPolicyFilter(); $filter->setViewer($user); $filter->requireCapabilities(array($capability)); $result = $filter->apply(array($object)); return (count($result) == 1); } public function setViewer(PhabricatorUser $user) { $this->viewer = $user; return $this; } public function requireCapabilities(array $capabilities) { $this->capabilities = $capabilities; return $this; } public function raisePolicyExceptions($raise) { $this->raisePolicyExceptions = $raise; return $this; } public function forcePolicy($forced_policy) { $this->forcedPolicy = $forced_policy; return $this; } public function apply(array $objects) { assert_instances_of($objects, 'PhabricatorPolicyInterface'); $viewer = $this->viewer; $capabilities = $this->capabilities; if (!$viewer || !$capabilities) { throw new PhutilInvalidStateException('setViewer', 'requireCapabilities'); } // If the viewer is omnipotent, short circuit all the checks and just // return the input unmodified. This is an optimization; we know the // result already. if ($viewer->isOmnipotent()) { return $objects; } $filtered = array(); $viewer_phid = $viewer->getPHID(); if (empty($this->userProjects[$viewer_phid])) { $this->userProjects[$viewer_phid] = array(); } $need_projects = array(); $need_policies = array(); foreach ($objects as $key => $object) { $object_capabilities = $object->getCapabilities(); foreach ($capabilities as $capability) { if (!in_array($capability, $object_capabilities)) { throw new Exception( pht( "Testing for capability '%s' on an object which does ". "not have that capability!", $capability)); } $policy = $this->getObjectPolicy($object, $capability); $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { $need_projects[$policy] = $policy; } if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { $need_policies[$policy] = $policy; } } } if ($need_policies) { $this->loadCustomPolicies(array_keys($need_policies)); } // If we need projects, check if any of the projects we need are also the // objects we're filtering. Because of how project rules work, this is a // common case. if ($need_projects) { foreach ($objects as $object) { if ($object instanceof PhabricatorProject) { $project_phid = $object->getPHID(); if (isset($need_projects[$project_phid])) { $is_member = $object->isUserMember($viewer_phid); $this->userProjects[$viewer_phid][$project_phid] = $is_member; unset($need_projects[$project_phid]); } } } } if ($need_projects) { $need_projects = array_unique($need_projects); // NOTE: We're using the omnipotent user here to avoid a recursive // descent into madness. We don't actually need to know if the user can // see these projects or not, since: the check is "user is member of // project", not "user can see project"; and membership implies // visibility anyway. Without this, we may load other projects and // re-enter the policy filter and generally create a huge mess. $projects = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withMemberPHIDs(array($viewer->getPHID())) ->withPHIDs($need_projects) ->execute(); foreach ($projects as $project) { $this->userProjects[$viewer_phid][$project->getPHID()] = true; } } foreach ($objects as $key => $object) { foreach ($capabilities as $capability) { if (!$this->checkCapability($object, $capability)) { // If we're missing any capability, move on to the next object. continue 2; } } // If we make it here, we have all of the required capabilities. $filtered[$key] = $object; } // If we survied the primary checks, apply extended checks to objects // with extended policies. $results = array(); $extended = array(); foreach ($filtered as $key => $object) { if ($object instanceof PhabricatorExtendedPolicyInterface) { $extended[$key] = $object; } else { $results[$key] = $object; } } if ($extended) { $results += $this->applyExtendedPolicyChecks($extended); // Put results back in the original order. $results = array_select_keys($results, array_keys($filtered)); } return $results; } private function applyExtendedPolicyChecks(array $extended_objects) { // First, we're going to detect cycles and reject any objects which are // part of a cycle. We don't want to loop forever if an object has a // self-referential or nonsense policy. static $in_flight = array(); $all_phids = array(); foreach ($extended_objects as $key => $object) { $phid = $object->getPHID(); if (isset($in_flight[$phid])) { // TODO: This could be more user-friendly. $this->rejectObject($extended_objects[$key], false, ''); unset($extended_objects[$key]); continue; } // We might throw from rejectObject(), so we don't want to actually mark // anything as in-flight until we survive this entire step. $all_phids[$phid] = $phid; } foreach ($all_phids as $phid) { $in_flight[$phid] = true; } $caught = null; try { $extended_objects = $this->executeExtendedPolicyChecks($extended_objects); } catch (Exception $ex) { $caught = $ex; } foreach ($all_phids as $phid) { unset($in_flight[$phid]); } if ($caught) { throw $caught; } return $extended_objects; } private function executeExtendedPolicyChecks(array $extended_objects) { $viewer = $this->viewer; $filter_capabilities = $this->capabilities; // Iterate over the objects we need to filter and pull all the nonempty // policies into a flat, structured list. $all_structs = array(); foreach ($extended_objects as $key => $extended_object) { foreach ($filter_capabilities as $extended_capability) { $extended_policies = $extended_object->getExtendedPolicy( $extended_capability, $viewer); if (!$extended_policies) { continue; } foreach ($extended_policies as $extended_policy) { list($object, $capabilities) = $extended_policy; // Build a description of the capabilities we need to check. This // will be something like `"view"`, or `"edit view"`, or possibly // a longer string with custom capabilities. Later, group the objects // up into groups which need the same capabilities tested. $capabilities = (array)$capabilities; $capabilities = array_fuse($capabilities); ksort($capabilities); $group = implode(' ', $capabilities); $struct = array( 'key' => $key, 'for' => $extended_capability, 'object' => $object, 'capabilities' => $capabilities, 'group' => $group, ); $all_structs[] = $struct; } } } // Extract any bare PHIDs from the structs; we need to load these objects. // These are objects which are required in order to perform an extended // policy check but which the original viewer did not have permission to // see (they presumably had other permissions which let them load the // object in the first place). $all_phids = array(); foreach ($all_structs as $idx => $struct) { $object = $struct['object']; if (is_string($object)) { $all_phids[$object] = $object; } } // If we have some bare PHIDs, we need to load the corresponding objects. if ($all_phids) { // We can pull these with the omnipotent user because we're immediately // filtering them. $ref_objects = id(new PhabricatorObjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($all_phids) ->execute(); $ref_objects = mpull($ref_objects, null, 'getPHID'); } else { $ref_objects = array(); } // Group the list of checks by the capabilities we need to check. $groups = igroup($all_structs, 'group'); foreach ($groups as $structs) { $head = head($structs); // All of the items in each group are checking for the same capabilities. $capabilities = $head['capabilities']; $key_map = array(); $objects_in = array(); foreach ($structs as $struct) { $extended_key = $struct['key']; if (empty($extended_objects[$key])) { // If this object has already been rejected by an earlier filtering // pass, we don't need to do any tests on it. continue; } $object = $struct['object']; if (is_string($object)) { // This is really a PHID, so look it up. $object_phid = $object; if (empty($ref_objects[$object_phid])) { // We weren't able to load the corresponding object, so just // reject this result outright. $reject = $extended_objects[$key]; unset($extended_objects[$key]); // TODO: This could be friendlier. $this->rejectObject($reject, false, ''); continue; } $object = $ref_objects[$object_phid]; } $phid = $object->getPHID(); $key_map[$phid][] = $extended_key; $objects_in[$phid] = $object; } if ($objects_in) { $objects_out = id(new PhabricatorPolicyFilter()) ->setViewer($viewer) ->requireCapabilities($capabilities) ->apply($objects_in); $objects_out = mpull($objects_out, null, 'getPHID'); } else { $objects_out = array(); } // If any objects were removed by filtering, we're going to reject all // of the original objects which needed them. foreach ($objects_in as $phid => $object_in) { if (isset($objects_out[$phid])) { // This object survived filtering, so we don't need to throw any // results away. continue; } foreach ($key_map[$phid] as $extended_key) { if (empty($extended_objects[$extended_key])) { // We've already rejected this object, so we don't need to reject // it again. continue; } $reject = $extended_objects[$extended_key]; unset($extended_objects[$extended_key]); // TODO: This isn't as user-friendly as it could be. It's possible // that we're rejecting this object for multiple capability/policy // failures, though. $this->rejectObject($reject, false, ''); } } } return $extended_objects; } private function checkCapability( PhabricatorPolicyInterface $object, $capability) { $policy = $this->getObjectPolicy($object, $capability); if (!$policy) { // TODO: Formalize this somehow? $policy = PhabricatorPolicies::POLICY_USER; } if ($policy == PhabricatorPolicies::POLICY_PUBLIC) { // If the object is set to "public" but that policy is disabled for this // install, restrict the policy to "user". if (!PhabricatorEnv::getEnvConfig('policy.allow-public')) { $policy = PhabricatorPolicies::POLICY_USER; } // If the object is set to "public" but the capability is not a public // capability, restrict the policy to "user". $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); if (!$capobj || !$capobj->shouldAllowPublicPolicySetting()) { $policy = PhabricatorPolicies::POLICY_USER; } } $viewer = $this->viewer; if ($viewer->isOmnipotent()) { return true; } + if ($object instanceof PhabricatorSpacesInterface) { + $space_phid = $object->getSpacePHID(); + if (!$this->canViewerSeeObjectsInSpace($viewer, $space_phid)) { + $this->rejectObjectFromSpace($object, $space_phid); + return false; + } + } + if ($object->hasAutomaticCapability($capability, $viewer)) { return true; } switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: return true; case PhabricatorPolicies::POLICY_USER: if ($viewer->getPHID()) { return true; } else { $this->rejectObject($object, $policy, $capability); } break; case PhabricatorPolicies::POLICY_ADMIN: if ($viewer->getIsAdmin()) { return true; } else { $this->rejectObject($object, $policy, $capability); } break; case PhabricatorPolicies::POLICY_NOONE: $this->rejectObject($object, $policy, $capability); break; default: $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { if (!empty($this->userProjects[$viewer->getPHID()][$policy])) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) { if ($viewer->getPHID() == $policy) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { if ($this->checkCustomPolicy($policy)) { return true; } else { $this->rejectObject($object, $policy, $capability); } } else { // Reject objects with unknown policies. $this->rejectObject($object, false, $capability); } } return false; } public function rejectObject( PhabricatorPolicyInterface $object, $policy, $capability) { if (!$this->raisePolicyExceptions) { return; } if ($this->viewer->isOmnipotent()) { // Never raise policy exceptions for the omnipotent viewer. Although we // will never normally issue a policy rejection for the omnipotent // viewer, we can end up here when queries blanket reject objects that // have failed to load, without distinguishing between nonexistent and // nonvisible objects. return; } $capobj = PhabricatorPolicyCapability::getCapabilityByKey($capability); $rejection = null; if ($capobj) { $rejection = $capobj->describeCapabilityRejection(); $capability_name = $capobj->getCapabilityName(); } else { $capability_name = $capability; } if (!$rejection) { // We couldn't find the capability object, or it doesn't provide a // tailored rejection string. $rejection = pht( 'You do not have the required capability ("%s") to do whatever you '. 'are trying to do.', $capability); } $more = PhabricatorPolicy::getPolicyExplanation($this->viewer, $policy); $exceptions = $object->describeAutomaticCapability($capability); $details = array_filter(array_merge(array($more), (array)$exceptions)); $access_denied = $this->renderAccessDenied($object); $full_message = pht( '[%s] (%s) %s // %s', $access_denied, $capability_name, $rejection, implode(' ', $details)); $exception = id(new PhabricatorPolicyException($full_message)) ->setTitle($access_denied) ->setRejection($rejection) ->setCapabilityName($capability_name) ->setMoreInfo($details); throw $exception; } private function loadCustomPolicies(array $phids) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $custom_policies = id(new PhabricatorPolicyQuery()) ->setViewer($viewer) ->withPHIDs($phids) ->execute(); $custom_policies = mpull($custom_policies, null, 'getPHID'); $classes = array(); $values = array(); foreach ($custom_policies as $policy) { foreach ($policy->getCustomRuleClasses() as $class) { $classes[$class] = $class; $values[$class][] = $policy->getCustomRuleValues($class); } } foreach ($classes as $class => $ignored) { $object = newv($class, array()); $object->willApplyRules($viewer, array_mergev($values[$class])); $classes[$class] = $object; } foreach ($custom_policies as $policy) { $policy->attachRuleObjects($classes); } if (empty($this->customPolicies[$viewer_phid])) { $this->customPolicies[$viewer_phid] = array(); } $this->customPolicies[$viewer->getPHID()] += $custom_policies; } private function checkCustomPolicy($policy_phid) { $viewer = $this->viewer; $viewer_phid = $viewer->getPHID(); $policy = idx($this->customPolicies[$viewer_phid], $policy_phid); if (!$policy) { // Reject, this policy is bogus. return false; } $objects = $policy->getRuleObjects(); $action = null; foreach ($policy->getRules() as $rule) { $object = idx($objects, idx($rule, 'rule')); if (!$object) { // Reject, this policy has a bogus rule. return false; } // If the user matches this rule, use this action. if ($object->applyRule($viewer, idx($rule, 'value'))) { $action = idx($rule, 'action'); break; } } if ($action === null) { $action = $policy->getDefaultAction(); } if ($action === PhabricatorPolicy::ACTION_ALLOW) { return true; } return false; } private function getObjectPolicy( PhabricatorPolicyInterface $object, $capability) { if ($this->forcedPolicy) { return $this->forcedPolicy; } else { return $object->getPolicy($capability); } } private function renderAccessDenied(PhabricatorPolicyInterface $object) { // NOTE: Not every type of policy object has a real PHID; just load an // empty handle if a real PHID isn't available. $phid = nonempty($object->getPHID(), PhabricatorPHIDConstants::PHID_VOID); $handle = id(new PhabricatorHandleQuery()) ->setViewer($this->viewer) ->withPHIDs(array($phid)) ->executeOne(); $object_name = $handle->getObjectName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($is_serious) { $access_denied = pht( 'Access Denied: %s', $object_name); } else { $access_denied = pht( 'You Shall Not Pass: %s', $object_name); } return $access_denied; } + + private function canViewerSeeObjectsInSpace( + PhabricatorUser $viewer, + $space_phid) { + + $spaces = PhabricatorSpacesNamespaceQuery::getAllSpaces(); + + // If there are no spaces, everything exists in an implicit default space + // with no policy controls. This is the default state. + if (!$spaces) { + if ($space_phid !== null) { + return false; + } else { + return true; + } + } + + if ($space_phid === null) { + $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); + } else { + $space = idx($spaces, $space_phid); + } + + if (!$space) { + return false; + } + + // This may be more involved later, but for now being able to see the + // space is equivalent to being able to see everything in it. + return self::hasCapability( + $viewer, + $space, + PhabricatorPolicyCapability::CAN_VIEW); + } + + private function rejectObjectFromSpace( + PhabricatorPolicyInterface $object, + $space_phid) { + + if (!$this->raisePolicyExceptions) { + return; + } + + if ($this->viewer->isOmnipotent()) { + return; + } + + $access_denied = $this->renderAccessDenied($object); + + $rejection = pht( + 'This object is in a space you do not have permission to access.'); + $full_message = pht('[%s] %s', $access_denied, $rejection); + + $exception = id(new PhabricatorPolicyException($full_message)) + ->setTitle($access_denied) + ->setRejection($rejection); + + throw $exception; + } + } diff --git a/src/applications/spaces/__tests__/PhabricatorSpacesTestCase.php b/src/applications/spaces/__tests__/PhabricatorSpacesTestCase.php index cca38d277a..f17d0f404a 100644 --- a/src/applications/spaces/__tests__/PhabricatorSpacesTestCase.php +++ b/src/applications/spaces/__tests__/PhabricatorSpacesTestCase.php @@ -1,135 +1,233 @@ true, ); } public function testSpacesAnnihilation() { $this->destroyAllSpaces(); // Test that our helper methods work correctly. $actor = $this->generateNewTestUser(); - $this->newSpace($actor, pht('Test Space'), true); + + $default = $this->newSpace($actor, pht('Test Space'), true); $this->assertEqual(1, count($this->loadAllSpaces())); + $this->assertEqual( + 1, + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); + $cache_default = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); + $this->assertEqual($default->getPHID(), $cache_default->getPHID()); + $this->destroyAllSpaces(); $this->assertEqual(0, count($this->loadAllSpaces())); + $this->assertEqual( + 0, + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); + $this->assertEqual( + null, + PhabricatorSpacesNamespaceQuery::getDefaultSpace()); } public function testSpacesSeveralSpaces() { $this->destroyAllSpaces(); // Try creating a few spaces, one of which is a default space. This should // work fine. $actor = $this->generateNewTestUser(); - $this->newSpace($actor, pht('Default Space'), true); + $default = $this->newSpace($actor, pht('Default Space'), true); $this->newSpace($actor, pht('Alternate Space'), false); $this->assertEqual(2, count($this->loadAllSpaces())); + $this->assertEqual( + 2, + count(PhabricatorSpacesNamespaceQuery::getAllSpaces())); + + $cache_default = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); + $this->assertEqual($default->getPHID(), $cache_default->getPHID()); } public function testSpacesRequireNames() { $this->destroyAllSpaces(); // Spaces must have nonempty names. $actor = $this->generateNewTestUser(); $caught = null; try { $options = array( 'continueOnNoEffect' => true, ); $this->newSpace($actor, '', true, $options); } catch (PhabricatorApplicationTransactionValidationException $ex) { $caught = $ex; } $this->assertTrue(($caught instanceof Exception)); } public function testSpacesUniqueDefaultSpace() { $this->destroyAllSpaces(); // It shouldn't be possible to create two default spaces. $actor = $this->generateNewTestUser(); $this->newSpace($actor, pht('Default Space'), true); $caught = null; try { $this->newSpace($actor, pht('Default Space #2'), true); } catch (AphrontDuplicateKeyQueryException $ex) { $caught = $ex; } $this->assertTrue(($caught instanceof Exception)); } + public function testSpacesPolicyFiltering() { + $this->destroyAllSpaces(); + + $creator = $this->generateNewTestUser(); + $viewer = $this->generateNewTestUser(); + + // Create a new paste. + $paste = PhabricatorPaste::initializeNewPaste($creator) + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) + ->setFilePHID('') + ->setLanguage('') + ->save(); + + // It should be visible. + $this->assertTrue( + PhabricatorPolicyFilter::hasCapability( + $viewer, + $paste, + PhabricatorPolicyCapability::CAN_VIEW)); + + // Create a default space with an open view policy. + $default = $this->newSpace($creator, pht('Default Space'), true) + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) + ->save(); + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); + + // The paste should now be in the space implicitly, but still visible + // because the space view policy is open. + $this->assertTrue( + PhabricatorPolicyFilter::hasCapability( + $viewer, + $paste, + PhabricatorPolicyCapability::CAN_VIEW)); + + // Make the space view policy restrictive. + $default + ->setViewPolicy(PhabricatorPolicies::POLICY_NOONE) + ->save(); + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); + + // The paste should be in the space implicitly, and no longer visible. + $this->assertFalse( + PhabricatorPolicyFilter::hasCapability( + $viewer, + $paste, + PhabricatorPolicyCapability::CAN_VIEW)); + + // Put the paste in the space explicitly. + $paste + ->setSpacePHID($default->getPHID()) + ->save(); + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); + + // This should still fail, we're just in the space explicitly now. + $this->assertFalse( + PhabricatorPolicyFilter::hasCapability( + $viewer, + $paste, + PhabricatorPolicyCapability::CAN_VIEW)); + + // Create an alternate space with more permissive policies, then move the + // paste to that space. + $alternate = $this->newSpace($creator, pht('Alternate Space'), false) + ->setViewPolicy(PhabricatorPolicies::POLICY_USER) + ->save(); + $paste + ->setSpacePHID($alternate->getPHID()) + ->save(); + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); + + // Now the paste should be visible again. + $this->assertTrue( + PhabricatorPolicyFilter::hasCapability( + $viewer, + $paste, + PhabricatorPolicyCapability::CAN_VIEW)); + } + private function loadAllSpaces() { return id(new PhabricatorSpacesNamespaceQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->execute(); } private function destroyAllSpaces() { + PhabricatorSpacesNamespaceQuery::destroySpacesCache(); $spaces = $this->loadAllSpaces(); foreach ($spaces as $space) { $engine = new PhabricatorDestructionEngine(); $engine->destroyObject($space); } } private function newSpace( PhabricatorUser $actor, $name, $is_default, array $options = array()) { $space = PhabricatorSpacesNamespace::initializeNewNamespace($actor); $type_name = PhabricatorSpacesNamespaceTransaction::TYPE_NAME; $type_default = PhabricatorSpacesNamespaceTransaction::TYPE_DEFAULT; $type_view = PhabricatorTransactions::TYPE_VIEW_POLICY; $type_edit = PhabricatorTransactions::TYPE_EDIT_POLICY; $xactions = array(); $xactions[] = id(new PhabricatorSpacesNamespaceTransaction()) ->setTransactionType($type_name) ->setNewValue($name); $xactions[] = id(new PhabricatorSpacesNamespaceTransaction()) ->setTransactionType($type_view) ->setNewValue($actor->getPHID()); $xactions[] = id(new PhabricatorSpacesNamespaceTransaction()) ->setTransactionType($type_edit) ->setNewValue($actor->getPHID()); if ($is_default) { $xactions[] = id(new PhabricatorSpacesNamespaceTransaction()) ->setTransactionType($type_default) ->setNewValue($is_default); } $content_source = PhabricatorContentSource::newConsoleSource(); $editor = id(new PhabricatorSpacesNamespaceEditor()) ->setActor($actor) ->setContentSource($content_source); if (isset($options['continueOnNoEffect'])) { $editor->setContinueOnNoEffect(true); } $editor->applyTransactions($space, $xactions); return $space; } } diff --git a/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php b/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php index be7b9a1fa0..815a18b927 100644 --- a/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php +++ b/src/applications/spaces/query/PhabricatorSpacesNamespaceQuery.php @@ -1,77 +1,144 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withIsDefaultNamespace($default) { $this->isDefaultNamespace = $default; return $this; } public function getQueryApplicationClass() { return 'PhabricatorSpacesApplication'; } protected function loadPage() { $table = new PhabricatorSpacesNamespace(); $conn_r = $table->establishConnection('r'); $rows = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); return $table->loadAllFromArray($rows); } protected function buildWhereClause(AphrontDatabaseConnection $conn_r) { $where = array(); if ($this->ids !== null) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->phids !== null) { $where[] = qsprintf( $conn_r, 'phid IN (%Ls)', $this->phids); } if ($this->isDefaultNamespace !== null) { if ($this->isDefaultNamespace) { $where[] = qsprintf( $conn_r, 'isDefaultNamespace = 1'); } else { $where[] = qsprintf( $conn_r, 'isDefaultNamespace IS NULL'); } } $where[] = $this->buildPagingClause($conn_r); return $this->formatWhereClause($where); } + public static function destroySpacesCache() { + $cache = PhabricatorCaches::getRequestCache(); + $cache->deleteKeys( + array( + self::KEY_ALL, + self::KEY_DEFAULT, + )); + } + + public static function getAllSpaces() { + $cache = PhabricatorCaches::getRequestCache(); + $cache_key = self::KEY_ALL; + + $spaces = $cache->getKey($cache_key); + if ($spaces === null) { + $spaces = id(new PhabricatorSpacesNamespaceQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->execute(); + $spaces = mpull($spaces, null, 'getPHID'); + $cache->setKey($cache_key, $spaces); + } + + return $spaces; + } + + public static function getDefaultSpace() { + $cache = PhabricatorCaches::getRequestCache(); + $cache_key = self::KEY_DEFAULT; + + $default_space = $cache->getKey($cache_key, false); + if ($default_space === false) { + $default_space = null; + + $spaces = self::getAllSpaces(); + foreach ($spaces as $space) { + if ($space->getIsDefaultNamespace()) { + $default_space = $space; + break; + } + } + + $cache->setKey($cache_key, $default_space); + } + + return $default_space; + } + + public static function getViewerSpaces(PhabricatorUser $viewer) { + $spaces = self::getAllSpaces(); + + $result = array(); + foreach ($spaces as $key => $space) { + $can_see = PhabricatorPolicyFilter::hasCapability( + $viewer, + $space, + PhabricatorPolicyCapability::CAN_VIEW); + if ($can_see) { + $result[$key] = $space; + } + } + + return $result; + } + } diff --git a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php index e206058164..5b98d0f7d3 100644 --- a/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php @@ -1,1614 +1,1776 @@ getResultCursor(head($page)), $this->getResultCursor(last($page)), ); } protected function getResultCursor($object) { if (!is_object($object)) { throw new Exception( pht( 'Expected object, got "%s".', gettype($object))); } return $object->getID(); } protected function nextPage(array $page) { // See getPagingViewer() for a description of this flag. $this->internalPaging = true; if ($this->beforeID !== null) { $page = array_reverse($page, $preserve_keys = true); list($before, $after) = $this->getPageCursors($page); $this->beforeID = $before; } else { list($before, $after) = $this->getPageCursors($page); $this->afterID = $after; } } final public function setAfterID($object_id) { $this->afterID = $object_id; return $this; } final protected function getAfterID() { return $this->afterID; } final public function setBeforeID($object_id) { $this->beforeID = $object_id; return $this; } final protected function getBeforeID() { return $this->beforeID; } /** * Get the viewer for making cursor paging queries. * * NOTE: You should ONLY use this viewer to load cursor objects while * building paging queries. * * Cursor paging can happen in two ways. First, the user can request a page * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we * can fall back to implicit paging if we filter some results out of a * result list because the user can't see them and need to go fetch some more * results to generate a large enough result list. * * In the first case, want to use the viewer's policies to load the object. * This prevents an attacker from figuring out information about an object * they can't see by executing queries like `/stuff/?after=33&order=name`, * which would otherwise give them a hint about the name of the object. * Generally, if a user can't see an object, they can't use it to page. * * In the second case, we need to load the object whether the user can see * it or not, because we need to examine new results. For example, if a user * loads `/stuff/` and we run a query for the first 100 items that they can * see, but the first 100 rows in the database aren't visible, we need to * be able to issue a query for the next 100 results. If we can't load the * cursor object, we'll fail or issue the same query over and over again. * So, generally, internal paging must bypass policy controls. * * This method returns the appropriate viewer, based on the context in which * the paging is occuring. * * @return PhabricatorUser Viewer for executing paging queries. */ final protected function getPagingViewer() { if ($this->internalPaging) { return PhabricatorUser::getOmnipotentUser(); } else { return $this->getViewer(); } } final protected function buildLimitClause(AphrontDatabaseConnection $conn_r) { if ($this->getRawResultLimit()) { return qsprintf($conn_r, 'LIMIT %d', $this->getRawResultLimit()); } else { return ''; } } final protected function didLoadResults(array $results) { if ($this->beforeID) { $results = array_reverse($results, $preserve_keys = true); } return $results; } final public function executeWithCursorPager(AphrontCursorPagerView $pager) { $limit = $pager->getPageSize(); $this->setLimit($limit + 1); if ($pager->getAfterID()) { $this->setAfterID($pager->getAfterID()); } else if ($pager->getBeforeID()) { $this->setBeforeID($pager->getBeforeID()); } $results = $this->execute(); $count = count($results); $sliced_results = $pager->sliceResults($results); if ($sliced_results) { list($before, $after) = $this->getPageCursors($sliced_results); if ($pager->getBeforeID() || ($count > $limit)) { $pager->setNextPageID($after); } if ($pager->getAfterID() || ($pager->getBeforeID() && ($count > $limit))) { $pager->setPrevPageID($before); } } return $sliced_results; } /** * Return the alias this query uses to identify the primary table. * * Some automatic query constructions may need to be qualified with a table * alias if the query performs joins which make column names ambiguous. If * this is the case, return the alias for the primary table the query * uses; generally the object table which has `id` and `phid` columns. * * @return string Alias for the primary table. */ protected function getPrimaryTableAlias() { return null; } protected function newResultObject() { return null; } /* -( Building Query Clauses )--------------------------------------------- */ /** * @task clauses */ protected function buildSelectClause(AphrontDatabaseConnection $conn) { $parts = $this->buildSelectClauseParts($conn); return $this->formatSelectClause($parts); } /** * @task clauses */ protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { $select = array(); $alias = $this->getPrimaryTableAlias(); if ($alias) { $select[] = qsprintf($conn, '%T.*', $alias); } else { $select[] = '*'; } $select[] = $this->buildEdgeLogicSelectClause($conn); return $select; } /** * @task clauses */ protected function buildJoinClause(AphrontDatabaseConnection $conn) { $joins = $this->buildJoinClauseParts($conn); return $this->formatJoinClause($joins); } /** * @task clauses */ protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = array(); $joins[] = $this->buildEdgeLogicJoinClause($conn); $joins[] = $this->buildApplicationSearchJoinClause($conn); return $joins; } /** * @task clauses */ protected function buildWhereClause(AphrontDatabaseConnection $conn) { $where = $this->buildWhereClauseParts($conn); return $this->formatWhereClause($where); } /** * @task clauses */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); $where[] = $this->buildPagingClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); + $where[] = $this->buildSpacesWhereClause($conn); return $where; } /** * @task clauses */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); return $this->formatHavingClause($having); } /** * @task clauses */ protected function buildHavingClauseParts(AphrontDatabaseConnection $conn) { $having = array(); $having[] = $this->buildEdgeLogicHavingClause($conn); return $having; } /** * @task clauses */ protected function buildGroupClause(AphrontDatabaseConnection $conn) { if (!$this->shouldGroupQueryResultRows()) { return ''; } return qsprintf( $conn, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn()); } /** * @task clauses */ protected function shouldGroupQueryResultRows() { if ($this->shouldGroupEdgeLogicResultRows()) { return true; } if ($this->getApplicationSearchMayJoinMultipleRows()) { return true; } return false; } /* -( Paging )------------------------------------------------------------- */ /** * @task paging */ protected function buildPagingClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); if ($this->beforeID !== null) { $cursor = $this->beforeID; $reversed = true; } else if ($this->afterID !== null) { $cursor = $this->afterID; $reversed = false; } else { // No paging is being applied to this query so we do not need to // construct a paging clause. return ''; } $keys = array(); foreach ($vector as $order) { $keys[] = $order->getOrderKey(); } $value_map = $this->getPagingValueMap($cursor, $keys); $columns = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); if (!array_key_exists($key, $value_map)) { throw new Exception( pht( 'Query "%s" failed to return a value from getPagingValueMap() '. 'for column "%s".', get_class($this), $key)); } $column = $orderable[$key]; $column['value'] = $value_map[$key]; $columns[] = $column; } return $this->buildPagingClauseFromMultipleColumns( $conn, $columns, array( 'reversed' => $reversed, )); } /** * @task paging */ protected function getPagingValueMap($cursor, array $keys) { return array( 'id' => $cursor, ); } /** * @task paging */ protected function loadCursorObject($cursor) { $query = newv(get_class($this), array()) ->setViewer($this->getPagingViewer()) ->withIDs(array((int)$cursor)); $this->willExecuteCursorQuery($query); $object = $query->executeOne(); if (!$object) { throw new Exception( pht( 'Cursor "%s" does not identify a valid object.', $cursor)); } return $object; } /** * @task paging */ protected function willExecuteCursorQuery( PhabricatorCursorPagedPolicyAwareQuery $query) { return; } /** * Simplifies the task of constructing a paging clause across multiple * columns. In the general case, this looks like: * * A > a OR (A = a AND B > b) OR (A = a AND B = b AND C > c) * * To build a clause, specify the name, type, and value of each column * to include: * * $this->buildPagingClauseFromMultipleColumns( * $conn_r, * array( * array( * 'table' => 't', * 'column' => 'title', * 'type' => 'string', * 'value' => $cursor->getTitle(), * 'reverse' => true, * ), * array( * 'table' => 't', * 'column' => 'id', * 'type' => 'int', * 'value' => $cursor->getID(), * ), * ), * array( * 'reversed' => $is_reversed, * )); * * This method will then return a composable clause for inclusion in WHERE. * * @param AphrontDatabaseConnection Connection query will execute on. * @param list Column description dictionaries. * @param map Additional constuction options. * @return string Query clause. * @task paging */ final protected function buildPagingClauseFromMultipleColumns( AphrontDatabaseConnection $conn, array $columns, array $options) { foreach ($columns as $column) { PhutilTypeSpec::checkMap( $column, array( 'table' => 'optional string|null', 'column' => 'string', 'value' => 'wild', 'type' => 'string', 'reverse' => 'optional bool', 'unique' => 'optional bool', 'null' => 'optional string|null', )); } PhutilTypeSpec::checkMap( $options, array( 'reversed' => 'optional bool', )); $is_query_reversed = idx($options, 'reversed', false); $clauses = array(); $accumulated = array(); $last_key = last_key($columns); foreach ($columns as $key => $column) { $type = $column['type']; $null = idx($column, 'null'); if ($column['value'] === null) { if ($null) { $value = null; } else { throw new Exception( pht( 'Column "%s" has null value, but does not specify a null '. 'behavior.', $key)); } } else { switch ($type) { case 'int': $value = qsprintf($conn, '%d', $column['value']); break; case 'float': $value = qsprintf($conn, '%f', $column['value']); break; case 'string': $value = qsprintf($conn, '%s', $column['value']); break; default: throw new Exception( pht( 'Column "%s" has unknown column type "%s".', $column['column'], $type)); } } $is_column_reversed = idx($column, 'reverse', false); $reverse = ($is_query_reversed xor $is_column_reversed); $clause = $accumulated; $table_name = idx($column, 'table'); $column_name = $column['column']; if ($table_name !== null) { $field = qsprintf($conn, '%T.%T', $table_name, $column_name); } else { $field = qsprintf($conn, '%T', $column_name); } $parts = array(); if ($null) { $can_page_if_null = ($null === 'head'); $can_page_if_nonnull = ($null === 'tail'); if ($reverse) { $can_page_if_null = !$can_page_if_null; $can_page_if_nonnull = !$can_page_if_nonnull; } $subclause = null; if ($can_page_if_null && $value === null) { $parts[] = qsprintf( $conn, '(%Q IS NOT NULL)', $field); } else if ($can_page_if_nonnull && $value !== null) { $parts[] = qsprintf( $conn, '(%Q IS NULL)', $field); } } if ($value !== null) { $parts[] = qsprintf( $conn, '%Q %Q %Q', $field, $reverse ? '>' : '<', $value); } if ($parts) { if (count($parts) > 1) { $clause[] = '('.implode(') OR (', $parts).')'; } else { $clause[] = head($parts); } } if ($clause) { if (count($clause) > 1) { $clauses[] = '('.implode(') AND (', $clause).')'; } else { $clauses[] = head($clause); } } if ($value === null) { $accumulated[] = qsprintf( $conn, '%Q IS NULL', $field); } else { $accumulated[] = qsprintf( $conn, '%Q = %Q', $field, $value); } } return '('.implode(') OR (', $clauses).')'; } /* -( Result Ordering )---------------------------------------------------- */ /** * Select a result ordering. * * This is a high-level method which selects an ordering from a predefined * list of builtin orders, as provided by @{method:getBuiltinOrders}. These * options are user-facing and not exhaustive, but are generally convenient * and meaningful. * * You can also use @{method:setOrderVector} to specify a low-level ordering * across individual orderable columns. This offers greater control but is * also more involved. * * @param string Key of a builtin order supported by this query. * @return this * @task order */ public function setOrder($order) { $orders = $this->getBuiltinOrders(); if (empty($orders[$order])) { throw new Exception( pht( 'Query "%s" does not support a builtin order "%s". Supported orders '. 'are: %s.', get_class($this), $order, implode(', ', array_keys($orders)))); } $this->builtinOrder = $order; $this->orderVector = null; return $this; } /** * Get builtin orders for this class. * * In application UIs, we want to be able to present users with a small * selection of meaningful order options (like "Order by Title") rather than * an exhaustive set of column ordering options. * * Meaningful user-facing orders are often really orders across multiple * columns: for example, a "title" ordering is usually implemented as a * "title, id" ordering under the hood. * * Builtin orders provide a mapping from convenient, understandable * user-facing orders to implementations. * * A builtin order should provide these keys: * * - `vector` (`list`): The actual order vector to use. * - `name` (`string`): Human-readable order name. * * @return map Map from builtin order keys to specification. * @task order */ public function getBuiltinOrders() { $orders = array( 'newest' => array( 'vector' => array('id'), 'name' => pht('Creation (Newest First)'), 'aliases' => array('created'), ), 'oldest' => array( 'vector' => array('-id'), 'name' => pht('Creation (Oldest First)'), ), ); $object = $this->newResultObject(); if ($object instanceof PhabricatorCustomFieldInterface) { $list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); foreach ($list->getFields() as $field) { $index = $field->buildOrderIndex(); if (!$index) { continue; } $key = $field->getFieldKey(); $digest = $field->getFieldIndex(); $full_key = 'custom:'.$key; $orders[$full_key] = array( 'vector' => array($full_key, 'id'), 'name' => $field->getFieldName(), ); } } return $orders; } /** * Set a low-level column ordering. * * This is a low-level method which offers granular control over column * ordering. In most cases, applications can more easily use * @{method:setOrder} to choose a high-level builtin order. * * To set an order vector, specify a list of order keys as provided by * @{method:getOrderableColumns}. * * @param PhabricatorQueryOrderVector|list List of order keys. * @return this * @task order */ public function setOrderVector($vector) { $vector = PhabricatorQueryOrderVector::newFromVector($vector); $orderable = $this->getOrderableColumns(); // Make sure that all the components identify valid columns. $unique = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); if (empty($orderable[$key])) { $valid = implode(', ', array_keys($orderable)); throw new Exception( pht( 'This query ("%s") does not support sorting by order key "%s". '. 'Supported orders are: %s.', get_class($this), $key, $valid)); } $unique[$key] = idx($orderable[$key], 'unique', false); } // Make sure that the last column is unique so that this is a strong // ordering which can be used for paging. $last = last($unique); if ($last !== true) { throw new Exception( pht( 'Order vector "%s" is invalid: the last column in an order must '. 'be a column with unique values, but "%s" is not unique.', $vector->getAsString(), last_key($unique))); } // Make sure that other columns are not unique; an ordering like "id, name" // does not make sense because only "id" can ever have an effect. array_pop($unique); foreach ($unique as $key => $is_unique) { if ($is_unique) { throw new Exception( pht( 'Order vector "%s" is invalid: only the last column in an order '. 'may be unique, but "%s" is a unique column and not the last '. 'column in the order.', $vector->getAsString(), $key)); } } $this->orderVector = $vector; return $this; } /** * Get the effective order vector. * * @return PhabricatorQueryOrderVector Effective vector. * @task order */ protected function getOrderVector() { if (!$this->orderVector) { if ($this->builtinOrder !== null) { $builtin_order = idx($this->getBuiltinOrders(), $this->builtinOrder); $vector = $builtin_order['vector']; } else { $vector = $this->getDefaultOrderVector(); } $vector = PhabricatorQueryOrderVector::newFromVector($vector); // We call setOrderVector() here to apply checks to the default vector. // This catches any errors in the implementation. $this->setOrderVector($vector); } return $this->orderVector; } /** * @task order */ protected function getDefaultOrderVector() { return array('id'); } /** * @task order */ public function getOrderableColumns() { $columns = array( 'id' => array( 'table' => $this->getPrimaryTableAlias(), 'column' => 'id', 'reverse' => false, 'type' => 'int', 'unique' => true, ), ); $object = $this->newResultObject(); if ($object instanceof PhabricatorCustomFieldInterface) { $list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); foreach ($list->getFields() as $field) { $index = $field->buildOrderIndex(); if (!$index) { continue; } $key = $field->getFieldKey(); $digest = $field->getFieldIndex(); $full_key = 'custom:'.$key; $columns[$full_key] = array( 'table' => 'appsearch_order_'.$digest, 'column' => 'indexValue', 'type' => $index->getIndexValueType(), 'null' => 'tail', ); } } return $columns; } /** * @task order */ final protected function buildOrderClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); $vector = $this->getOrderVector(); $parts = array(); foreach ($vector as $order) { $part = $orderable[$order->getOrderKey()]; if ($order->getIsReversed()) { $part['reverse'] = !idx($part, 'reverse', false); } $parts[] = $part; } return $this->formatOrderClause($conn, $parts); } /** * @task order */ protected function formatOrderClause( AphrontDatabaseConnection $conn, array $parts) { $is_query_reversed = false; if ($this->getBeforeID()) { $is_query_reversed = !$is_query_reversed; } $sql = array(); foreach ($parts as $key => $part) { $is_column_reversed = !empty($part['reverse']); $descending = true; if ($is_query_reversed) { $descending = !$descending; } if ($is_column_reversed) { $descending = !$descending; } $table = idx($part, 'table'); $column = $part['column']; if ($table !== null) { $field = qsprintf($conn, '%T.%T', $table, $column); } else { $field = qsprintf($conn, '%T', $column); } $null = idx($part, 'null'); if ($null) { switch ($null) { case 'head': $null_field = qsprintf($conn, '(%Q IS NULL)', $field); break; case 'tail': $null_field = qsprintf($conn, '(%Q IS NOT NULL)', $field); break; default: throw new Exception( pht( 'NULL value "%s" is invalid. Valid values are "head" and '. '"tail".', $null)); } if ($descending) { $sql[] = qsprintf($conn, '%Q DESC', $null_field); } else { $sql[] = qsprintf($conn, '%Q ASC', $null_field); } } if ($descending) { $sql[] = qsprintf($conn, '%Q DESC', $field); } else { $sql[] = qsprintf($conn, '%Q ASC', $field); } } return qsprintf($conn, 'ORDER BY %Q', implode(', ', $sql)); } /* -( Application Search )------------------------------------------------- */ /** * Constrain the query with an ApplicationSearch index, requiring field values * contain at least one of the values in a set. * * This constraint can build the most common types of queries, like: * * - Find users with shirt sizes "X" or "XL". * - Find shoes with size "13". * * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. * @param string|list One or more values to filter by. * @return this * @task appsearch */ public function withApplicationSearchContainsConstraint( PhabricatorCustomFieldIndexStorage $index, $value) { $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => '=', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'value' => $value, ); return $this; } /** * Constrain the query with an ApplicationSearch index, requiring values * exist in a given range. * * This constraint is useful for expressing date ranges: * * - Find events between July 1st and July 7th. * * The ends of the range are inclusive, so a `$min` of `3` and a `$max` of * `5` will match fields with values `3`, `4`, or `5`. Providing `null` for * either end of the range will leave that end of the constraint open. * * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. * @param int|null Minimum permissible value, inclusive. * @param int|null Maximum permissible value, inclusive. * @return this * @task appsearch */ public function withApplicationSearchRangeConstraint( PhabricatorCustomFieldIndexStorage $index, $min, $max) { $index_type = $index->getIndexValueType(); if ($index_type != 'int') { throw new Exception( pht( 'Attempting to apply a range constraint to a field with index type '. '"%s", expected type "%s".', $index_type, 'int')); } $this->applicationSearchConstraints[] = array( 'type' => $index->getIndexValueType(), 'cond' => 'range', 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'value' => array($min, $max), ); return $this; } /** * Order the results by an ApplicationSearch index. * * @param PhabricatorCustomField Field to which the index belongs. * @param PhabricatorCustomFieldIndexStorage Table where the index is stored. * @param bool True to sort ascending. * @return this * @task appsearch */ public function withApplicationSearchOrder( PhabricatorCustomField $field, PhabricatorCustomFieldIndexStorage $index, $ascending) { $this->applicationSearchOrders[] = array( 'key' => $field->getFieldKey(), 'type' => $index->getIndexValueType(), 'table' => $index->getTableName(), 'index' => $index->getIndexKey(), 'ascending' => $ascending, ); return $this; } /** * Get the name of the query's primary object PHID column, for constructing * JOIN clauses. Normally (and by default) this is just `"phid"`, but it may * be something more exotic. * * See @{method:getPrimaryTableAlias} if the column needs to be qualified with * a table alias. * * @return string Column name. * @task appsearch */ protected function getApplicationSearchObjectPHIDColumn() { if ($this->getPrimaryTableAlias()) { $prefix = $this->getPrimaryTableAlias().'.'; } else { $prefix = ''; } return $prefix.'phid'; } /** * Determine if the JOINs built by ApplicationSearch might cause each primary * object to return multiple result rows. Generally, this means the query * needs an extra GROUP BY clause. * * @return bool True if the query may return multiple rows for each object. * @task appsearch */ protected function getApplicationSearchMayJoinMultipleRows() { foreach ($this->applicationSearchConstraints as $constraint) { $type = $constraint['type']; $value = $constraint['value']; $cond = $constraint['cond']; switch ($cond) { case '=': switch ($type) { case 'string': case 'int': if (count((array)$value) > 1) { return true; } break; default: throw new Exception(pht('Unknown index type "%s"!', $type)); } break; case 'range': // NOTE: It's possible to write a custom field where multiple rows // match a range constraint, but we don't currently ship any in the // upstream and I can't immediately come up with cases where this // would make sense. break; default: throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); } } return false; } /** * Construct a GROUP BY clause appropriate for ApplicationSearch constraints. * * @param AphrontDatabaseConnection Connection executing the query. * @return string Group clause. * @task appsearch */ protected function buildApplicationSearchGroupClause( AphrontDatabaseConnection $conn_r) { if ($this->getApplicationSearchMayJoinMultipleRows()) { return qsprintf( $conn_r, 'GROUP BY %Q', $this->getApplicationSearchObjectPHIDColumn()); } else { return ''; } } /** * Construct a JOIN clause appropriate for applying ApplicationSearch * constraints. * * @param AphrontDatabaseConnection Connection executing the query. * @return string Join clause. * @task appsearch */ protected function buildApplicationSearchJoinClause( AphrontDatabaseConnection $conn_r) { $joins = array(); foreach ($this->applicationSearchConstraints as $key => $constraint) { $table = $constraint['table']; $alias = 'appsearch_'.$key; $index = $constraint['index']; $cond = $constraint['cond']; $phid_column = $this->getApplicationSearchObjectPHIDColumn(); switch ($cond) { case '=': $type = $constraint['type']; switch ($type) { case 'string': $constraint_clause = qsprintf( $conn_r, '%T.indexValue IN (%Ls)', $alias, (array)$constraint['value']); break; case 'int': $constraint_clause = qsprintf( $conn_r, '%T.indexValue IN (%Ld)', $alias, (array)$constraint['value']); break; default: throw new Exception(pht('Unknown index type "%s"!', $type)); } $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND (%Q)', $table, $alias, $alias, $phid_column, $alias, $index, $constraint_clause); break; case 'range': list($min, $max) = $constraint['value']; if (($min === null) && ($max === null)) { // If there's no actual range constraint, just move on. break; } if ($min === null) { $constraint_clause = qsprintf( $conn_r, '%T.indexValue <= %d', $alias, $max); } else if ($max === null) { $constraint_clause = qsprintf( $conn_r, '%T.indexValue >= %d', $alias, $min); } else { $constraint_clause = qsprintf( $conn_r, '%T.indexValue BETWEEN %d AND %d', $alias, $min, $max); } $joins[] = qsprintf( $conn_r, 'JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s AND (%Q)', $table, $alias, $alias, $phid_column, $alias, $index, $constraint_clause); break; default: throw new Exception(pht('Unknown constraint condition "%s"!', $cond)); } } foreach ($this->applicationSearchOrders as $key => $order) { $table = $order['table']; $index = $order['index']; $alias = 'appsearch_order_'.$index; $phid_column = $this->getApplicationSearchObjectPHIDColumn(); $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T %T ON %T.objectPHID = %Q AND %T.indexKey = %s', $table, $alias, $alias, $phid_column, $alias, $index); } return implode(' ', $joins); } /* -( Integration with CustomField )--------------------------------------- */ /** * @task customfield */ protected function getPagingValueMapForCustomFields( PhabricatorCustomFieldInterface $object) { // We have to get the current field values on the cursor object. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->setViewer($this->getViewer()); $fields->readFieldsFromStorage($object); $map = array(); foreach ($fields->getFields() as $field) { $map['custom:'.$field->getFieldKey()] = $field->getValueForStorage(); } return $map; } /** * @task customfield */ protected function isCustomFieldOrderKey($key) { $prefix = 'custom:'; return !strncmp($key, $prefix, strlen($prefix)); } /* -( Edge Logic )--------------------------------------------------------- */ /** * Convenience method for specifying edge logic constraints with a list of * PHIDs. * * @param const Edge constant. * @param const Constraint operator. * @param list List of PHIDs. * @return this * @task edgelogic */ public function withEdgeLogicPHIDs($edge_type, $operator, array $phids) { $constraints = array(); foreach ($phids as $phid) { $constraints[] = new PhabricatorQueryConstraint($operator, $phid); } return $this->withEdgeLogicConstraints($edge_type, $constraints); } /** * @task edgelogic */ public function withEdgeLogicConstraints($edge_type, array $constraints) { assert_instances_of($constraints, 'PhabricatorQueryConstraint'); $constraints = mgroup($constraints, 'getOperator'); foreach ($constraints as $operator => $list) { foreach ($list as $item) { $value = $item->getValue(); $this->edgeLogicConstraints[$edge_type][$operator][$value] = $item; } } $this->edgeLogicConstraintsAreValid = false; return $this; } /** * @task edgelogic */ public function buildEdgeLogicSelectClause(AphrontDatabaseConnection $conn) { $select = array(); $this->validateEdgeLogicConstraints(); foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_AND: if (count($list) > 1) { $select[] = qsprintf( $conn, 'COUNT(DISTINCT(%T.dst)) %T', $alias, $this->buildEdgeLogicTableAliasCount($alias)); } break; default: break; } } } return $select; } /** * @task edgelogic */ public function buildEdgeLogicJoinClause(AphrontDatabaseConnection $conn) { $edge_table = PhabricatorEdgeConfig::TABLE_NAME_EDGE; $phid_column = $this->getApplicationSearchObjectPHIDColumn(); $joins = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { $op_null = PhabricatorQueryConstraint::OPERATOR_NULL; $has_null = isset($constraints[$op_null]); foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: $joins[] = qsprintf( $conn, 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d AND %T.dst IN (%Ls)', $edge_table, $alias, $phid_column, $alias, $alias, $type, $alias, mpull($list, 'getValue')); break; case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: // If we're including results with no matches, we have to degrade // this to a LEFT join. We'll use WHERE to select matching rows // later. if ($has_null) { $join_type = 'LEFT'; } else { $join_type = ''; } $joins[] = qsprintf( $conn, '%Q JOIN %T %T ON %Q = %T.src AND %T.type = %d AND %T.dst IN (%Ls)', $join_type, $edge_table, $alias, $phid_column, $alias, $alias, $type, $alias, mpull($list, 'getValue')); break; case PhabricatorQueryConstraint::OPERATOR_NULL: $joins[] = qsprintf( $conn, 'LEFT JOIN %T %T ON %Q = %T.src AND %T.type = %d', $edge_table, $alias, $phid_column, $alias, $alias, $type); break; } } } return $joins; } /** * @task edgelogic */ public function buildEdgeLogicWhereClause(AphrontDatabaseConnection $conn) { $where = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { $full = array(); $null = array(); $op_null = PhabricatorQueryConstraint::OPERATOR_NULL; $has_null = isset($constraints[$op_null]); foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: $full[] = qsprintf( $conn, '%T.dst IS NULL', $alias); break; case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: if ($has_null) { $full[] = qsprintf( $conn, '%T.dst IS NOT NULL', $alias); } break; case PhabricatorQueryConstraint::OPERATOR_NULL: $null[] = qsprintf( $conn, '%T.dst IS NULL', $alias); break; } } if ($full && $null) { $full = $this->formatWhereSubclause($full); $null = $this->formatWhereSubclause($null); $where[] = qsprintf($conn, '(%Q OR %Q)', $full, $null); } else if ($full) { foreach ($full as $condition) { $where[] = $condition; } } else if ($null) { foreach ($null as $condition) { $where[] = $condition; } } } return $where; } /** * @task edgelogic */ public function buildEdgeLogicHavingClause(AphrontDatabaseConnection $conn) { $having = array(); foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { $alias = $this->getEdgeLogicTableAlias($operator, $type); switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_AND: if (count($list) > 1) { $having[] = qsprintf( $conn, '%T = %d', $this->buildEdgeLogicTableAliasCount($alias), count($list)); } break; } } } return $having; } /** * @task edgelogic */ public function shouldGroupEdgeLogicResultRows() { foreach ($this->edgeLogicConstraints as $type => $constraints) { foreach ($constraints as $operator => $list) { switch ($operator) { case PhabricatorQueryConstraint::OPERATOR_NOT: case PhabricatorQueryConstraint::OPERATOR_AND: case PhabricatorQueryConstraint::OPERATOR_OR: if (count($list) > 1) { return true; } break; case PhabricatorQueryConstraint::OPERATOR_NULL: return true; } } } return false; } /** * @task edgelogic */ private function getEdgeLogicTableAlias($operator, $type) { return 'edgelogic_'.$operator.'_'.$type; } /** * @task edgelogic */ private function buildEdgeLogicTableAliasCount($alias) { return $alias.'_count'; } /** * Select certain edge logic constraint values. * * @task edgelogic */ protected function getEdgeLogicValues( array $edge_types, array $operators) { $values = array(); $constraint_lists = $this->edgeLogicConstraints; if ($edge_types) { $constraint_lists = array_select_keys($constraint_lists, $edge_types); } foreach ($constraint_lists as $type => $constraints) { if ($operators) { $constraints = array_select_keys($constraints, $operators); } foreach ($constraints as $operator => $list) { foreach ($list as $constraint) { $values[] = $constraint->getValue(); } } } return $values; } /** * Validate edge logic constraints for the query. * * @return this * @task edgelogic */ private function validateEdgeLogicConstraints() { if ($this->edgeLogicConstraintsAreValid) { return $this; } // This should probably be more modular, eventually, but we only do // project-based edge logic today. $project_phids = $this->getEdgeLogicValues( array( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, ), array( PhabricatorQueryConstraint::OPERATOR_AND, PhabricatorQueryConstraint::OPERATOR_OR, PhabricatorQueryConstraint::OPERATOR_NOT, )); if ($project_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->setParentQuery($this) ->withPHIDs($project_phids) ->execute(); $projects = mpull($projects, null, 'getPHID'); foreach ($project_phids as $phid) { if (empty($projects[$phid])) { throw new PhabricatorEmptyQueryException( pht( 'This query is constrained by a project you do not have '. 'permission to see.')); } } } $this->edgeLogicConstraintsAreValid = true; return $this; } +/* -( Spaces )------------------------------------------------------------- */ + + + /** + * Constrain the query to return results from only specific Spaces. + * + * Pass a list of Space PHIDs, or `null` to represent the default space. Only + * results in those Spaces will be returned. + * + * Queries are always constrained to include only results from spaces the + * viewer has access to. + * + * @param list + * @task spaces + */ + public function withSpacePHIDs(array $space_phids) { + $object = $this->newResultObject(); + + if (!$object) { + throw new Exception( + pht( + 'This query (of class "%s") does not implement newResultObject(), '. + 'but must implement this method to enable support for Spaces.', + get_class($this))); + } + + if (!($object instanceof PhabricatorSpacesInterface)) { + throw new Exception( + pht( + 'This query (of class "%s") returned an object of class "%s" from '. + 'getNewResultObject(), but it does not implement the required '. + 'interface ("%s"). Objects must implement this interface to enable '. + 'Spaces support.', + get_class($this), + get_class($object), + 'PhabricatorSpacesInterface')); + } + + $this->spacePHIDs = $space_phids; + + return $this; + } + + + /** + * Constrain the query to include only results in valid Spaces. + * + * This method builds part of a WHERE clause which considers the spaces the + * viewer has access to see with any explicit constraint on spaces added by + * @{method:withSpacePHIDs}. + * + * @param AphrontDatabaseConnection Database connection. + * @return string Part of a WHERE clause. + * @task spaces + */ + private function buildSpacesWhereClause(AphrontDatabaseConnection $conn) { + $object = $this->newResultObject(); + if (!$object) { + return null; + } + + if (!($object instanceof PhabricatorSpacesInterface)) { + return null; + } + + $viewer = $this->getViewer(); + + $space_phids = array(); + $include_null = false; + + $all = PhabricatorSpacesNamespaceQuery::getAllSpaces(); + if (!$all) { + // If there are no spaces at all, implicitly give the viewer access to + // the default space. + $include_null = true; + } else { + // Otherwise, give them access to the spaces they have permission to + // see. + $viewer_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces( + $viewer); + foreach ($viewer_spaces as $viewer_space) { + $phid = $viewer_space->getPHID(); + $space_phids[$phid] = $phid; + if ($viewer_space->getIsDefaultNamespace()) { + $include_null = true; + } + } + } + + // If we have additional explicit constraints, evaluate them now. + if ($this->spacePHIDs !== null) { + $explicit = array(); + $explicit_null = false; + foreach ($this->spacePHIDs as $phid) { + if ($phid === null) { + $space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); + } else { + $space = idx($all, $phid); + } + + if ($space) { + $phid = $space->getPHID(); + $explicit[$phid] = $phid; + if ($space->getIsDefaultNamespace()) { + $explicit_null = true; + } + } + } + + // If the viewer can see the default space but it isn't on the explicit + // list of spaces to query, don't match it. + if ($include_null && !$explicit_null) { + $include_null = false; + } + + // Include only the spaces common to the viewer and the constraints. + $space_phids = array_intersect_key($space_phids, $explicit); + } + + if (!$space_phids && !$include_null) { + if ($this->spacePHIDs === null) { + throw new PhabricatorEmptyQueryException( + pht('You do not have access to any spaces.')); + } else { + throw new PhabricatorEmptyQueryException( + pht( + 'You do not have access to any of the spaces this query '. + 'is constrained to.')); + } + } + + $alias = $this->getPrimaryTableAlias(); + if ($alias) { + $col = qsprintf($conn, '%T.spacePHID', $alias); + } else { + $col = 'spacePHID'; + } + + if ($space_phids && $include_null) { + return qsprintf( + $conn, + '(%Q IN (%Ls) OR %Q IS NULL)', + $col, + $space_phids, + $col); + } else if ($space_phids) { + return qsprintf( + $conn, + '%Q IN (%Ls)', + $col, + $space_phids); + } else { + return qsprintf( + $conn, + '%Q IS NULL', + $col); + } + } + }