diff --git a/src/applications/phriction/controller/PhrictionDocumentController.php b/src/applications/phriction/controller/PhrictionDocumentController.php index 9fbd16e5ca..c4b1d03cb3 100644 --- a/src/applications/phriction/controller/PhrictionDocumentController.php +++ b/src/applications/phriction/controller/PhrictionDocumentController.php @@ -1,467 +1,464 @@ slug = $data['slug']; } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $slug = PhabricatorSlug::normalize($this->slug); if ($slug != $this->slug) { $uri = PhrictionDocument::getSlugURI($slug); // Canonicalize pages to their one true URI. return id(new AphrontRedirectResponse())->setURI($uri); } require_celerity_resource('phriction-document-css'); $document = id(new PhrictionDocumentQuery()) ->setViewer($user) ->withSlugs(array($slug)) ->executeOne(); $version_note = null; $core_content = ''; $move_notice = ''; $properties = null; if (!$document) { $document = new PhrictionDocument(); if (PhrictionDocument::isProjectSlug($slug)) { $project = id(new PhabricatorProjectQuery()) ->setViewer($user) ->withPhrictionSlugs(array( PhrictionDocument::getProjectSlugIdentifier($slug))) ->executeOne(); if (!$project) { return new Aphront404Response(); } } $create_uri = '/phriction/edit/?slug='.$slug; $notice = new AphrontErrorView(); $notice->setSeverity(AphrontErrorView::SEVERITY_NODATA); $notice->setTitle(pht('No content here!')); $notice->appendChild( pht( 'No document found at %s. You can '. 'create a new document here.', phutil_tag('tt', array(), $slug), $create_uri)); $core_content = $notice; $page_title = pht('Page Not Found'); } else { $version = $request->getInt('v'); if ($version) { $content = id(new PhrictionContent())->loadOneWhere( 'documentID = %d AND version = %d', $document->getID(), $version); if (!$content) { return new Aphront404Response(); } if ($content->getID() != $document->getContentID()) { $vdate = phabricator_datetime($content->getDateCreated(), $user); $version_note = new AphrontErrorView(); $version_note->setSeverity(AphrontErrorView::SEVERITY_NOTICE); $version_note->setTitle('Older Version'); $version_note->appendChild( pht('You are viewing an older version of this document, as it '. 'appeared on %s.', $vdate)); } } else { $content = id(new PhrictionContent())->load($document->getContentID()); } $page_title = $content->getTitle(); $properties = $this ->buildPropertyListView($document, $content, $slug); $doc_status = $document->getStatus(); $current_status = $content->getChangeType(); if ($current_status == PhrictionChangeType::CHANGE_EDIT || $current_status == PhrictionChangeType::CHANGE_MOVE_HERE) { $core_content = $content->renderContent($user); } else if ($current_status == PhrictionChangeType::CHANGE_DELETE) { $notice = new AphrontErrorView(); $notice->setSeverity(AphrontErrorView::SEVERITY_NOTICE); $notice->setTitle(pht('Document Deleted')); $notice->appendChild( pht('This document has been deleted. You can edit it to put new '. 'content here, or use history to revert to an earlier version.')); $core_content = $notice->render(); } else if ($current_status == PhrictionChangeType::CHANGE_STUB) { $notice = new AphrontErrorView(); $notice->setSeverity(AphrontErrorView::SEVERITY_NOTICE); $notice->setTitle(pht('Empty Document')); $notice->appendChild( pht('This document is empty. You can edit it to put some proper '. 'content here.')); $core_content = $notice->render(); } else if ($current_status == PhrictionChangeType::CHANGE_MOVE_AWAY) { $new_doc_id = $content->getChangeRef(); $new_doc = id(new PhrictionDocumentQuery()) ->setViewer($user) ->withIDs(array($new_doc_id)) - ->exectueOne(); + ->executeOne(); $slug_uri = PhrictionDocument::getSlugURI($new_doc->getSlug()); $notice = new AphrontErrorView(); $notice->setSeverity(AphrontErrorView::SEVERITY_NOTICE); $notice->setTitle(pht('Document Moved')); $notice->appendChild(phutil_tag('p', array(), pht('This document has been moved to %s. You can edit it to put new '. 'content here, or use history to revert to an earlier version.', phutil_tag('a', array('href' => $slug_uri), $slug_uri)))); $core_content = $notice->render(); } else { throw new Exception("Unknown document status '{$doc_status}'!"); } $move_notice = null; if ($current_status == PhrictionChangeType::CHANGE_MOVE_HERE) { $from_doc_id = $content->getChangeRef(); $from_doc = id(new PhrictionDocumentQuery()) ->setViewer($user) ->withIDs(array($from_doc_id)) ->executeOne(); $slug_uri = PhrictionDocument::getSlugURI($from_doc->getSlug()); $move_notice = id(new AphrontErrorView()) ->setSeverity(AphrontErrorView::SEVERITY_NOTICE) ->appendChild(pht('This document was moved from %s', phutil_tag('a', array('href' => $slug_uri), $slug_uri))) ->render(); } } if ($version_note) { $version_note = $version_note->render(); } $children = $this->renderDocumentChildren($slug); $actions = $this->buildActionView($user, $document); $crumbs = $this->buildApplicationCrumbs(); $crumbs->setActionList($actions); $crumb_views = $this->renderBreadcrumbs($slug); foreach ($crumb_views as $view) { $crumbs->addCrumb($view); } $header = id(new PHUIHeaderView()) ->setHeader($page_title); $prop_list = null; if ($properties) { $prop_list = new PHUIPropertyGroupView(); $prop_list->addPropertyList($properties); } $page_content = id(new PHUIDocumentView()) ->setOffset(true) ->setHeader($header) ->appendChild( array( $actions, $prop_list, $move_notice, $core_content, )); $core_page = phutil_tag( 'div', array( 'class' => 'phriction-offset' ), array( $page_content, $children, )); return $this->buildApplicationPage( array( $crumbs->render(), $core_page, ), array( 'pageObjects' => array($document->getPHID()), 'title' => $page_title, 'device' => true, )); } private function buildPropertyListView( PhrictionDocument $document, PhrictionContent $content, $slug) { $viewer = $this->getRequest()->getUser(); $view = id(new PHUIPropertyListView()) ->setUser($viewer) ->setObject($document); $project_phid = null; if (PhrictionDocument::isProjectSlug($slug)) { $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPhrictionSlugs(array( PhrictionDocument::getProjectSlugIdentifier($slug))) ->executeOne(); if ($project) { $project_phid = $project->getPHID(); } } $phids = array_filter( array( $content->getAuthorPHID(), $project_phid, )); $this->loadHandles($phids); $project_info = null; if ($project_phid) { $view->addProperty( pht('Project Info'), $this->getHandle($project_phid)->renderLink()); } $view->addProperty( pht('Last Author'), $this->getHandle($content->getAuthorPHID())->renderLink()); $age = time() - $content->getDateCreated(); $age = floor($age / (60 * 60 * 24)); if ($age < 1) { $when = pht('Today'); } else if ($age == 1) { $when = pht('Yesterday'); } else { $when = pht("%d Days Ago", $age); } $view->addProperty(pht('Last Updated'), $when); return $view; } private function buildActionView( PhabricatorUser $user, PhrictionDocument $document) { $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $document, PhabricatorPolicyCapability::CAN_EDIT); $slug = PhabricatorSlug::normalize($this->slug); $action_view = id(new PhabricatorActionListView()) ->setUser($user) ->setObjectURI($this->getRequest()->getRequestURI()) ->setObject($document); if (!$document->getID()) { return $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('Create This Document')) ->setIcon('fa-plus-square') ->setHref('/phriction/edit/?slug='.$slug)); } $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('Edit Document')) ->setIcon('fa-pencil') ->setHref('/phriction/edit/'.$document->getID().'/')); if ($document->getStatus() == PhrictionDocumentStatus::STATUS_EXISTS) { $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('Move Document')) ->setIcon('fa-arrows') ->setHref('/phriction/move/'.$document->getID().'/') ->setWorkflow(true)); $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('Delete Document')) ->setIcon('fa-times') ->setHref('/phriction/delete/'.$document->getID().'/') ->setWorkflow(true)); } return $action_view->addAction( id(new PhabricatorActionView()) ->setName(pht('View History')) ->setIcon('fa-list') ->setHref(PhrictionDocument::getSlugURI($slug, 'history'))); } private function renderDocumentChildren($slug) { $document_dao = new PhrictionDocument(); $content_dao = new PhrictionContent(); $conn = $document_dao->establishConnection('r'); $limit = 250; $d_child = PhabricatorSlug::getDepth($slug) + 1; $d_grandchild = PhabricatorSlug::getDepth($slug) + 2; // Select children and grandchildren. $children = queryfx_all( $conn, 'SELECT d.slug, d.depth, c.title FROM %T d JOIN %T c ON d.contentID = c.id WHERE d.slug LIKE %> AND d.depth IN (%d, %d) AND d.status IN (%Ld) ORDER BY d.depth, c.title LIMIT %d', $document_dao->getTableName(), $content_dao->getTableName(), ($slug == '/' ? '' : $slug), $d_child, $d_grandchild, array( PhrictionDocumentStatus::STATUS_EXISTS, PhrictionDocumentStatus::STATUS_STUB, ), $limit); if (!$children) { return; } // We're going to render in one of three modes to try to accommodate // different information scales: // // - If we found fewer than $limit rows, we know we have all the children // and grandchildren and there aren't all that many. We can just render // everything. // - If we found $limit rows but the results included some grandchildren, // we just throw them out and render only the children, as we know we // have them all. // - If we found $limit rows and the results have no grandchildren, we // have a ton of children. Render them and then let the user know that // this is not an exhaustive list. if (count($children) == $limit) { $more_children = true; foreach ($children as $child) { if ($child['depth'] == $d_grandchild) { $more_children = false; } } $show_grandchildren = false; } else { $show_grandchildren = true; $more_children = false; } $grandchildren = array(); foreach ($children as $key => $child) { if ($child['depth'] == $d_child) { continue; } else { unset($children[$key]); if ($show_grandchildren) { $ancestors = PhabricatorSlug::getAncestry($child['slug']); $grandchildren[end($ancestors)][] = $child; } } } // Fill in any missing children. $known_slugs = ipull($children, null, 'slug'); foreach ($grandchildren as $slug => $ignored) { if (empty($known_slugs[$slug])) { $children[] = array( 'slug' => $slug, 'depth' => $d_child, 'title' => PhabricatorSlug::getDefaultTitle($slug), 'empty' => true, ); } } $children = isort($children, 'title'); $list = array(); foreach ($children as $child) { $list[] = hsprintf('
  • '); $list[] = $this->renderChildDocumentLink($child); $grand = idx($grandchildren, $child['slug'], array()); if ($grand) { $list[] = hsprintf(''); } $list[] = hsprintf('
  • '); } if ($more_children) { $list[] = phutil_tag('li', array(), pht('More...')); } $content = array( phutil_tag( 'div', array( 'class' => 'phriction-children-header '. 'sprite-gradient gradient-lightblue-header', ), pht('Document Hierarchy')), phutil_tag( 'div', array( 'class' => 'phriction-children', ), phutil_tag('ul', array(), $list)), ); return id(new PHUIDocumentView()) ->setOffset(true) ->appendChild($content); } private function renderChildDocumentLink(array $info) { $title = nonempty($info['title'], pht('(Untitled Document)')); $item = phutil_tag( 'a', array( 'href' => PhrictionDocument::getSlugURI($info['slug']), ), $title); if (isset($info['empty'])) { $item = phutil_tag('em', array(), $item); } return $item; } protected function getDocumentSlug() { return $this->slug; } } diff --git a/src/applications/phriction/phid/PhrictionPHIDTypeDocument.php b/src/applications/phriction/phid/PhrictionPHIDTypeDocument.php index 8cb4835176..01034362c6 100644 --- a/src/applications/phriction/phid/PhrictionPHIDTypeDocument.php +++ b/src/applications/phriction/phid/PhrictionPHIDTypeDocument.php @@ -1,54 +1,54 @@ needContent(true) - ->withPHIDs($phids); + ->withPHIDs($phids) + ->needContent(true); } public function loadHandles( PhabricatorHandleQuery $query, array $handles, array $objects) { foreach ($handles as $phid => $handle) { $document = $objects[$phid]; $content = $document->getContent(); $title = $content->getTitle(); $slug = $document->getSlug(); $status = $document->getStatus(); $handle->setName($title); $handle->setURI(PhrictionDocument::getSlugURI($slug)); if ($status != PhrictionDocumentStatus::STATUS_EXISTS) { $handle->setStatus(PhabricatorObjectHandleStatus::STATUS_CLOSED); } } } } diff --git a/src/applications/phriction/query/PhrictionDocumentQuery.php b/src/applications/phriction/query/PhrictionDocumentQuery.php index 3e0b712235..c673f988b2 100644 --- a/src/applications/phriction/query/PhrictionDocumentQuery.php +++ b/src/applications/phriction/query/PhrictionDocumentQuery.php @@ -1,199 +1,275 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withSlugs(array $slugs) { $this->slugs = $slugs; return $this; } public function withStatus($status) { $this->status = $status; return $this; } public function needContent($need_content) { $this->needContent = $need_content; return $this; } public function setOrder($order) { $this->order = $order; return $this; } protected function loadPage() { - $document = new PhrictionDocument(); - $conn_r = $document->establishConnection('r'); + $table = new PhrictionDocument(); + $conn_r = $table->establishConnection('r'); $rows = queryfx_all( $conn_r, 'SELECT * FROM %T %Q %Q %Q', - $document->getTableName(), + $table->getTableName(), $this->buildWhereClause($conn_r), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); - return $document->loadAllFromArray($rows); + $documents = $table->loadAllFromArray($rows); + + if ($documents) { + $ancestor_slugs = array(); + foreach ($documents as $key => $document) { + $document_slug = $document->getSlug(); + foreach (PhabricatorSlug::getAncestry($document_slug) as $ancestor) { + $ancestor_slugs[$ancestor][] = $key; + } + } + + if ($ancestor_slugs) { + $ancestors = queryfx_all( + $conn_r, + 'SELECT * FROM %T WHERE slug IN (%Ls)', + $document->getTableName(), + array_keys($ancestor_slugs)); + $ancestors = $table->loadAllFromArray($ancestors); + $ancestors = mpull($ancestors, null, 'getSlug'); + + foreach ($ancestor_slugs as $ancestor_slug => $document_keys) { + $ancestor = idx($ancestors, $ancestor_slug); + foreach ($document_keys as $document_key) { + $documents[$document_key]->attachAncestor( + $ancestor_slug, + $ancestor); + } + } + } + } + + return $documents; } protected function willFilterPage(array $documents) { + // To view a Phriction document, you must also be able to view all of the + // ancestor documents. Filter out documents which have ancestors that are + // not visible. + + $document_map = array(); + foreach ($documents as $document) { + $document_map[$document->getSlug()] = $document; + foreach ($document->getAncestors() as $key => $ancestor) { + if ($ancestor) { + $document_map[$key] = $ancestor; + } + } + } + + $filtered_map = $this->applyPolicyFilter( + $document_map, + array(PhabricatorPolicyCapability::CAN_VIEW)); + + // Filter all of the documents where a parent is not visible. + foreach ($documents as $document_key => $document) { + // If the document itself is not visible, filter it. + if (!isset($filtered_map[$document->getSlug()])) { + $this->didRejectResult($documents[$document_key]); + unset($documents[$document_key]); + continue; + } + + // If an ancestor exists but is not visible, filter the document. + foreach ($document->getAncestors() as $ancestor_key => $ancestor) { + if (!$ancestor) { + continue; + } + + if (!isset($filtered_map[$ancestor_key])) { + $this->didRejectResult($documents[$document_key]); + unset($documents[$document_key]); + break; + } + } + } + + if (!$documents) { + return $documents; + } + if ($this->needContent) { $contents = id(new PhrictionContent())->loadAllWhere( 'id IN (%Ld)', mpull($documents, 'getContentID')); foreach ($documents as $key => $document) { $content_id = $document->getContentID(); if (empty($contents[$content_id])) { unset($documents[$key]); continue; } $document->attachContent($contents[$content_id]); } } foreach ($documents as $document) { $document->attachProject(null); } $project_slugs = array(); foreach ($documents as $key => $document) { $slug = $document->getSlug(); if (!PhrictionDocument::isProjectSlug($slug)) { continue; } $project_slugs[$key] = PhrictionDocument::getProjectSlugIdentifier($slug); } if ($project_slugs) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withPhrictionSlugs($project_slugs) ->execute(); $projects = mpull($projects, null, 'getPhrictionSlug'); foreach ($documents as $key => $document) { $slug = idx($project_slugs, $key); if ($slug) { $project = idx($projects, $slug); if (!$project) { unset($documents[$key]); continue; } $document->attachProject($project); } } } return $documents; } protected function buildWhereClause(AphrontDatabaseConnection $conn) { $where = array(); if ($this->ids) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->phids) { $where[] = qsprintf( $conn, 'phid IN (%Ls)', $this->phids); } if ($this->slugs) { $where[] = qsprintf( $conn, 'slug IN (%Ls)', $this->slugs); } switch ($this->status) { case self::STATUS_OPEN: $where[] = qsprintf( $conn, 'status NOT IN (%Ld)', array( PhrictionDocumentStatus::STATUS_DELETED, PhrictionDocumentStatus::STATUS_MOVED, PhrictionDocumentStatus::STATUS_STUB, )); break; case self::STATUS_NONSTUB: $where[] = qsprintf( $conn, 'status NOT IN (%Ld)', array( PhrictionDocumentStatus::STATUS_MOVED, PhrictionDocumentStatus::STATUS_STUB, )); break; case self::STATUS_ANY: break; default: throw new Exception("Unknown status '{$this->status}'!"); } $where[] = $this->buildPagingClause($conn); return $this->formatWhereClause($where); } protected function getPagingColumn() { switch ($this->order) { case self::ORDER_CREATED: return 'id'; case self::ORDER_UPDATED: return 'contentID'; default: throw new Exception("Unknown order '{$this->order}'!"); } } protected function getPagingValue($result) { switch ($this->order) { case self::ORDER_CREATED: return $result->getID(); case self::ORDER_UPDATED: return $result->getContentID(); default: throw new Exception("Unknown order '{$this->order}'!"); } } public function getQueryApplicationClass() { return 'PhabricatorApplicationPhriction'; } } diff --git a/src/applications/phriction/storage/PhrictionDocument.php b/src/applications/phriction/storage/PhrictionDocument.php index 6cb0658e4f..8c5b5d58bc 100644 --- a/src/applications/phriction/storage/PhrictionDocument.php +++ b/src/applications/phriction/storage/PhrictionDocument.php @@ -1,163 +1,183 @@ true, self::CONFIG_TIMESTAMPS => false, ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhrictionPHIDTypeDocument::TYPECONST); } public static function getSlugURI($slug, $type = 'document') { static $types = array( 'document' => '/w/', 'history' => '/phriction/history/', ); if (empty($types[$type])) { throw new Exception("Unknown URI type '{$type}'!"); } $prefix = $types[$type]; if ($slug == '/') { return $prefix; } else { // NOTE: The effect here is to escape non-latin characters, since modern // browsers deal with escaped UTF8 characters in a reasonable way (showing // the user a readable URI) but older programs may not. $slug = phutil_escape_uri($slug); return $prefix.$slug; } } public function setSlug($slug) { $this->slug = PhabricatorSlug::normalize($slug); $this->depth = PhabricatorSlug::getDepth($slug); return $this; } public function attachContent(PhrictionContent $content) { $this->contentObject = $content; return $this; } public function getContent() { return $this->assertAttached($this->contentObject); } public function getProject() { return $this->assertAttached($this->project); } public function attachProject(PhabricatorProject $project = null) { $this->project = $project; return $this; } public function hasProject() { return (bool)$this->getProject(); } + public function getAncestors() { + return $this->ancestors; + } + + public function getAncestor($slug) { + return $this->assertAttachedKey($this->ancestors, $slug); + } + + public function attachAncestor($slug, $ancestor) { + $this->ancestors[$slug] = $ancestor; + return $this; + } + public static function isProjectSlug($slug) { $slug = PhabricatorSlug::normalize($slug); $prefix = 'projects/'; if ($slug == $prefix) { // The 'projects/' document is not itself a project slug. return false; } return !strncmp($slug, $prefix, strlen($prefix)); } public static function getProjectSlugIdentifier($slug) { if (!self::isProjectSlug($slug)) { throw new Exception("Slug '{$slug}' is not a project slug!"); } $slug = PhabricatorSlug::normalize($slug); $parts = explode('/', $slug); return $parts[1].'/'; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { if ($this->hasProject()) { return $this->getProject()->getPolicy($capability); } + return PhabricatorPolicies::POLICY_USER; } public function hasAutomaticCapability($capability, PhabricatorUser $user) { if ($this->hasProject()) { return $this->getProject()->hasAutomaticCapability($capability, $user); } return false; } public function describeAutomaticCapability($capability) { if ($this->hasProject()) { return pht( "This is a project wiki page, and inherits the project's policies."); } + + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return pht( + 'To view a wiki document, you must also be able to view all '. + 'of its parents.'); + } + return null; } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return false; } public function shouldShowSubscribersProperty() { return true; } public function shouldAllowSubscription($phid) { return true; } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return PhabricatorSubscribersQuery::loadSubscribersForPHID($this->phid); } } diff --git a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php index d2fd056e7f..34215d03b4 100644 --- a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php @@ -1,628 +1,641 @@ setViewer($user) * ->withConstraint($example) * ->execute(); * * Normally, you should extend @{class:PhabricatorCursorPagedPolicyAwareQuery}, * not this class. @{class:PhabricatorCursorPagedPolicyAwareQuery} provides a * more practical interface for building usable queries against most object * types. * * NOTE: Although this class extends @{class:PhabricatorOffsetPagedQuery}, * offset paging with policy filtering is not efficient. All results must be * loaded into the application and filtered here: skipping `N` rows via offset * is an `O(N)` operation with a large constant. Prefer cursor-based paging * with @{class:PhabricatorCursorPagedPolicyAwareQuery}, which can filter far * more efficiently in MySQL. * * @task config Query Configuration * @task exec Executing Queries * @task policyimpl Policy Query Implementation */ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { private $viewer; private $raisePolicyExceptions; private $parentQuery; private $rawResultLimit; private $capabilities; private $workspace = array(); private $policyFilteredPHIDs = array(); private $canUseApplication; /* -( Query Configuration )------------------------------------------------ */ /** * Set the viewer who is executing the query. Results will be filtered * according to the viewer's capabilities. You must set a viewer to execute * a policy query. * * @param PhabricatorUser The viewing user. * @return this * @task config */ final public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } /** * Get the query's viewer. * * @return PhabricatorUser The viewing user. * @task config */ final public function getViewer() { return $this->viewer; } /** * Set the parent query of this query. This is useful for nested queries so * that configuration like whether or not to raise policy exceptions is * seamlessly passed along to child queries. * * @return this * @task config */ final public function setParentQuery(PhabricatorPolicyAwareQuery $query) { $this->parentQuery = $query; return $this; } /** * Get the parent query. See @{method:setParentQuery} for discussion. * * @return PhabricatorPolicyAwareQuery The parent query. * @task config */ final public function getParentQuery() { return $this->parentQuery; } /** * Hook to configure whether this query should raise policy exceptions. * * @return this * @task config */ final public function setRaisePolicyExceptions($bool) { $this->raisePolicyExceptions = $bool; return $this; } /** * @return bool * @task config */ final public function shouldRaisePolicyExceptions() { return (bool)$this->raisePolicyExceptions; } /** * @task config */ final public function requireCapabilities(array $capabilities) { $this->capabilities = $capabilities; return $this; } /* -( Query Execution )---------------------------------------------------- */ /** * Execute the query, expecting a single result. This method simplifies * loading objects for detail pages or edit views. * * // Load one result by ID. * $obj = id(new ExampleQuery()) * ->setViewer($user) * ->withIDs(array($id)) * ->executeOne(); * if (!$obj) { * return new Aphront404Response(); * } * * If zero results match the query, this method returns `null`. * If one result matches the query, this method returns that result. * * If two or more results match the query, this method throws an exception. * You should use this method only when the query constraints guarantee at * most one match (e.g., selecting a specific ID or PHID). * * If one result matches the query but it is caught by the policy filter (for * example, the user is trying to view or edit an object which exists but * which they do not have permission to see) a policy exception is thrown. * * @return mixed Single result, or null. * @task exec */ final public function executeOne() { $this->setRaisePolicyExceptions(true); try { $results = $this->execute(); } catch (Exception $ex) { $this->setRaisePolicyExceptions(false); throw $ex; } if (count($results) > 1) { throw new Exception("Expected a single result!"); } if (!$results) { return null; } return head($results); } /** * Execute the query, loading all visible results. * * @return list Result objects. * @task exec */ final public function execute() { if (!$this->viewer) { throw new Exception("Call setViewer() before execute()!"); } $parent_query = $this->getParentQuery(); if ($parent_query) { $this->setRaisePolicyExceptions( $parent_query->shouldRaisePolicyExceptions()); } $results = array(); $filter = $this->getPolicyFilter(); $offset = (int)$this->getOffset(); $limit = (int)$this->getLimit(); $count = 0; if ($limit) { $need = $offset + $limit; } else { $need = 0; } $this->willExecute(); do { if ($need) { $this->rawResultLimit = min($need - $count, 1024); } else { $this->rawResultLimit = 0; } if ($this->canViewerUseQueryApplication()) { try { $page = $this->loadPage(); } catch (PhabricatorEmptyQueryException $ex) { $page = array(); } } else { $page = array(); } if ($page) { $maybe_visible = $this->willFilterPage($page); } else { $maybe_visible = array(); } if ($this->shouldDisablePolicyFiltering()) { $visible = $maybe_visible; } else { $visible = $filter->apply($maybe_visible); $policy_filtered = array(); foreach ($maybe_visible as $key => $object) { if (empty($visible[$key])) { $phid = $object->getPHID(); if ($phid) { $policy_filtered[$phid] = $phid; } } } $this->addPolicyFilteredPHIDs($policy_filtered); } if ($visible) { $this->putObjectsInWorkspace($this->getWorkspaceMapForPage($visible)); $visible = $this->didFilterPage($visible); } $removed = array(); foreach ($maybe_visible as $key => $object) { if (empty($visible[$key])) { $removed[$key] = $object; } } $this->didFilterResults($removed); foreach ($visible as $key => $result) { ++$count; // If we have an offset, we just ignore that many results and start // storing them only once we've hit the offset. This reduces memory // requirements for large offsets, compared to storing them all and // slicing them away later. if ($count > $offset) { $results[$key] = $result; } if ($need && ($count >= $need)) { // If we have all the rows we need, break out of the paging query. break 2; } } if (!$this->rawResultLimit) { // If we don't have a load count, we loaded all the results. We do // not need to load another page. break; } if (count($page) < $this->rawResultLimit) { // If we have a load count but the unfiltered results contained fewer // objects, we know this was the last page of objects; we do not need // to load another page because we can deduce it would be empty. break; } $this->nextPage($page); } while (true); $results = $this->didLoadResults($results); return $results; } private function getPolicyFilter() { $filter = new PhabricatorPolicyFilter(); $filter->setViewer($this->viewer); - if (!$this->capabilities) { - $capabilities = array( - PhabricatorPolicyCapability::CAN_VIEW, - ); - } else { - $capabilities = $this->capabilities; - } + $capabilities = $this->getRequiredCapabilities(); $filter->requireCapabilities($capabilities); $filter->raisePolicyExceptions($this->shouldRaisePolicyExceptions()); return $filter; } + protected function getRequiredCapabilities() { + if ($this->capabilities) { + return $this->capabilities; + } + + return array( + PhabricatorPolicyCapability::CAN_VIEW, + ); + } + + protected function applyPolicyFilter(array $objects, array $capabilities) { + if ($this->shouldDisablePolicyFiltering()) { + return $objects; + } + $filter = $this->getPolicyFilter(); + $filter->requireCapabilities($capabilities); + return $filter->apply($objects); + } + protected function didRejectResult(PhabricatorPolicyInterface $object) { $this->getPolicyFilter()->rejectObject( $object, $object->getPolicy(PhabricatorPolicyCapability::CAN_VIEW), PhabricatorPolicyCapability::CAN_VIEW); } public function addPolicyFilteredPHIDs(array $phids) { $this->policyFilteredPHIDs += $phids; if ($this->getParentQuery()) { $this->getParentQuery()->addPolicyFilteredPHIDs($phids); } return $this; } /** * Return a map of all object PHIDs which were loaded in the query but * filtered out by policy constraints. This allows a caller to distinguish * between objects which do not exist (or, at least, were filtered at the * content level) and objects which exist but aren't visible. * * @return map Map of object PHIDs which were filtered * by policies. * @task exec */ public function getPolicyFilteredPHIDs() { return $this->policyFilteredPHIDs; } /* -( Query Workspace )---------------------------------------------------- */ /** * Put a map of objects into the query workspace. Many queries perform * subqueries, which can eventually end up loading the same objects more than * once (often to perform policy checks). * * For example, loading a user may load the user's profile image, which might * load the user object again in order to verify that the viewer has * permission to see the file. * * The "query workspace" allows queries to load objects from elsewhere in a * query block instead of refetching them. * * When using the query workspace, it's important to obey two rules: * * **Never put objects into the workspace which the viewer may not be able * to see**. You need to apply all policy filtering //before// putting * objects in the workspace. Otherwise, subqueries may read the objects and * use them to permit access to content the user shouldn't be able to view. * * **Fully enrich objects pulled from the workspace.** After pulling objects * from the workspace, you still need to load and attach any additional * content the query requests. Otherwise, a query might return objects without * requested content. * * Generally, you do not need to update the workspace yourself: it is * automatically populated as a side effect of objects surviving policy * filtering. * * @param map Objects to add to the query * workspace. * @return this * @task workspace */ public function putObjectsInWorkspace(array $objects) { assert_instances_of($objects, 'PhabricatorPolicyInterface'); $viewer_phid = $this->getViewer()->getPHID(); // The workspace is scoped per viewer to prevent accidental contamination. if (empty($this->workspace[$viewer_phid])) { $this->workspace[$viewer_phid] = array(); } $this->workspace[$viewer_phid] += $objects; return $this; } /** * Retrieve objects from the query workspace. For more discussion about the * workspace mechanism, see @{method:putObjectsInWorkspace}. This method * searches both the current query's workspace and the workspaces of parent * queries. * * @param list List of PHIDs to retreive. * @return this * @task workspace */ public function getObjectsFromWorkspace(array $phids) { $viewer_phid = $this->getViewer()->getPHID(); $results = array(); foreach ($phids as $key => $phid) { if (isset($this->workspace[$viewer_phid][$phid])) { $results[$phid] = $this->workspace[$viewer_phid][$phid]; unset($phids[$key]); } } if ($phids && $this->getParentQuery()) { $results += $this->getParentQuery()->getObjectsFromWorkspace($phids); } return $results; } /** * Convert a result page to a `` map. * * @param list Objects. * @return map Map of objects which can * be put into the workspace. * @task workspace */ protected function getWorkspaceMapForPage(array $results) { $map = array(); foreach ($results as $result) { $phid = $result->getPHID(); if ($phid !== null) { $map[$phid] = $result; } } return $map; } /* -( Policy Query Implementation )---------------------------------------- */ /** * Get the number of results @{method:loadPage} should load. If the value is * 0, @{method:loadPage} should load all available results. * * @return int The number of results to load, or 0 for all results. * @task policyimpl */ final protected function getRawResultLimit() { return $this->rawResultLimit; } /** * Hook invoked before query execution. Generally, implementations should * reset any internal cursors. * * @return void * @task policyimpl */ protected function willExecute() { return; } /** * Load a raw page of results. Generally, implementations should load objects * from the database. They should attempt to return the number of results * hinted by @{method:getRawResultLimit}. * * @return list List of filterable policy objects. * @task policyimpl */ abstract protected function loadPage(); /** * Update internal state so that the next call to @{method:loadPage} will * return new results. Generally, you should adjust a cursor position based * on the provided result page. * * @param list The current page of results. * @return void * @task policyimpl */ abstract protected function nextPage(array $page); /** * Hook for applying a page filter prior to the privacy filter. This allows * you to drop some items from the result set without creating problems with * pagination or cursor updates. You can also load and attach data which is * required to perform policy filtering. * * Generally, you should load non-policy data and perform non-policy filtering * later, in @{method:didFilterPage}. Strictly fewer objects will make it that * far (so the program will load less data) and subqueries from that context * can use the query workspace to further reduce query load. * * This method will only be called if data is available. Implementations * do not need to handle the case of no results specially. * * @param list Results from `loadPage()`. * @return list Objects for policy filtering. * @task policyimpl */ protected function willFilterPage(array $page) { return $page; } /** * Hook for performing additional non-policy loading or filtering after an * object has satisfied all policy checks. Generally, this means loading and * attaching related data. * * Subqueries executed during this phase can use the query workspace, which * may improve performance or make circular policies resolvable. Data which * is not necessary for policy filtering should generally be loaded here. * * This callback can still filter objects (for example, if attachable data * is discovered to not exist), but should not do so for policy reasons. * * This method will only be called if data is available. Implementations do * not need to handle the case of no results specially. * * @param list Results from @{method:willFilterPage()}. * @return list Objects after additional * non-policy processing. */ protected function didFilterPage(array $page) { return $page; } /** * Hook for removing filtered results from alternate result sets. This * hook will be called with any objects which were returned by the query but * filtered for policy reasons. The query should remove them from any cached * or partial result sets. * * @param list List of objects that should not be returned by alternate * result mechanisms. * @return void * @task policyimpl */ protected function didFilterResults(array $results) { return; } /** * Hook for applying final adjustments before results are returned. This is * used by @{class:PhabricatorCursorPagedPolicyAwareQuery} to reverse results * that are queried during reverse paging. * * @param list Query results. * @return list Final results. * @task policyimpl */ protected function didLoadResults(array $results) { return $results; } /** * Allows a subclass to disable policy filtering. This method is dangerous. * It should be used only if the query loads data which has already been * filtered (for example, because it wraps some other query which uses * normal policy filtering). * * @return bool True to disable all policy filtering. * @task policyimpl */ protected function shouldDisablePolicyFiltering() { return false; } /** * If this query belongs to an application, return the application class name * here. This will prevent the query from returning results if the viewer can * not access the application. * * If this query does not belong to an application, return `null`. * * @return string|null Application class name. */ abstract public function getQueryApplicationClass(); /** * Determine if the viewer has permission to use this query's application. * For queries which aren't part of an application, this method always returns * true. * * @return bool True if the viewer has application-level permission to * execute the query. */ public function canViewerUseQueryApplication() { if ($this->canUseApplication === null) { $class = $this->getQueryApplicationClass(); if (!$class) { $this->canUseApplication = true; } else { $result = id(new PhabricatorApplicationQuery()) ->setViewer($this->getViewer()) ->withClasses(array($class)) ->execute(); $this->canUseApplication = (bool)$result; } } return $this->canUseApplication; } }