diff --git a/resources/sql/autopatches/20150602.mlist.2.php b/resources/sql/autopatches/20150602.mlist.2.php index a8f2a090ba..26d08e6f89 100644 --- a/resources/sql/autopatches/20150602.mlist.2.php +++ b/resources/sql/autopatches/20150602.mlist.2.php @@ -1,145 +1,146 @@ establishConnection('w'); $lists = new LiskRawMigrationIterator($conn_w, 'metamta_mailinglist'); echo pht('Migrating mailing lists...')."\n"; foreach ($lists as $list) { $name = $list['name']; $email = $list['email']; $uri = $list['uri']; $old_phid = $list['phid']; $username = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $name); $username = preg_replace('/-{2,}/', '-', $username); $username = trim($username, '-'); if (!strlen($username)) { $username = 'mailinglist'; } $username .= '-list'; $username_okay = false; for ($suffix = 1; $suffix <= 9; $suffix++) { if ($suffix == 1) { $effective_username = $username; } else { $effective_username = $username.$suffix; } $collision = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withUsernames(array($effective_username)) ->executeOne(); if (!$collision) { $username_okay = true; break; } } if (!$username_okay) { echo pht( 'Failed to migrate mailing list "%s": unable to generate a unique '. - 'username for it.')."\n"; + 'username for it.', + $name)."\n"; continue; } $username = $effective_username; if (!PhabricatorUser::validateUsername($username)) { echo pht( 'Failed to migrate mailing list "%s": unable to generate a valid '. 'username for it.', $name)."\n"; continue; } $address = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $email); if ($address) { echo pht( 'Failed to migrate mailing list "%s": an existing user already '. 'has the email address "%s".', $name, $email)."\n"; continue; } $user = id(new PhabricatorUser()) ->setUsername($username) ->setRealName(pht('Mailing List "%s"', $name)) ->setIsApproved(1) ->setIsMailingList(1); $email_object = id(new PhabricatorUserEmail()) ->setAddress($email) ->setIsVerified(1); try { id(new PhabricatorUserEditor()) ->setActor($user) ->createNewUser($user, $email_object); } catch (Exception $ex) { echo pht( 'Failed to migrate mailing list "%s": %s.', $name, $ex->getMessage())."\n"; continue; } $new_phid = $user->getPHID(); // NOTE: After the PHID type is removed we can't use any Edge code to // modify edges. $edge_type = PhabricatorSubscribedToObjectEdgeType::EDGECONST; $edge_inverse = PhabricatorObjectHasSubscriberEdgeType::EDGECONST; $map = PhabricatorPHIDType::getAllTypes(); foreach ($map as $type => $spec) { try { $object = $spec->newObject(); if (!$object) { continue; } $object_conn_w = $object->establishConnection('w'); queryfx( $object_conn_w, 'UPDATE %T SET dst = %s WHERE dst = %s AND type = %s', PhabricatorEdgeConfig::TABLE_NAME_EDGE, $new_phid, $old_phid, $edge_inverse); } catch (Exception $ex) { // Just ignore these; they're mostly tables not existing. continue; } } try { $dst_phids = queryfx_all( $conn_w, 'SELECT dst FROM %T WHERE src = %s AND type = %s', PhabricatorEdgeConfig::TABLE_NAME_EDGE, $old_phid, $edge_type); if ($dst_phids) { $editor = new PhabricatorEdgeEditor(); foreach ($dst_phids as $dst_phid) { $editor->addEdge($new_phid, $edge_type, $dst_phid['dst']); } $editor->save(); } } catch (Exception $ex) { echo pht( 'Unable to migrate some inverse edges for mailing list "%s": %s.', $name, $ex->getMessage())."\n"; continue; } echo pht( 'Migrated mailing list "%s" to mailing list user "%s".', $name, $user->getUsername())."\n"; } diff --git a/src/applications/differential/management/PhabricatorDifferentialRebuildChangesetsWorkflow.php b/src/applications/differential/management/PhabricatorDifferentialRebuildChangesetsWorkflow.php index 80a8ac29a8..44e5be5d4a 100644 --- a/src/applications/differential/management/PhabricatorDifferentialRebuildChangesetsWorkflow.php +++ b/src/applications/differential/management/PhabricatorDifferentialRebuildChangesetsWorkflow.php @@ -1,97 +1,98 @@ setName('rebuild-changesets') ->setExamples('**rebuild-changesets** --revision __revision__') ->setSynopsis(pht('Rebuild changesets for a revision.')) ->setArguments( array( array( 'name' => 'revision', 'param' => 'revision', 'help' => pht('Revision to rebuild changesets for.'), ), )); } public function execute(PhutilArgumentParser $args) { $viewer = $this->getViewer(); $revision_identifier = $args->getArg('revision'); if (!$revision_identifier) { throw new PhutilArgumentUsageException( pht('Specify a revision to rebuild changesets for with "--revision".')); } $revision = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withNames(array($revision_identifier)) ->executeOne(); if ($revision) { if (!($revision instanceof DifferentialRevision)) { throw new PhutilArgumentUsageException( pht( 'Object "%s" specified by "--revision" must be a Differential '. - 'revision.')); + 'revision.', + $revision_identifier)); } } else { $revision = id(new DifferentialRevisionQuery()) ->setViewer($viewer) ->withIDs(array($revision_identifier)) ->executeOne(); } if (!$revision) { throw new PhutilArgumentUsageException( pht( 'No revision "%s" exists.', $revision_identifier)); } $diffs = id(new DifferentialDiffQuery()) ->setViewer($viewer) ->withRevisionIDs(array($revision->getID())) ->execute(); $changesets = id(new DifferentialChangesetQuery()) ->setViewer($viewer) ->withDiffs($diffs) ->needHunks(true) ->execute(); $changeset_groups = mgroup($changesets, 'getDiffID'); foreach ($changeset_groups as $diff_id => $changesets) { echo tsprintf( "%s\n", pht( 'Rebuilding %s changeset(s) for diff ID %d.', phutil_count($changesets), $diff_id)); foreach ($changesets as $changeset) { echo tsprintf( " %s\n", $changeset->getFilename()); } id(new DifferentialChangesetEngine()) ->setViewer($viewer) ->rebuildChangesets($changesets); foreach ($changesets as $changeset) { $changeset->save(); } echo tsprintf( "%s\n", pht('Done.')); } } } diff --git a/src/applications/differential/xaction/DifferentialRevisionRepositoryTransaction.php b/src/applications/differential/xaction/DifferentialRevisionRepositoryTransaction.php index 63c7f2c32d..f92871ca16 100644 --- a/src/applications/differential/xaction/DifferentialRevisionRepositoryTransaction.php +++ b/src/applications/differential/xaction/DifferentialRevisionRepositoryTransaction.php @@ -1,95 +1,96 @@ getRepositoryPHID(); } public function applyInternalEffects($object, $value) { $object->setRepositoryPHID($value); } public function getTitle() { $old = $this->getOldValue(); $new = $this->getNewValue(); if ($old && $new) { return pht( '%s changed the repository for this revision from %s to %s.', $this->renderAuthor(), $this->renderHandle($old), $this->renderHandle($new)); } else if ($new) { return pht( '%s set the repository for this revision to %s.', $this->renderAuthor(), $this->renderHandle($new)); } else { return pht( '%s removed %s as the repository for this revision.', $this->renderAuthor(), $this->renderHandle($old)); } } public function getTitleForFeed() { $old = $this->getOldValue(); $new = $this->getNewValue(); if ($old && $new) { return pht( '%s changed the repository for %s from %s to %s.', $this->renderAuthor(), $this->renderObject(), $this->renderHandle($old), $this->renderHandle($new)); } else if ($new) { return pht( '%s set the repository for %s to %s.', $this->renderAuthor(), $this->renderObject(), $this->renderHandle($new)); } else { return pht( '%s removed %s as the repository for %s.', $this->renderAuthor(), $this->renderHandle($old), $this->renderObject()); } } public function validateTransactions($object, array $xactions) { $actor = $this->getActor(); $errors = array(); $old_value = $object->getRepositoryPHID(); foreach ($xactions as $xaction) { $new_value = $xaction->getNewValue(); if (!$new_value) { continue; } if ($new_value == $old_value) { continue; } $repository = id(new PhabricatorRepositoryQuery()) ->setViewer($actor) ->withPHIDs(array($new_value)) ->executeOne(); if (!$repository) { $errors[] = $this->newInvalidError( pht( 'Repository "%s" is not a valid repository, or you do not have '. - 'permission to view it.'), + 'permission to view it.', + $new_value), $xaction); } } return $errors; } } diff --git a/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php b/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php index 01fec105a3..a3b8554c5e 100644 --- a/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php +++ b/src/applications/fact/chart/PhabricatorChartFunctionArgumentParser.php @@ -1,214 +1,215 @@ function = $function; return $this; } public function getFunction() { return $this->function; } public function setRawArguments(array $arguments) { $this->rawArguments = $arguments; $this->unconsumedArguments = $arguments; } public function addArgument(PhabricatorChartFunctionArgument $spec) { $name = $spec->getName(); if (!strlen($name)) { throw new Exception( pht( 'Chart function "%s" emitted an argument specification with no '. 'argument name. Argument specifications must have unique names.', $this->getFunctionArgumentSignature())); } $type = $spec->getType(); if (!strlen($type)) { throw new Exception( pht( 'Chart function "%s" emitted an argument specification ("%s") with '. 'no type. Each argument specification must have a valid type.', + $this->getFunctionArgumentSignature(), $name)); } if (isset($this->argumentMap[$name])) { throw new Exception( pht( 'Chart function "%s" emitted multiple argument specifications '. 'with the same name ("%s"). Each argument specification must have '. 'a unique name.', $this->getFunctionArgumentSignature(), $name)); } if ($this->repeatableArgument) { if ($spec->getRepeatable()) { throw new Exception( pht( 'Chart function "%s" emitted multiple repeatable argument '. 'specifications ("%s" and "%s"). Only one argument may be '. 'repeatable and it must be the last argument.', $this->getFunctionArgumentSignature(), $name, $this->repeatableArgument->getName())); } else { throw new Exception( pht( 'Chart function "%s" emitted a repeatable argument ("%s"), then '. 'another argument ("%s"). No arguments are permitted after a '. 'repeatable argument.', $this->getFunctionArgumentSignature(), $this->repeatableArgument->getName(), $name)); } } if ($spec->getRepeatable()) { $this->repeatableArgument = $spec; } $this->argumentMap[$name] = $spec; $this->unparsedArguments[] = $spec; return $this; } public function parseArgument( PhabricatorChartFunctionArgument $spec) { $this->addArgument($spec); return $this->parseArguments(); } public function setHaveAllArguments($have_all) { $this->haveAllArguments = $have_all; return $this; } public function getAllArguments() { return array_values($this->argumentMap); } public function getRawArguments() { return $this->rawArguments; } public function parseArguments() { $have_count = count($this->rawArguments); $want_count = count($this->argumentMap); if ($this->haveAllArguments) { if ($this->repeatableArgument) { if ($want_count > $have_count) { throw new Exception( pht( 'Function "%s" expects %s or more argument(s), but only %s '. 'argument(s) were provided.', $this->getFunctionArgumentSignature(), $want_count, $have_count)); } } else if ($want_count !== $have_count) { throw new Exception( pht( 'Function "%s" expects %s argument(s), but %s argument(s) were '. 'provided.', $this->getFunctionArgumentSignature(), $want_count, $have_count)); } } while ($this->unparsedArguments) { $argument = array_shift($this->unparsedArguments); $name = $argument->getName(); if (!$this->unconsumedArguments) { throw new Exception( pht( 'Function "%s" expects at least %s argument(s), but only %s '. 'argument(s) were provided.', $this->getFunctionArgumentSignature(), $want_count, $have_count)); } $raw_argument = array_shift($this->unconsumedArguments); $this->argumentPosition++; $is_repeatable = $argument->getRepeatable(); // If this argument is repeatable and we have more arguments, add it // back to the end of the list so we can continue parsing. if ($is_repeatable && $this->unconsumedArguments) { $this->unparsedArguments[] = $argument; } try { $value = $argument->newValue($raw_argument); } catch (Exception $ex) { throw new Exception( pht( 'Argument "%s" (in position "%s") to function "%s" is '. 'invalid: %s', $name, $this->argumentPosition, $this->getFunctionArgumentSignature(), $ex->getMessage())); } if ($is_repeatable) { if (!isset($this->argumentValues[$name])) { $this->argumentValues[$name] = array(); } $this->argumentValues[$name][] = $value; } else { $this->argumentValues[$name] = $value; } } } public function getArgumentValue($key) { if (!array_key_exists($key, $this->argumentValues)) { throw new Exception( pht( 'Function "%s" is requesting an argument ("%s") that it did '. 'not define.', $this->getFunctionArgumentSignature(), $key)); } return $this->argumentValues[$key]; } private function getFunctionArgumentSignature() { $argument_list = array(); foreach ($this->argumentMap as $key => $spec) { $argument_list[] = $key; } if (!$this->haveAllArguments || $this->repeatableArgument) { $argument_list[] = '...'; } return sprintf( '%s(%s)', $this->getFunction()->getFunctionKey(), implode(', ', $argument_list)); } } diff --git a/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php b/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php index df42d8632f..fecce6136e 100644 --- a/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php +++ b/src/applications/harbormaster/management/HarbormasterManagementPublishWorkflow.php @@ -1,87 +1,88 @@ setName('publish') ->setExamples(pht('**publish** __buildable__ ...')) ->setSynopsis( pht( 'Publish a buildable. This is primarily useful for developing '. 'and debugging applications which have buildable objects.')) ->setArguments( array( array( 'name' => 'buildable', 'wildcard' => true, ), )); } public function execute(PhutilArgumentParser $args) { $viewer = $this->getViewer(); $buildable_names = $args->getArg('buildable'); if (!$buildable_names) { throw new PhutilArgumentUsageException( pht( 'Name one or more buildables to publish, like "B123".')); } $query = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withNames($buildable_names); $query->execute(); $result_map = $query->getNamedResults(); foreach ($buildable_names as $name) { if (!isset($result_map[$name])) { throw new PhutilArgumentUsageException( pht( 'Argument "%s" does not name a buildable. Provide one or more '. 'valid buildable monograms or PHIDs.', $name)); } } foreach ($result_map as $name => $result) { if (!($result instanceof HarbormasterBuildable)) { throw new PhutilArgumentUsageException( pht( 'Object "%s" is not a HarbormasterBuildable (it is a "%s"). '. 'Name one or more buildables to publish, like "B123".', + $name, get_class($result))); } } foreach ($result_map as $buildable) { echo tsprintf( "%s\n", pht( 'Publishing "%s"...', $buildable->getMonogram())); // Reload the buildable to pick up builds. $buildable = id(new HarbormasterBuildableQuery()) ->setViewer($viewer) ->withIDs(array($buildable->getID())) ->needBuilds(true) ->executeOne(); $engine = id(new HarbormasterBuildEngine()) ->setViewer($viewer) ->publishBuildable($buildable, $buildable); } echo tsprintf( "%s\n", pht('Done.')); return 0; } } diff --git a/src/applications/harbormaster/storage/build/HarbormasterBuildLog.php b/src/applications/harbormaster/storage/build/HarbormasterBuildLog.php index 2b160445d8..5df8b30bff 100644 --- a/src/applications/harbormaster/storage/build/HarbormasterBuildLog.php +++ b/src/applications/harbormaster/storage/build/HarbormasterBuildLog.php @@ -1,786 +1,788 @@ rope = new PhutilRope(); } public function __destruct() { if ($this->isOpen) { $this->closeBuildLog(); } if ($this->lock) { if ($this->lock->isLocked()) { $this->lock->unlock(); } } } public static function initializeNewBuildLog( HarbormasterBuildTarget $build_target) { return id(new HarbormasterBuildLog()) ->setBuildTargetPHID($build_target->getPHID()) ->setDuration(null) ->setLive(1) ->setByteLength(0) ->setChunkFormat(HarbormasterBuildLogChunk::CHUNK_ENCODING_TEXT); } public function scheduleRebuild($force) { PhabricatorWorker::scheduleTask( 'HarbormasterLogWorker', array( 'logPHID' => $this->getPHID(), 'force' => $force, ), array( 'objectPHID' => $this->getPHID(), )); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_SERIALIZATION => array( 'lineMap' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( // T6203/NULLABILITY // It seems like these should be non-nullable? All logs should have a // source, etc. 'logSource' => 'text255?', 'logType' => 'text255?', 'duration' => 'uint32?', 'live' => 'bool', 'filePHID' => 'phid?', 'byteLength' => 'uint64', 'chunkFormat' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_buildtarget' => array( 'columns' => array('buildTargetPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( HarbormasterBuildLogPHIDType::TYPECONST); } public function attachBuildTarget(HarbormasterBuildTarget $build_target) { $this->buildTarget = $build_target; return $this; } public function getBuildTarget() { return $this->assertAttached($this->buildTarget); } public function getName() { return pht('Build Log'); } public function newChunkIterator() { return id(new HarbormasterBuildLogChunkIterator($this)) ->setPageSize(8); } public function newDataIterator() { return $this->newChunkIterator() ->setAsString(true); } private function loadLastChunkInfo() { $chunk_table = new HarbormasterBuildLogChunk(); $conn_w = $chunk_table->establishConnection('w'); return queryfx_one( $conn_w, 'SELECT id, size, encoding FROM %T WHERE logID = %d ORDER BY id DESC LIMIT 1', $chunk_table->getTableName(), $this->getID()); } public function loadData($offset, $length) { $end = ($offset + $length); $chunks = id(new HarbormasterBuildLogChunk())->loadAllWhere( 'logID = %d AND headOffset < %d AND tailOffset >= %d ORDER BY headOffset ASC', $this->getID(), $end, $offset); // Make sure that whatever we read out of the database is a single // contiguous range which contains all of the requested bytes. $ranges = array(); foreach ($chunks as $chunk) { $ranges[] = array( 'head' => $chunk->getHeadOffset(), 'tail' => $chunk->getTailOffset(), ); } $ranges = isort($ranges, 'head'); $ranges = array_values($ranges); $count = count($ranges); for ($ii = 0; $ii < ($count - 1); $ii++) { if ($ranges[$ii + 1]['head'] === $ranges[$ii]['tail']) { $ranges[$ii + 1]['head'] = $ranges[$ii]['head']; unset($ranges[$ii]); } } if (count($ranges) !== 1) { $display_ranges = array(); foreach ($ranges as $range) { $display_ranges[] = pht( '(%d - %d)', $range['head'], $range['tail']); } if (!$display_ranges) { $display_ranges[] = pht(''); } throw new Exception( pht( 'Attempt to load log bytes (%d - %d) failed: failed to '. 'load a single contiguous range. Actual ranges: %s.', + $offset, + $end, implode('; ', $display_ranges))); } $range = head($ranges); if ($range['head'] > $offset || $range['tail'] < $end) { throw new Exception( pht( 'Attempt to load log bytes (%d - %d) failed: the loaded range '. '(%d - %d) does not span the requested range.', $offset, $end, $range['head'], $range['tail'])); } $parts = array(); foreach ($chunks as $chunk) { $parts[] = $chunk->getChunkDisplayText(); } $parts = implode('', $parts); $chop_head = ($offset - $range['head']); $chop_tail = ($range['tail'] - $end); if ($chop_head) { $parts = substr($parts, $chop_head); } if ($chop_tail) { $parts = substr($parts, 0, -$chop_tail); } return $parts; } public function getLineSpanningRange($min_line, $max_line) { $map = $this->getLineMap(); if (!$map) { throw new Exception(pht('No line map.')); } $min_pos = 0; $min_line = 0; $max_pos = $this->getByteLength(); list($map) = $map; foreach ($map as $marker) { list($offset, $count) = $marker; if ($count < $min_line) { if ($offset > $min_pos) { $min_pos = $offset; $min_line = $count; } } if ($count > $max_line) { $max_pos = min($max_pos, $offset); break; } } return array($min_pos, $max_pos, $min_line); } public function getReadPosition($read_offset) { $position = array(0, 0); $map = $this->getLineMap(); if (!$map) { throw new Exception(pht('No line map.')); } list($map) = $map; foreach ($map as $marker) { list($offset, $count) = $marker; if ($offset > $read_offset) { break; } $position = $marker; } return $position; } public function getLogText() { // TODO: Remove this method since it won't scale for big logs. $all_chunks = $this->newChunkIterator(); $full_text = array(); foreach ($all_chunks as $chunk) { $full_text[] = $chunk->getChunkDisplayText(); } return implode('', $full_text); } public function getURI() { $id = $this->getID(); return "/harbormaster/log/view/{$id}/"; } public function getRenderURI($lines) { if (strlen($lines)) { $lines = '$'.$lines; } $id = $this->getID(); return "/harbormaster/log/render/{$id}/{$lines}"; } /* -( Chunks )------------------------------------------------------------- */ public function canCompressLog() { return function_exists('gzdeflate'); } public function compressLog() { $this->processLog(HarbormasterBuildLogChunk::CHUNK_ENCODING_GZIP); } public function decompressLog() { $this->processLog(HarbormasterBuildLogChunk::CHUNK_ENCODING_TEXT); } private function processLog($mode) { if (!$this->getLock()->isLocked()) { throw new Exception( pht( 'You can not process build log chunks unless the log lock is '. 'held.')); } $chunks = $this->newChunkIterator(); // NOTE: Because we're going to insert new chunks, we need to stop the // iterator once it hits the final chunk which currently exists. Otherwise, // it may start consuming chunks we just wrote and run forever. $last = $this->loadLastChunkInfo(); if ($last) { $chunks->setRange(null, $last['id']); } $byte_limit = self::CHUNK_BYTE_LIMIT; $rope = new PhutilRope(); $this->openTransaction(); $offset = 0; foreach ($chunks as $chunk) { $rope->append($chunk->getChunkDisplayText()); $chunk->delete(); while ($rope->getByteLength() > $byte_limit) { $offset += $this->writeEncodedChunk($rope, $offset, $byte_limit, $mode); } } while ($rope->getByteLength()) { $offset += $this->writeEncodedChunk($rope, $offset, $byte_limit, $mode); } $this ->setChunkFormat($mode) ->save(); $this->saveTransaction(); } private function writeEncodedChunk( PhutilRope $rope, $offset, $length, $mode) { $data = $rope->getPrefixBytes($length); $size = strlen($data); switch ($mode) { case HarbormasterBuildLogChunk::CHUNK_ENCODING_TEXT: // Do nothing. break; case HarbormasterBuildLogChunk::CHUNK_ENCODING_GZIP: $data = gzdeflate($data); if ($data === false) { throw new Exception(pht('Failed to gzdeflate() log data!')); } break; default: throw new Exception(pht('Unknown chunk encoding "%s"!', $mode)); } $this->writeChunk($mode, $offset, $size, $data); $rope->removeBytesFromHead($size); return $size; } private function writeChunk($encoding, $offset, $raw_size, $data) { $head_offset = $offset; $tail_offset = $offset + $raw_size; return id(new HarbormasterBuildLogChunk()) ->setLogID($this->getID()) ->setEncoding($encoding) ->setHeadOffset($head_offset) ->setTailOffset($tail_offset) ->setSize($raw_size) ->setChunk($data) ->save(); } /* -( Writing )------------------------------------------------------------ */ public function getLock() { if (!$this->lock) { $phid = $this->getPHID(); $phid_key = PhabricatorHash::digestToLength($phid, 14); $lock_key = "build.log({$phid_key})"; $lock = PhabricatorGlobalLock::newLock($lock_key); $this->lock = $lock; } return $this->lock; } public function openBuildLog() { if ($this->isOpen) { throw new Exception(pht('This build log is already open!')); } $is_new = !$this->getID(); if ($is_new) { $this->save(); } $this->getLock()->lock(); $this->isOpen = true; $this->reload(); if (!$this->getLive()) { $this->setLive(1)->save(); } return $this; } public function closeBuildLog($forever = true) { if (!$this->isOpen) { throw new Exception( pht( 'You must openBuildLog() before you can closeBuildLog().')); } $this->flush(); if ($forever) { $start = $this->getDateCreated(); $now = PhabricatorTime::getNow(); $this ->setDuration($now - $start) ->setLive(0) ->save(); } $this->getLock()->unlock(); $this->isOpen = false; if ($forever) { $this->scheduleRebuild(false); } return $this; } public function append($content) { if (!$this->isOpen) { throw new Exception( pht( 'You must openBuildLog() before you can append() content to '. 'the log.')); } $content = (string)$content; $this->rope->append($content); $this->flush(); return $this; } private function flush() { // TODO: Maybe don't flush more than a couple of times per second. If a // caller writes a single character over and over again, we'll currently // spend a lot of time flushing that. $chunk_table = id(new HarbormasterBuildLogChunk())->getTableName(); $chunk_limit = self::CHUNK_BYTE_LIMIT; $encoding_text = HarbormasterBuildLogChunk::CHUNK_ENCODING_TEXT; $rope = $this->rope; while (true) { $length = $rope->getByteLength(); if (!$length) { break; } $conn_w = $this->establishConnection('w'); $last = $this->loadLastChunkInfo(); $can_append = ($last) && ($last['encoding'] == $encoding_text) && ($last['size'] < $chunk_limit); if ($can_append) { $append_id = $last['id']; $prefix_size = $last['size']; } else { $append_id = null; $prefix_size = 0; } $data_limit = ($chunk_limit - $prefix_size); $append_data = $rope->getPrefixBytes($data_limit); $data_size = strlen($append_data); $this->openTransaction(); if ($append_id) { queryfx( $conn_w, 'UPDATE %T SET chunk = CONCAT(chunk, %B), size = %d, tailOffset = headOffset + %d WHERE id = %d', $chunk_table, $append_data, $prefix_size + $data_size, $prefix_size + $data_size, $append_id); } else { $this->writeChunk( $encoding_text, $this->getByteLength(), $data_size, $append_data); } $this->updateLineMap($append_data); $this->save(); $this->saveTransaction(); $rope->removeBytesFromHead($data_size); } } public function updateLineMap($append_data, $marker_distance = null) { $this->byteLength += strlen($append_data); if (!$marker_distance) { $marker_distance = (self::CHUNK_BYTE_LIMIT / 2); } if (!$this->lineMap) { $this->lineMap = array( array(), 0, 0, null, ); } list($map, $map_bytes, $line_count, $prefix) = $this->lineMap; $buffer = $append_data; if ($prefix) { $prefix = base64_decode($prefix); $buffer = $prefix.$buffer; } if ($map) { list($last_marker, $last_count) = last($map); } else { $last_marker = 0; $last_count = 0; } $max_utf8_width = 8; $next_marker = $last_marker + $marker_distance; $pos = 0; $len = strlen($buffer); while (true) { // If we only have a few bytes left in the buffer, leave it as a prefix // for next time. if (($len - $pos) <= ($max_utf8_width * 2)) { $prefix = substr($buffer, $pos); break; } // The next slice we're going to look at is the smaller of: // // - the number of bytes we need to make it to the next marker; or // - all the bytes we have left, minus one. $slice_length = min( ($marker_distance - $map_bytes), ($len - $pos) - 1); // We don't slice all the way to the end for two reasons. // First, we want to avoid slicing immediately after a "\r" if we don't // know what the next character is, because we want to make sure to // count "\r\n" as a single newline, rather than counting the "\r" as // a newline and then later counting the "\n" as another newline. // Second, we don't want to slice in the middle of a UTF8 character if // we can help it. We may not be able to avoid this, since the whole // buffer may just be binary data, but in most cases we can backtrack // a little bit and try to make it out of emoji or other legitimate // multibyte UTF8 characters which appear in the log. $min_width = max(1, $slice_length - $max_utf8_width); while ($slice_length >= $min_width) { $here = $buffer[$pos + ($slice_length - 1)]; $next = $buffer[$pos + ($slice_length - 1) + 1]; // If this is "\r" and the next character is "\n", extend the slice // to include the "\n". Otherwise, we're fine to slice here since we // know we're not in the middle of a UTF8 character. if ($here === "\r") { if ($next === "\n") { $slice_length++; } break; } // If the next character is 0x7F or lower, or between 0xC2 and 0xF4, // we're not slicing in the middle of a UTF8 character. $ord = ord($next); if ($ord <= 0x7F || ($ord >= 0xC2 && $ord <= 0xF4)) { break; } $slice_length--; } $slice = substr($buffer, $pos, $slice_length); $pos += $slice_length; $map_bytes += $slice_length; // Count newlines in the slice. This goofy approach is meaningfully // faster than "preg_match_all()" or "preg_split()". See PHI766. $n_rn = substr_count($slice, "\r\n"); $n_r = substr_count($slice, "\r"); $n_n = substr_count($slice, "\n"); $line_count += ($n_rn) + ($n_r - $n_rn) + ($n_n - $n_rn); if ($map_bytes >= ($marker_distance - $max_utf8_width)) { $map[] = array( $last_marker + $map_bytes, $last_count + $line_count, ); $last_count = $last_count + $line_count; $line_count = 0; $last_marker = $last_marker + $map_bytes; $map_bytes = 0; $next_marker = $last_marker + $marker_distance; } } $this->lineMap = array( $map, $map_bytes, $line_count, base64_encode($prefix), ); return $this; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { return $this->getBuildTarget()->getPolicy($capability); } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getBuildTarget()->hasAutomaticCapability( $capability, $viewer); } public function describeAutomaticCapability($capability) { return pht( 'Users must be able to see a build target to view its build log.'); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->destroyFile($engine); $this->destroyChunks(); $this->delete(); } public function destroyFile(PhabricatorDestructionEngine $engine = null) { if (!$engine) { $engine = new PhabricatorDestructionEngine(); } $file_phid = $this->getFilePHID(); if ($file_phid) { $viewer = $engine->getViewer(); $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($file_phid)) ->executeOne(); if ($file) { $engine->destroyObject($file); } } $this->setFilePHID(null); return $this; } public function destroyChunks() { $chunk = new HarbormasterBuildLogChunk(); $conn = $chunk->establishConnection('w'); // Just delete the chunks directly so we don't have to pull the data over // the wire for large logs. queryfx( $conn, 'DELETE FROM %T WHERE logID = %d', $chunk->getTableName(), $this->getID()); return $this; } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('buildTargetPHID') ->setType('phid') ->setDescription(pht('Build target this log is attached to.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('byteLength') ->setType('int') ->setDescription(pht('Length of the log in bytes.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('filePHID') ->setType('phid?') ->setDescription(pht('A file containing the log data.')), ); } public function getFieldValuesForConduit() { return array( 'buildTargetPHID' => $this->getBuildTargetPHID(), 'byteLength' => (int)$this->getByteLength(), 'filePHID' => $this->getFilePHID(), ); } public function getConduitSearchAttachments() { return array(); } } diff --git a/src/applications/maniphest/constants/ManiphestTaskPriority.php b/src/applications/maniphest/constants/ManiphestTaskPriority.php index d559299af9..8b43da132b 100644 --- a/src/applications/maniphest/constants/ManiphestTaskPriority.php +++ b/src/applications/maniphest/constants/ManiphestTaskPriority.php @@ -1,262 +1,261 @@ $spec) { $map[$key] = idx($spec, 'name', $key); } return $map; } /** * Get the priorities and their command keywords. * * @return map Priorities to lists of command keywords. */ public static function getTaskPriorityKeywordsMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $words = idx($spec, 'keywords', array()); if (!is_array($words)) { $words = array($words); } foreach ($words as $word_key => $word) { $words[$word_key] = phutil_utf8_strtolower($word); } $words = array_unique($words); $map[$key] = $words; } return $map; } /** * Get the canonical keyword for a given priority constant. * * @return string|null Keyword, or `null` if no keyword is configured. */ public static function getKeywordForTaskPriority($priority) { $map = self::getConfig(); $spec = idx($map, $priority); if (!$spec) { return null; } $keywords = idx($spec, 'keywords'); if (!$keywords) { return null; } return head($keywords); } /** * Get a map of supported alternate names for each priority. * * Keys are aliases, like "wish" and "wishlist". Values are canonical * priority keywords, like "wishlist". * * @return map Map of aliases to canonical priority keywords. */ public static function getTaskPriorityAliasMap() { $keyword_map = self::getTaskPriorityKeywordsMap(); $result = array(); foreach ($keyword_map as $key => $keywords) { $target = self::getKeywordForTaskPriority($key); if ($target === null) { continue; } // NOTE: Include the raw priority value, like "25", in the list of // aliases. This supports legacy sources like saved EditEngine forms. $result[$key] = $target; foreach ($keywords as $keyword) { $result[$keyword] = $target; } } return $result; } /** * Get the priorities and their related short (one-word) descriptions. * * @return map Priorities to short descriptions. */ public static function getShortNameMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $map[$key] = idx($spec, 'short', idx($spec, 'name', $key)); } return $map; } /** * Get a map from priority constants to their colors. * * @return map Priorities to colors. */ public static function getColorMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $map[$key] = idx($spec, 'color', 'grey'); } return $map; } /** * Return the default priority for this instance of Phabricator. * * @return int The value of the default priority constant. */ public static function getDefaultPriority() { return PhabricatorEnv::getEnvConfig('maniphest.default-priority'); } /** * Retrieve the full name of the priority level provided. * * @param int A priority level. * @return string The priority name if the level is a valid one. */ public static function getTaskPriorityName($priority) { return idx(self::getTaskPriorityMap(), $priority, $priority); } /** * Retrieve the color of the priority level given * * @param int A priority level. * @return string The color of the priority if the level is valid, * or black if it is not. */ public static function getTaskPriorityColor($priority) { return idx(self::getColorMap(), $priority, 'black'); } public static function getTaskPriorityIcon($priority) { return 'fa-arrow-right'; } public static function getTaskPriorityFromKeyword($keyword) { $map = self::getTaskPriorityKeywordsMap(); foreach ($map as $priority => $keywords) { if (in_array($keyword, $keywords)) { return $priority; } } return null; } public static function isDisabledPriority($priority) { $config = idx(self::getConfig(), $priority, array()); return idx($config, 'disabled', false); } public static function getConfig() { $config = PhabricatorEnv::getEnvConfig('maniphest.priorities'); krsort($config); return $config; } private static function isValidPriorityKeyword($keyword) { if (!strlen($keyword) || strlen($keyword) > 64) { return false; } // Alphanumeric, but not exclusively numeric if (!preg_match('/^(?![0-9]*$)[a-zA-Z0-9]+$/', $keyword)) { return false; } return true; } public static function validateConfiguration($config) { if (!is_array($config)) { throw new Exception( pht( 'Configuration is not valid. Maniphest priority configurations '. - 'must be dictionaries.', - $config)); + 'must be dictionaries.')); } $all_keywords = array(); foreach ($config as $key => $value) { if (!ctype_digit((string)$key)) { throw new Exception( pht( 'Key "%s" is not a valid priority constant. Priority constants '. 'must be nonnegative integers.', $key)); } if (!is_array($value)) { throw new Exception( pht( 'Value for key "%s" should be a dictionary.', $key)); } PhutilTypeSpec::checkMap( $value, array( 'name' => 'string', 'keywords' => 'list', 'short' => 'optional string', 'color' => 'optional string', 'disabled' => 'optional bool', )); $keywords = $value['keywords']; foreach ($keywords as $keyword) { if (!self::isValidPriorityKeyword($keyword)) { throw new Exception( pht( 'Key "%s" is not a valid priority keyword. Priority keywords '. 'must be 1-64 alphanumeric characters and cannot be '. 'exclusively digits. For example, "%s" or "%s" are '. 'reasonable choices.', $keyword, 'low', 'critical')); } if (isset($all_keywords[$keyword])) { throw new Exception( pht( 'Two different task priorities ("%s" and "%s") have the same '. 'keyword ("%s"). Keywords must uniquely identify priorities.', $value['name'], $all_keywords[$keyword], $keyword)); } $all_keywords[$keyword] = $value['name']; } } } } diff --git a/src/applications/metamta/adapter/PhabricatorMailAdapter.php b/src/applications/metamta/adapter/PhabricatorMailAdapter.php index 8c1a6c0ba7..7a0f1bae31 100644 --- a/src/applications/metamta/adapter/PhabricatorMailAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailAdapter.php @@ -1,173 +1,174 @@ getPhobjectClassConstant('ADAPTERTYPE'); } final public static function getAllAdapters() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getAdapterType') ->execute(); } abstract public function getSupportedMessageTypes(); abstract public function sendMessage(PhabricatorMailExternalMessage $message); /** * Return true if this adapter supports setting a "Message-ID" when sending * email. * * This is an ugly implementation detail because mail threading is a horrible * mess, implemented differently by every client in existence. */ public function supportsMessageIDHeader() { return false; } final public function supportsMessageType($message_type) { if ($this->mediaMap === null) { $media_map = $this->getSupportedMessageTypes(); $media_map = array_fuse($media_map); if ($this->media) { $config_map = $this->media; $config_map = array_fuse($config_map); $media_map = array_intersect_key($media_map, $config_map); } $this->mediaMap = $media_map; } return isset($this->mediaMap[$message_type]); } final public function setMedia(array $media) { $native_map = $this->getSupportedMessageTypes(); $native_map = array_fuse($native_map); foreach ($media as $medium) { if (!isset($native_map[$medium])) { throw new Exception( pht( 'Adapter ("%s") is configured for medium "%s", but this is not '. 'a supported delivery medium. Supported media are: %s.', + get_class($this), $medium, implode(', ', $native_map))); } } $this->media = $media; $this->mediaMap = null; return $this; } final public function getMedia() { return $this->media; } final public function setKey($key) { $this->key = $key; return $this; } final public function getKey() { return $this->key; } final public function setPriority($priority) { $this->priority = $priority; return $this; } final public function getPriority() { return $this->priority; } final public function setSupportsInbound($supports_inbound) { $this->supportsInbound = $supports_inbound; return $this; } final public function getSupportsInbound() { return $this->supportsInbound; } final public function setSupportsOutbound($supports_outbound) { $this->supportsOutbound = $supports_outbound; return $this; } final public function getSupportsOutbound() { return $this->supportsOutbound; } final public function getOption($key) { if (!array_key_exists($key, $this->options)) { throw new Exception( pht( 'Mailer ("%s") is attempting to access unknown option ("%s").', get_class($this), $key)); } return $this->options[$key]; } final public function setOptions(array $options) { $this->validateOptions($options); $this->options = $options; return $this; } abstract protected function validateOptions(array $options); abstract public function newDefaultOptions(); final protected function guessIfHostSupportsMessageID($config, $host) { // See T13265. Mailers like "SMTP" and "sendmail" usually allow us to // set the "Message-ID" header to a value we choose, but we may not be // able to if the mailer is being used as API glue and the outbound // pathway ends up routing to a service with an SMTP API that selects // its own "Message-ID" header, like Amazon SES. // If users configured a behavior explicitly, use that behavior. if ($config !== null) { return $config; } // If the server we're connecting to is part of a service that we know // does not support "Message-ID", guess that we don't support "Message-ID". if ($host !== null) { $host_blocklist = array( '/\.amazonaws\.com\z/', '/\.postmarkapp\.com\z/', '/\.sendgrid\.net\z/', ); $host = phutil_utf8_strtolower($host); foreach ($host_blocklist as $regexp) { if (preg_match($regexp, $host)) { return false; } } } return true; } } diff --git a/src/applications/notification/config/PhabricatorNotificationServersConfigType.php b/src/applications/notification/config/PhabricatorNotificationServersConfigType.php index 5f0c1f7e2f..f13105a249 100644 --- a/src/applications/notification/config/PhabricatorNotificationServersConfigType.php +++ b/src/applications/notification/config/PhabricatorNotificationServersConfigType.php @@ -1,138 +1,140 @@ $spec) { if (!is_array($spec)) { throw $this->newException( pht( 'Notification server configuration is not valid: each entry in '. 'the list must be a dictionary describing a service, but '. 'the value with index "%s" is not a dictionary.', $index)); } } $has_admin = false; $has_client = false; $map = array(); foreach ($value as $index => $spec) { try { PhutilTypeSpec::checkMap( $spec, array( 'type' => 'string', 'host' => 'string', 'port' => 'int', 'protocol' => 'string', 'path' => 'optional string', 'disabled' => 'optional bool', )); } catch (Exception $ex) { throw $this->newException( pht( 'Notification server configuration has an invalid service '. 'specification (at index "%s"): %s.', $index, $ex->getMessage())); } $type = $spec['type']; $host = $spec['host']; $port = $spec['port']; $protocol = $spec['protocol']; $disabled = idx($spec, 'disabled'); switch ($type) { case 'admin': if (!$disabled) { $has_admin = true; } break; case 'client': if (!$disabled) { $has_client = true; } break; default: throw $this->newException( pht( 'Notification server configuration describes an invalid '. 'host ("%s", at index "%s") with an unrecognized type ("%s"). '. 'Valid types are "%s" or "%s".', $host, $index, $type, 'admin', 'client')); } switch ($protocol) { case 'http': case 'https': break; default: throw $this->newException( pht( 'Notification server configuration describes an invalid '. 'host ("%s", at index "%s") with an invalid protocol ("%s"). '. 'Valid protocols are "%s" or "%s".', $host, $index, $protocol, 'http', 'https')); } $path = idx($spec, 'path'); if ($type == 'admin' && strlen($path)) { throw $this->newException( pht( 'Notification server configuration describes an invalid host '. '("%s", at index "%s"). This is an "admin" service but it has a '. '"path" property. This property is only valid for "client" '. - 'services.')); + 'services.', + $host, + $index)); } // We can't guarantee that you didn't just give the same host two // different names in DNS, but this check can catch silly copy/paste // mistakes. $key = "{$host}:{$port}"; if (isset($map[$key])) { throw $this->newException( pht( 'Notification server configuration is invalid: it describes the '. 'same host and port ("%s") multiple times. Each host and port '. 'combination should appear only once in the list.', $key)); } $map[$key] = true; } if ($value) { if (!$has_admin) { throw $this->newException( pht( 'Notification server configuration is invalid: it does not '. 'specify any enabled servers with type "admin". Notifications '. 'require at least one active "admin" server.')); } if (!$has_client) { throw $this->newException( pht( 'Notification server configuration is invalid: it does not '. 'specify any enabled servers with type "client". Notifications '. 'require at least one active "client" server.')); } } } } diff --git a/src/applications/nuance/github/__tests__/NuanceGitHubRawEventTestCase.php b/src/applications/nuance/github/__tests__/NuanceGitHubRawEventTestCase.php index 29b9e3c0a9..4af9a440e2 100644 --- a/src/applications/nuance/github/__tests__/NuanceGitHubRawEventTestCase.php +++ b/src/applications/nuance/github/__tests__/NuanceGitHubRawEventTestCase.php @@ -1,113 +1,114 @@ readTestCases($path); foreach ($cases as $name => $info) { $input = $info['input']; $expect = $info['expect']; $event = NuanceGitHubRawEvent::newEvent( NuanceGitHubRawEvent::TYPE_ISSUE, $input); $this->assertGitHubRawEventParse($expect, $event, $name); } } public function testRepositoryEvents() { $path = dirname(__FILE__).'/repositoryevents/'; $cases = $this->readTestCases($path); foreach ($cases as $name => $info) { $input = $info['input']; $expect = $info['expect']; $event = NuanceGitHubRawEvent::newEvent( NuanceGitHubRawEvent::TYPE_REPOSITORY, $input); $this->assertGitHubRawEventParse($expect, $event, $name); } } private function assertGitHubRawEventParse( array $expect, NuanceGitHubRawEvent $event, $name) { $actual = array( 'repository.name.full' => $event->getRepositoryFullName(), 'is.issue' => $event->isIssueEvent(), 'is.pull' => $event->isPullRequestEvent(), 'issue.number' => $event->getIssueNumber(), 'pull.number' => $event->getPullRequestNumber(), 'id' => $event->getID(), 'uri' => $event->getURI(), 'title.full' => $event->getEventFullTitle(), 'comment' => $event->getComment(), 'actor.id' => $event->getActorGitHubUserID(), ); // Only verify the keys which are actually present in the test. This // allows tests to specify only relevant keys. $actual = array_select_keys($actual, array_keys($expect)); ksort($expect); ksort($actual); $this->assertEqual($expect, $actual, $name); } private function readTestCases($path) { $files = Filesystem::listDirectory($path, $include_hidden = false); $tests = array(); foreach ($files as $file) { $data = Filesystem::readFile($path.$file); $parts = preg_split('/^~{5,}$/m', $data); if (count($parts) < 2) { throw new Exception( pht( 'Expected test file "%s" to contain an input section in JSON, '. 'then an expected result section in JSON, with the two sections '. 'separated by a line of "~~~~~", but the divider is not present '. 'in the file.', $file)); } else if (count($parts) > 2) { throw new Exception( pht( 'Expected test file "%s" to contain exactly two sections, '. - 'but it has more than two sections.')); + 'but it has more than two sections.', + $file)); } list($input, $expect) = $parts; try { $input = phutil_json_decode($input); $expect = phutil_json_decode($expect); } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Exception while decoding test data for test "%s".', $file), $ex); } $tests[$file] = array( 'input' => $input, 'expect' => $expect, ); } return $tests; } } diff --git a/src/applications/phragment/controller/PhragmentRevertController.php b/src/applications/phragment/controller/PhragmentRevertController.php index e9d56eb112..b9aa050327 100644 --- a/src/applications/phragment/controller/PhragmentRevertController.php +++ b/src/applications/phragment/controller/PhragmentRevertController.php @@ -1,79 +1,78 @@ getViewer(); $id = $request->getURIData('id'); $dblob = $request->getURIData('dblob'); $fragment = id(new PhragmentFragmentQuery()) ->setViewer($viewer) ->withPaths(array($dblob)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if ($fragment === null) { return new Aphront404Response(); } $version = id(new PhragmentFragmentVersionQuery()) ->setViewer($viewer) ->withFragmentPHIDs(array($fragment->getPHID())) ->withIDs(array($id)) ->executeOne(); if ($version === null) { return new Aphront404Response(); } if ($request->isDialogFormPost()) { $file_phid = $version->getFilePHID(); $file = null; if ($file_phid !== null) { $file = id(new PhabricatorFileQuery()) ->setViewer($viewer) ->withPHIDs(array($file_phid)) ->executeOne(); if ($file === null) { throw new Exception( pht('The file associated with this version was not found.')); } } if ($file === null) { $fragment->deleteFile($viewer); } else { $fragment->updateFromFile($viewer, $file); } return id(new AphrontRedirectResponse()) ->setURI($this->getApplicationURI('/history/'.$dblob)); } return $this->createDialog($fragment, $version); } public function createDialog( PhragmentFragment $fragment, PhragmentFragmentVersion $version) { $viewer = $this->getViewer(); $dialog = id(new AphrontDialogView()) ->setTitle(pht('Really revert this fragment?')) ->setUser($this->getViewer()) ->addSubmitButton(pht('Revert')) ->addCancelButton(pht('Cancel')) ->appendParagraph(pht( 'Reverting this fragment to version %d will create a new version of '. 'the fragment. It will not delete any version history.', - $version->getSequence(), $version->getSequence())); return id(new AphrontDialogResponse())->setDialog($dialog); } } diff --git a/src/applications/policy/storage/PhabricatorPolicy.php b/src/applications/policy/storage/PhabricatorPolicy.php index 66a7d9e3be..7904f17927 100644 --- a/src/applications/policy/storage/PhabricatorPolicy.php +++ b/src/applications/policy/storage/PhabricatorPolicy.php @@ -1,515 +1,514 @@ true, self::CONFIG_SERIALIZATION => array( 'rules' => self::SERIALIZATION_JSON, ), self::CONFIG_COLUMN_SCHEMA => array( 'defaultAction' => 'text32', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPolicyPHIDTypePolicy::TYPECONST); } public static function newFromPolicyAndHandle( $policy_identifier, PhabricatorObjectHandle $handle = null) { $is_global = PhabricatorPolicyQuery::isGlobalPolicy($policy_identifier); if ($is_global) { return PhabricatorPolicyQuery::getGlobalPolicy($policy_identifier); } $policy = PhabricatorPolicyQuery::getObjectPolicy($policy_identifier); if ($policy) { return $policy; } if (!$handle) { throw new Exception( pht( "Policy identifier is an object PHID ('%s'), but no object handle ". "was provided. A handle must be provided for object policies.", $policy_identifier)); } $handle_phid = $handle->getPHID(); if ($policy_identifier != $handle_phid) { throw new Exception( pht( "Policy identifier is an object PHID ('%s'), but the provided ". "handle has a different PHID ('%s'). The handle must correspond ". "to the policy identifier.", $policy_identifier, $handle_phid)); } $policy = id(new PhabricatorPolicy()) ->setPHID($policy_identifier) ->setHref($handle->getURI()); $phid_type = phid_get_type($policy_identifier); switch ($phid_type) { case PhabricatorProjectProjectPHIDType::TYPECONST: $policy ->setType(PhabricatorPolicyType::TYPE_PROJECT) ->setName($handle->getName()) ->setIcon($handle->getIcon()); break; case PhabricatorPeopleUserPHIDType::TYPECONST: $policy->setType(PhabricatorPolicyType::TYPE_USER); $policy->setName($handle->getFullName()); break; case PhabricatorPolicyPHIDTypePolicy::TYPECONST: // TODO: This creates a weird handle-based version of a rule policy. // It behaves correctly, but can't be applied since it doesn't have // any rules. It is used to render transactions, and might need some // cleanup. break; default: $policy->setType(PhabricatorPolicyType::TYPE_MASKED); $policy->setName($handle->getFullName()); break; } $policy->makeEphemeral(); return $policy; } public function setType($type) { $this->type = $type; return $this; } public function getType() { if (!$this->type) { return PhabricatorPolicyType::TYPE_CUSTOM; } return $this->type; } public function setName($name) { $this->name = $name; return $this; } public function getName() { if (!$this->name) { return pht('Custom Policy'); } return $this->name; } public function setShortName($short_name) { $this->shortName = $short_name; return $this; } public function getShortName() { if ($this->shortName) { return $this->shortName; } return $this->getName(); } public function setHref($href) { $this->href = $href; return $this; } public function getHref() { return $this->href; } public function setWorkflow($workflow) { $this->workflow = $workflow; return $this; } public function getWorkflow() { return $this->workflow; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function getIcon() { if ($this->icon) { return $this->icon; } switch ($this->getType()) { case PhabricatorPolicyType::TYPE_GLOBAL: static $map = array( PhabricatorPolicies::POLICY_PUBLIC => 'fa-globe', PhabricatorPolicies::POLICY_USER => 'fa-users', PhabricatorPolicies::POLICY_ADMIN => 'fa-eye', PhabricatorPolicies::POLICY_NOONE => 'fa-ban', ); return idx($map, $this->getPHID(), 'fa-question-circle'); case PhabricatorPolicyType::TYPE_USER: return 'fa-user'; case PhabricatorPolicyType::TYPE_PROJECT: return 'fa-briefcase'; case PhabricatorPolicyType::TYPE_CUSTOM: case PhabricatorPolicyType::TYPE_MASKED: return 'fa-certificate'; default: return 'fa-question-circle'; } } public function getSortKey() { return sprintf( '%02d%s', PhabricatorPolicyType::getPolicyTypeOrder($this->getType()), $this->getSortName()); } private function getSortName() { if ($this->getType() == PhabricatorPolicyType::TYPE_GLOBAL) { static $map = array( PhabricatorPolicies::POLICY_PUBLIC => 0, PhabricatorPolicies::POLICY_USER => 1, PhabricatorPolicies::POLICY_ADMIN => 2, PhabricatorPolicies::POLICY_NOONE => 3, ); return idx($map, $this->getPHID()); } return $this->getName(); } public static function getPolicyExplanation( PhabricatorUser $viewer, $policy) { $type = phid_get_type($policy); if ($type === PhabricatorProjectProjectPHIDType::TYPECONST) { $handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($policy)) ->executeOne(); return pht( 'Members of the project "%s" can take this action.', $handle->getFullName()); } return self::getOpaquePolicyExplanation($viewer, $policy); } public static function getOpaquePolicyExplanation( PhabricatorUser $viewer, $policy) { $rule = PhabricatorPolicyQuery::getObjectPolicyRule($policy); if ($rule) { return $rule->getPolicyExplanation(); } switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: return pht( 'This object is public and can be viewed by anyone, even if they '. 'do not have a Phabricator account.'); case PhabricatorPolicies::POLICY_USER: return pht('Logged in users can take this action.'); case PhabricatorPolicies::POLICY_ADMIN: return pht('Administrators can take this action.'); case PhabricatorPolicies::POLICY_NOONE: return pht('By default, no one can take this action.'); default: $handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($policy)) ->executeOne(); $type = phid_get_type($policy); if ($type == PhabricatorProjectProjectPHIDType::TYPECONST) { return pht( 'Members of a particular project can take this action. (You '. 'can not see this object, so the name of this project is '. - 'restricted.)', - $handle->getFullName()); + 'restricted.)'); } else if ($type == PhabricatorPeopleUserPHIDType::TYPECONST) { return pht( '%s can take this action.', $handle->getFullName()); } else if ($type == PhabricatorPolicyPHIDTypePolicy::TYPECONST) { return pht( 'This object has a custom policy controlling who can take this '. 'action.'); } else { return pht( 'This object has an unknown or invalid policy setting ("%s").', $policy); } } } public function getFullName() { switch ($this->getType()) { case PhabricatorPolicyType::TYPE_PROJECT: return pht('Members of Project: %s', $this->getName()); case PhabricatorPolicyType::TYPE_MASKED: return pht('Other: %s', $this->getName()); case PhabricatorPolicyType::TYPE_USER: return pht('Only User: %s', $this->getName()); default: return $this->getName(); } } public function newRef(PhabricatorUser $viewer) { return id(new PhabricatorPolicyRef()) ->setViewer($viewer) ->setPolicy($this); } public function isProjectPolicy() { return ($this->getType() === PhabricatorPolicyType::TYPE_PROJECT); } public function isCustomPolicy() { return ($this->getType() === PhabricatorPolicyType::TYPE_CUSTOM); } public function isMaskedPolicy() { return ($this->getType() === PhabricatorPolicyType::TYPE_MASKED); } /** * Return a list of custom rule classes (concrete subclasses of * @{class:PhabricatorPolicyRule}) this policy uses. * * @return list List of class names. */ public function getCustomRuleClasses() { $classes = array(); foreach ($this->getRules() as $rule) { if (!is_array($rule)) { // This rule is invalid. We'll reject it later, but don't need to // extract anything from it for now. continue; } $class = idx($rule, 'rule'); try { if (class_exists($class)) { $classes[$class] = $class; } } catch (Exception $ex) { continue; } } return array_keys($classes); } /** * Return a list of all values used by a given rule class to implement this * policy. This is used to bulk load data (like project memberships) in order * to apply policy filters efficiently. * * @param string Policy rule classname. * @return list List of values used in this policy. */ public function getCustomRuleValues($rule_class) { $values = array(); foreach ($this->getRules() as $rule) { if ($rule['rule'] == $rule_class) { $values[] = $rule['value']; } } return $values; } public function attachRuleObjects(array $objects) { $this->ruleObjects = $objects; return $this; } public function getRuleObjects() { return $this->assertAttached($this->ruleObjects); } /** * Return `true` if this policy is stronger (more restrictive) than some * other policy. * * Because policies are complicated, determining which policies are * "stronger" is not trivial. This method uses a very coarse working * definition of policy strength which is cheap to compute, unambiguous, * and intuitive in the common cases. * * This method returns `true` if the //class// of this policy is stronger * than the other policy, even if the policies are (or might be) the same in * practice. For example, "Members of Project X" is considered a stronger * policy than "All Users", even though "Project X" might (in some rare * cases) contain every user. * * Generally, the ordering here is: * * - Public * - All Users * - (Everything Else) * - No One * * In the "everything else" bucket, we can't make any broad claims about * which policy is stronger (and we especially can't make those claims * cheaply). * * Even if we fully evaluated each policy, the two policies might be * "Members of X" and "Members of Y", each of which permits access to some * set of unique users. In this case, neither is strictly stronger than * the other. * * @param PhabricatorPolicy Other policy. * @return bool `true` if this policy is more restrictive than the other * policy. */ public function isStrongerThan(PhabricatorPolicy $other) { $this_policy = $this->getPHID(); $other_policy = $other->getPHID(); $strengths = array( PhabricatorPolicies::POLICY_PUBLIC => -2, PhabricatorPolicies::POLICY_USER => -1, // (Default policies have strength 0.) PhabricatorPolicies::POLICY_NOONE => 1, ); $this_strength = idx($strengths, $this->getPHID(), 0); $other_strength = idx($strengths, $other->getPHID(), 0); return ($this_strength > $other_strength); } public function isValidPolicyForEdit() { return $this->getType() !== PhabricatorPolicyType::TYPE_MASKED; } public static function getSpecialRules( PhabricatorPolicyInterface $object, PhabricatorUser $viewer, $capability, $active_only) { $exceptions = array(); if ($object instanceof PhabricatorPolicyCodexInterface) { $codex = id(PhabricatorPolicyCodex::newFromObject($object, $viewer)) ->setCapability($capability); $rules = $codex->getPolicySpecialRuleDescriptions(); foreach ($rules as $rule) { $is_active = $rule->getIsActive(); if ($is_active) { $rule_capabilities = $rule->getCapabilities(); if ($rule_capabilities) { if (!in_array($capability, $rule_capabilities)) { $is_active = false; } } } if (!$is_active && $active_only) { continue; } $description = $rule->getDescription(); if (!$is_active) { $description = phutil_tag( 'span', array( 'class' => 'phui-policy-section-view-inactive-rule', ), $description); } $exceptions[] = $description; } } if (!$exceptions) { if (method_exists($object, 'describeAutomaticCapability')) { $exceptions = (array)$object->describeAutomaticCapability($capability); $exceptions = array_filter($exceptions); } } return $exceptions; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, ); } public function getPolicy($capability) { // NOTE: We implement policies only so we can comply with the interface. // The actual query skips them, as enforcing policies on policies seems // perilous and isn't currently required by the application. return PhabricatorPolicies::POLICY_PUBLIC; } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->delete(); } } diff --git a/src/applications/project/icon/PhabricatorProjectIconSet.php b/src/applications/project/icon/PhabricatorProjectIconSet.php index b128a35cad..5462da78d6 100644 --- a/src/applications/project/icon/PhabricatorProjectIconSet.php +++ b/src/applications/project/icon/PhabricatorProjectIconSet.php @@ -1,508 +1,509 @@ 'project', 'icon' => 'fa-briefcase', 'name' => pht('Project'), 'default' => true, 'image' => 'v3/briefcase.png', ), array( 'key' => 'tag', 'icon' => 'fa-tags', 'name' => pht('Tag'), 'image' => 'v3/tag.png', ), array( 'key' => 'policy', 'icon' => 'fa-lock', 'name' => pht('Policy'), 'image' => 'v3/lock.png', ), array( 'key' => 'group', 'icon' => 'fa-users', 'name' => pht('Group'), 'image' => 'v3/people.png', ), array( 'key' => 'folder', 'icon' => 'fa-folder', 'name' => pht('Folder'), 'image' => 'v3/folder.png', ), array( 'key' => 'timeline', 'icon' => 'fa-calendar', 'name' => pht('Timeline'), 'image' => 'v3/calendar.png', ), array( 'key' => 'goal', 'icon' => 'fa-flag-checkered', 'name' => pht('Goal'), 'image' => 'v3/flag.png', ), array( 'key' => 'release', 'icon' => 'fa-truck', 'name' => pht('Release'), 'image' => 'v3/truck.png', ), array( 'key' => 'bugs', 'icon' => 'fa-bug', 'name' => pht('Bugs'), 'image' => 'v3/bug.png', ), array( 'key' => 'cleanup', 'icon' => 'fa-trash-o', 'name' => pht('Cleanup'), 'image' => 'v3/trash.png', ), array( 'key' => 'umbrella', 'icon' => 'fa-umbrella', 'name' => pht('Umbrella'), 'image' => 'v3/umbrella.png', ), array( 'key' => 'communication', 'icon' => 'fa-envelope', 'name' => pht('Communication'), 'image' => 'v3/mail.png', ), array( 'key' => 'organization', 'icon' => 'fa-building', 'name' => pht('Organization'), 'image' => 'v3/organization.png', ), array( 'key' => 'infrastructure', 'icon' => 'fa-cloud', 'name' => pht('Infrastructure'), 'image' => 'v3/cloud.png', ), array( 'key' => 'account', 'icon' => 'fa-credit-card', 'name' => pht('Account'), 'image' => 'v3/creditcard.png', ), array( 'key' => 'experimental', 'icon' => 'fa-flask', 'name' => pht('Experimental'), 'image' => 'v3/experimental.png', ), array( 'key' => 'milestone', 'icon' => 'fa-map-marker', 'name' => pht('Milestone'), 'special' => self::SPECIAL_MILESTONE, 'image' => 'v3/marker.png', ), ); } protected function newIcons() { $map = self::getIconSpecifications(); $icons = array(); foreach ($map as $spec) { $special = idx($spec, 'special'); if ($special === self::SPECIAL_MILESTONE) { continue; } $icons[] = id(new PhabricatorIconSetIcon()) ->setKey($spec['key']) ->setIsDisabled(idx($spec, 'disabled')) ->setIcon($spec['icon']) ->setLabel($spec['name']); } return $icons; } private static function getIconSpecifications() { return PhabricatorEnv::getEnvConfig('projects.icons'); } public static function getDefaultIconKey() { $icons = self::getIconSpecifications(); foreach ($icons as $icon) { if (idx($icon, 'default')) { return $icon['key']; } } return null; } public static function getIconIcon($key) { $spec = self::getIconSpec($key); return idx($spec, 'icon', null); } public static function getIconName($key) { $spec = self::getIconSpec($key); return idx($spec, 'name', null); } public static function getIconImage($key) { $spec = self::getIconSpec($key); return idx($spec, 'image', 'v3/briefcase.png'); } private static function getIconSpec($key) { $icons = self::getIconSpecifications(); foreach ($icons as $icon) { if (idx($icon, 'key') === $key) { return $icon; } } return array(); } public static function getMilestoneIconKey() { $icons = self::getIconSpecifications(); foreach ($icons as $icon) { if (idx($icon, 'special') === self::SPECIAL_MILESTONE) { return idx($icon, 'key'); } } return null; } public static function validateConfiguration($config) { if (!is_array($config)) { throw new Exception( pht('Configuration must be a list of project icon specifications.')); } foreach ($config as $idx => $value) { if (!is_array($value)) { throw new Exception( pht( 'Value for index "%s" should be a dictionary.', $idx)); } PhutilTypeSpec::checkMap( $value, array( 'key' => 'string', 'name' => 'string', 'icon' => 'string', 'image' => 'optional string', 'special' => 'optional string', 'disabled' => 'optional bool', 'default' => 'optional bool', )); if (!preg_match('/^[a-z]{1,32}\z/', $value['key'])) { throw new Exception( pht( 'Icon key "%s" is not a valid icon key. Icon keys must be 1-32 '. 'characters long and contain only lowercase letters. For example, '. '"%s" and "%s" are reasonable keys.', + $value['key'], 'tag', 'group')); } $special = idx($value, 'special'); $valid = array( self::SPECIAL_MILESTONE => true, ); if ($special !== null) { if (empty($valid[$special])) { throw new Exception( pht( 'Icon special attribute "%s" is not valid. Recognized special '. 'attributes are: %s.', $special, implode(', ', array_keys($valid)))); } } } $default = null; $milestone = null; $keys = array(); foreach ($config as $idx => $value) { $key = $value['key']; if (isset($keys[$key])) { throw new Exception( pht( 'Project icons must have unique keys, but two icons share the '. 'same key ("%s").', $key)); } else { $keys[$key] = true; } $is_disabled = idx($value, 'disabled'); $image = idx($value, 'image'); if ($image !== null) { $builtin = idx($value, 'image'); $builtin_map = id(new PhabricatorFilesOnDiskBuiltinFile()) ->getProjectBuiltinFiles(); $builtin_map = array_flip($builtin_map); $root = dirname(phutil_get_library_root('phabricator')); $image = $root.'/resources/builtin/projects/'.$builtin; if (!array_key_exists($image, $builtin_map)) { throw new Exception( pht( 'The project image ("%s") specified for ("%s") '. 'was not found in the folder "resources/builtin/projects/".', $builtin, $key)); } } if (idx($value, 'default')) { if ($default === null) { if ($is_disabled) { throw new Exception( pht( 'The project icon marked as the default icon ("%s") must not '. 'be disabled.', $key)); } $default = $value; } else { $original_key = $default['key']; throw new Exception( pht( 'Two different icons ("%s", "%s") are marked as the default '. 'icon. Only one icon may be marked as the default.', $key, $original_key)); } } $special = idx($value, 'special'); if ($special === self::SPECIAL_MILESTONE) { if ($milestone === null) { if ($is_disabled) { throw new Exception( pht( 'The project icon ("%s") with special attribute "%s" must '. 'not be disabled', $key, self::SPECIAL_MILESTONE)); } $milestone = $value; } else { $original_key = $milestone['key']; throw new Exception( pht( 'Two different icons ("%s", "%s") are marked with special '. 'attribute "%s". Only one icon may be marked with this '. 'attribute.', $key, $original_key, self::SPECIAL_MILESTONE)); } } } if ($default === null) { throw new Exception( pht( 'Project icons must include one icon marked as the "%s" icon, '. 'but no such icon exists.', 'default')); } if ($milestone === null) { throw new Exception( pht( 'Project icons must include one icon marked with special attribute '. '"%s", but no such icon exists.', self::SPECIAL_MILESTONE)); } } private static function getColorSpecifications() { return PhabricatorEnv::getEnvConfig('projects.colors'); } public static function getColorMap() { $specifications = self::getColorSpecifications(); return ipull($specifications, 'name', 'key'); } public static function getDefaultColorKey() { $specifications = self::getColorSpecifications(); foreach ($specifications as $specification) { if (idx($specification, 'default')) { return $specification['key']; } } return null; } private static function getAvailableColorKeys() { $list = array(); $specifications = self::getDefaultColorMap(); foreach ($specifications as $specification) { $list[] = $specification['key']; } return $list; } public static function getColorName($color_key) { $map = self::getColorMap(); return idx($map, $color_key); } public static function getDefaultColorMap() { return array( array( 'key' => PHUITagView::COLOR_RED, 'name' => pht('Red'), ), array( 'key' => PHUITagView::COLOR_ORANGE, 'name' => pht('Orange'), ), array( 'key' => PHUITagView::COLOR_YELLOW, 'name' => pht('Yellow'), ), array( 'key' => PHUITagView::COLOR_GREEN, 'name' => pht('Green'), ), array( 'key' => PHUITagView::COLOR_BLUE, 'name' => pht('Blue'), 'default' => true, ), array( 'key' => PHUITagView::COLOR_INDIGO, 'name' => pht('Indigo'), ), array( 'key' => PHUITagView::COLOR_VIOLET, 'name' => pht('Violet'), ), array( 'key' => PHUITagView::COLOR_PINK, 'name' => pht('Pink'), ), array( 'key' => PHUITagView::COLOR_GREY, 'name' => pht('Grey'), ), array( 'key' => PHUITagView::COLOR_CHECKERED, 'name' => pht('Checkered'), ), ); } public static function validateColorConfiguration($config) { if (!is_array($config)) { throw new Exception( pht('Configuration must be a list of project color specifications.')); } $available_keys = self::getAvailableColorKeys(); $available_keys = array_fuse($available_keys); foreach ($config as $idx => $value) { if (!is_array($value)) { throw new Exception( pht( 'Value for index "%s" should be a dictionary.', $idx)); } PhutilTypeSpec::checkMap( $value, array( 'key' => 'string', 'name' => 'string', 'default' => 'optional bool', )); $key = $value['key']; if (!isset($available_keys[$key])) { throw new Exception( pht( 'Color key "%s" is not a valid color key. The supported color '. 'keys are: %s.', $key, implode(', ', $available_keys))); } } $default = null; $keys = array(); foreach ($config as $idx => $value) { $key = $value['key']; if (isset($keys[$key])) { throw new Exception( pht( 'Project colors must have unique keys, but two icons share the '. 'same key ("%s").', $key)); } else { $keys[$key] = true; } if (idx($value, 'default')) { if ($default === null) { $default = $value; } else { $original_key = $default['key']; throw new Exception( pht( 'Two different colors ("%s", "%s") are marked as the default '. 'color. Only one color may be marked as the default.', $key, $original_key)); } } } if ($default === null) { throw new Exception( pht( 'Project colors must include one color marked as the "%s" color, '. 'but no such color exists.', 'default')); } } } diff --git a/src/applications/search/ferret/function/FerretSearchFunction.php b/src/applications/search/ferret/function/FerretSearchFunction.php index 60886c2fb6..8019a741ca 100644 --- a/src/applications/search/ferret/function/FerretSearchFunction.php +++ b/src/applications/search/ferret/function/FerretSearchFunction.php @@ -1,122 +1,124 @@ newFerretSearchFunctions(); if (!is_array($functions)) { throw new Exception( pht( 'Expected fulltext engine extension ("%s") to return a '. 'list of functions from "newFerretSearchFunctions()", '. 'got "%s".', get_class($extension), phutil_describe_type($functions))); } foreach ($functions as $idx => $function) { if (!($function instanceof FerretSearchFunction)) { throw new Exception( pht( 'Expected fulltext engine extension ("%s") to return a list '. 'of "FerretSearchFunction" objects from '. '"newFerretSearchFunctions()", but found something else '. '("%s") at index "%s".', get_class($extension), phutil_describe_type($function), $idx)); } $function_name = $function->getFerretFunctionName(); self::validateFerretFunctionName($function_name); $normal_name = self::getNormalizedFunctionName( $function_name); if ($normal_name !== $function_name) { throw new Exception( pht( 'Ferret function "%s" is specified with a denormalized name. '. 'Instead, specify the function using the normalized '. 'function name ("%s").', + $function_name, $normal_name)); } if (isset($function_map[$function_name])) { $other_extension = $function_map[$function_name]; throw new Exception( pht( 'Two different fulltext engine extensions ("%s" and "%s") '. 'both define a search function with the same name ("%s"). '. 'Each function must have a unique name.', get_class($extension), get_class($other_extension), $function_name)); } $function_map[$function_name] = $extension; $field_key = $function->getFerretFieldKey(); self::validateFerretFunctionFieldKey($field_key); if (isset($field_map[$field_key])) { $other_extension = $field_map[$field_key]; throw new Exception( pht( 'Two different fulltext engine extensions ("%s" and "%s") '. 'both define a search function with the same key ("%s"). '. 'Each function must have a unique key.', get_class($extension), get_class($other_extension), $field_key)); } $field_map[$field_key] = $extension; $results[$function_name] = $function; } } ksort($results); return $results; } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php index 1c142847a3..ea1d7ed773 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -1,2744 +1,2746 @@ viewer = $viewer; return $this; } final public function getViewer() { return $this->viewer; } final public function setController(PhabricatorController $controller) { $this->controller = $controller; $this->setViewer($controller->getViewer()); return $this; } final public function getController() { return $this->controller; } final public function getEngineKey() { $key = $this->getPhobjectClassConstant('ENGINECONST', 64); if (strpos($key, '/') !== false) { throw new Exception( pht( 'EditEngine ("%s") contains an invalid key character "/".', get_class($this))); } return $key; } final public function getApplication() { $app_class = $this->getEngineApplicationClass(); return PhabricatorApplication::getByClass($app_class); } final public function addContextParameter($key) { $this->contextParameters[] = $key; return $this; } public function isEngineConfigurable() { return true; } public function isEngineExtensible() { return true; } public function isDefaultQuickCreateEngine() { return false; } public function getDefaultQuickCreateFormKeys() { $keys = array(); if ($this->isDefaultQuickCreateEngine()) { $keys[] = self::EDITENGINECONFIG_DEFAULT; } foreach ($keys as $idx => $key) { $keys[$idx] = $this->getEngineKey().'/'.$key; } return $keys; } public static function splitFullKey($full_key) { return explode('/', $full_key, 2); } public function getQuickCreateOrderVector() { return id(new PhutilSortVector()) ->addString($this->getObjectCreateShortText()); } /** * Force the engine to edit a particular object. */ public function setTargetObject($target_object) { $this->targetObject = $target_object; return $this; } public function getTargetObject() { return $this->targetObject; } public function setNavigation(AphrontSideNavFilterView $navigation) { $this->navigation = $navigation; return $this; } public function getNavigation() { return $this->navigation; } /* -( Managing Fields )---------------------------------------------------- */ abstract public function getEngineApplicationClass(); abstract protected function buildCustomEditFields($object); public function getFieldsForConfig( PhabricatorEditEngineConfiguration $config) { $object = $this->newEditableObject(); $this->editEngineConfiguration = $config; // This is mostly making sure that we fill in default values. $this->setIsCreate(true); return $this->buildEditFields($object); } final protected function buildEditFields($object) { $viewer = $this->getViewer(); $fields = $this->buildCustomEditFields($object); foreach ($fields as $field) { $field ->setViewer($viewer) ->setObject($object); } $fields = mpull($fields, null, 'getKey'); if ($this->isEngineExtensible()) { $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions(); } else { $extensions = array(); } // See T13248. Create a template object to provide to extensions. We // adjust the template to have the intended subtype, so that extensions // may change behavior based on the form subtype. $template_object = clone $object; if ($this->getIsCreate()) { if ($this->supportsSubtypes()) { $config = $this->getEditEngineConfiguration(); $subtype = $config->getSubtype(); $template_object->setSubtype($subtype); } } foreach ($extensions as $extension) { $extension->setViewer($viewer); if (!$extension->supportsObject($this, $template_object)) { continue; } $extension_fields = $extension->buildCustomEditFields( $this, $template_object); // TODO: Validate this in more detail with a more tailored error. assert_instances_of($extension_fields, 'PhabricatorEditField'); foreach ($extension_fields as $field) { $field ->setViewer($viewer) ->setObject($object); $group_key = $field->getBulkEditGroupKey(); if ($group_key === null) { $field->setBulkEditGroupKey('extension'); } } $extension_fields = mpull($extension_fields, null, 'getKey'); foreach ($extension_fields as $key => $field) { $fields[$key] = $field; } } $config = $this->getEditEngineConfiguration(); $fields = $this->willConfigureFields($object, $fields); $fields = $config->applyConfigurationToFields($this, $object, $fields); $fields = $this->applyPageToFields($object, $fields); return $fields; } protected function willConfigureFields($object, array $fields) { return $fields; } final public function supportsSubtypes() { try { $object = $this->newEditableObject(); } catch (Exception $ex) { return false; } return ($object instanceof PhabricatorEditEngineSubtypeInterface); } final public function newSubtypeMap() { return $this->newEditableObject()->newEditEngineSubtypeMap(); } /* -( Display Text )------------------------------------------------------- */ /** * @task text */ abstract public function getEngineName(); /** * @task text */ abstract protected function getObjectCreateTitleText($object); /** * @task text */ protected function getFormHeaderText($object) { $config = $this->getEditEngineConfiguration(); return $config->getName(); } /** * @task text */ abstract protected function getObjectEditTitleText($object); /** * @task text */ abstract protected function getObjectCreateShortText(); /** * @task text */ abstract protected function getObjectName(); /** * @task text */ abstract protected function getObjectEditShortText($object); /** * @task text */ protected function getObjectCreateButtonText($object) { return $this->getObjectCreateTitleText($object); } /** * @task text */ protected function getObjectEditButtonText($object) { return pht('Save Changes'); } /** * @task text */ protected function getCommentViewSeriousHeaderText($object) { return pht('Take Action'); } /** * @task text */ protected function getCommentViewSeriousButtonText($object) { return pht('Submit'); } /** * @task text */ protected function getCommentViewHeaderText($object) { return $this->getCommentViewSeriousHeaderText($object); } /** * @task text */ protected function getCommentViewButtonText($object) { return $this->getCommentViewSeriousButtonText($object); } /** * @task text */ protected function getPageHeader($object) { return null; } /** * Return a human-readable header describing what this engine is used to do, * like "Configure Maniphest Task Forms". * * @return string Human-readable description of the engine. * @task text */ abstract public function getSummaryHeader(); /** * Return a human-readable summary of what this engine is used to do. * * @return string Human-readable description of the engine. * @task text */ abstract public function getSummaryText(); /* -( Edit Engine Configuration )------------------------------------------ */ protected function supportsEditEngineConfiguration() { return true; } final protected function getEditEngineConfiguration() { return $this->editEngineConfiguration; } public function newConfigurationQuery() { return id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($this->getViewer()) ->withEngineKeys(array($this->getEngineKey())); } private function loadEditEngineConfigurationWithQuery( PhabricatorEditEngineConfigurationQuery $query, $sort_method) { if ($sort_method) { $results = $query->execute(); $results = msort($results, $sort_method); $result = head($results); } else { $result = $query->executeOne(); } if (!$result) { return null; } $this->editEngineConfiguration = $result; return $result; } private function loadEditEngineConfigurationWithIdentifier($identifier) { $query = $this->newConfigurationQuery() ->withIdentifiers(array($identifier)); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultConfiguration() { $query = $this->newConfigurationQuery() ->withIdentifiers( array( self::EDITENGINECONFIG_DEFAULT, )) ->withIgnoreDatabaseConfigurations(true); return $this->loadEditEngineConfigurationWithQuery($query, null); } private function loadDefaultCreateConfiguration() { $query = $this->newConfigurationQuery() ->withIsDefault(true) ->withIsDisabled(false); return $this->loadEditEngineConfigurationWithQuery( $query, 'getCreateSortKey'); } public function loadDefaultEditConfiguration($object) { $query = $this->newConfigurationQuery() ->withIsEdit(true) ->withIsDisabled(false); // If this object supports subtyping, we edit it with a form of the same // subtype: so "bug" tasks get edited with "bug" forms. if ($object instanceof PhabricatorEditEngineSubtypeInterface) { $query->withSubtypes( array( $object->getEditEngineSubtype(), )); } return $this->loadEditEngineConfigurationWithQuery( $query, 'getEditSortKey'); } final public function getBuiltinEngineConfigurations() { $configurations = $this->newBuiltinEngineConfigurations(); if (!$configurations) { throw new Exception( pht( 'EditEngine ("%s") returned no builtin engine configurations, but '. 'an edit engine must have at least one configuration.', get_class($this))); } assert_instances_of($configurations, 'PhabricatorEditEngineConfiguration'); $has_default = false; foreach ($configurations as $config) { if ($config->getBuiltinKey() == self::EDITENGINECONFIG_DEFAULT) { $has_default = true; } } if (!$has_default) { $first = head($configurations); if (!$first->getBuiltinKey()) { $first ->setBuiltinKey(self::EDITENGINECONFIG_DEFAULT) ->setIsDefault(true) ->setIsEdit(true); if (!strlen($first->getName())) { $first->setName($this->getObjectCreateShortText()); } } else { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but none are marked as default and the first configuration has '. 'a different builtin key already. Mark a builtin as default or '. 'omit the key from the first configuration', get_class($this))); } } $builtins = array(); foreach ($configurations as $key => $config) { $builtin_key = $config->getBuiltinKey(); if ($builtin_key === null) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but one (with key "%s") is missing a builtin key. Provide a '. 'builtin key for each configuration (you can omit it from the '. 'first configuration in the list to automatically assign the '. 'default key).', get_class($this), $key)); } if (isset($builtins[$builtin_key])) { throw new Exception( pht( 'EditEngine ("%s") returned builtin engine configurations, '. 'but at least two specify the same builtin key ("%s"). Engines '. 'must have unique builtin keys.', get_class($this), $builtin_key)); } $builtins[$builtin_key] = $config; } return $builtins; } protected function newBuiltinEngineConfigurations() { return array( $this->newConfiguration(), ); } final protected function newConfiguration() { return PhabricatorEditEngineConfiguration::initializeNewConfiguration( $this->getViewer(), $this); } /* -( Managing URIs )------------------------------------------------------ */ /** * @task uri */ abstract protected function getObjectViewURI($object); /** * @task uri */ protected function getObjectCreateCancelURI($object) { return $this->getApplication()->getApplicationURI(); } /** * @task uri */ protected function getEditorURI() { return $this->getApplication()->getApplicationURI('edit/'); } /** * @task uri */ protected function getObjectEditCancelURI($object) { return $this->getObjectViewURI($object); } /** * @task uri */ public function getCreateURI($form_key) { try { $create_uri = $this->getEditURI(null, "form/{$form_key}/"); } catch (Exception $ex) { $create_uri = null; } return $create_uri; } /** * @task uri */ public function getEditURI($object = null, $path = null) { $parts = array(); $parts[] = $this->getEditorURI(); if ($object && $object->getID()) { $parts[] = $object->getID().'/'; } if ($path !== null) { $parts[] = $path; } return implode('', $parts); } public function getEffectiveObjectViewURI($object) { if ($this->getIsCreate()) { return $this->getObjectViewURI($object); } $page = $this->getSelectedPage(); if ($page) { $view_uri = $page->getViewURI(); if ($view_uri !== null) { return $view_uri; } } return $this->getObjectViewURI($object); } public function getEffectiveObjectEditDoneURI($object) { return $this->getEffectiveObjectViewURI($object); } public function getEffectiveObjectEditCancelURI($object) { $page = $this->getSelectedPage(); if ($page) { $view_uri = $page->getViewURI(); if ($view_uri !== null) { return $view_uri; } } return $this->getObjectEditCancelURI($object); } /* -( Creating and Loading Objects )--------------------------------------- */ /** * Initialize a new object for creation. * * @return object Newly initialized object. * @task load */ abstract protected function newEditableObject(); /** * Build an empty query for objects. * * @return PhabricatorPolicyAwareQuery Query. * @task load */ abstract protected function newObjectQuery(); /** * Test if this workflow is creating a new object or editing an existing one. * * @return bool True if a new object is being created. * @task load */ final public function getIsCreate() { return $this->isCreate; } /** * Initialize a new object for object creation via Conduit. * * @return object Newly initialized object. * @param list Raw transactions. * @task load */ protected function newEditableObjectFromConduit(array $raw_xactions) { return $this->newEditableObject(); } /** * Initialize a new object for documentation creation. * * @return object Newly initialized object. * @task load */ protected function newEditableObjectForDocumentation() { return $this->newEditableObject(); } /** * Flag this workflow as a create or edit. * * @param bool True if this is a create workflow. * @return this * @task load */ private function setIsCreate($is_create) { $this->isCreate = $is_create; return $this; } /** * Try to load an object by ID, PHID, or monogram. This is done primarily * to make Conduit a little easier to use. * * @param wild ID, PHID, or monogram. * @param list List of required capability constants, or omit for * defaults. * @return object Corresponding editable object. * @task load */ private function newObjectFromIdentifier( $identifier, array $capabilities = array()) { if (is_int($identifier) || ctype_digit($identifier)) { $object = $this->newObjectFromID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with ID "%s".', $identifier)); } return $object; } $type_unknown = PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN; if (phid_get_type($identifier) != $type_unknown) { $object = $this->newObjectFromPHID($identifier, $capabilities); if (!$object) { throw new Exception( pht( 'No object exists with PHID "%s".', $identifier)); } return $object; } $target = id(new PhabricatorObjectQuery()) ->setViewer($this->getViewer()) ->withNames(array($identifier)) ->executeOne(); if (!$target) { throw new Exception( pht( 'Monogram "%s" does not identify a valid object.', $identifier)); } $expect = $this->newEditableObject(); $expect_class = get_class($expect); $target_class = get_class($target); if ($expect_class !== $target_class) { throw new Exception( pht( 'Monogram "%s" identifies an object of the wrong type. Loaded '. 'object has class "%s", but this editor operates on objects of '. 'type "%s".', $identifier, $target_class, $expect_class)); } // Load the object by PHID using this engine's standard query. This makes // sure it's really valid, goes through standard policy check logic, and // picks up any `need...()` clauses we want it to load with. $object = $this->newObjectFromPHID($target->getPHID(), $capabilities); if (!$object) { throw new Exception( pht( 'Failed to reload object identified by monogram "%s" when '. 'querying by PHID.', $identifier)); } return $object; } /** * Load an object by ID. * * @param int Object ID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromID($id, array $capabilities = array()) { $query = $this->newObjectQuery() ->withIDs(array($id)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object by PHID. * * @param phid Object PHID. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromPHID($phid, array $capabilities = array()) { $query = $this->newObjectQuery() ->withPHIDs(array($phid)); return $this->newObjectFromQuery($query, $capabilities); } /** * Load an object given a configured query. * * @param PhabricatorPolicyAwareQuery Configured query. * @param list List of required capability constants, or omit for * defaults. * @return object|null Object, or null if no such object exists. * @task load */ private function newObjectFromQuery( PhabricatorPolicyAwareQuery $query, array $capabilities = array()) { $viewer = $this->getViewer(); if (!$capabilities) { $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } $object = $query ->setViewer($viewer) ->requireCapabilities($capabilities) ->executeOne(); if (!$object) { return null; } return $object; } /** * Verify that an object is appropriate for editing. * * @param wild Loaded value. * @return void * @task load */ private function validateObject($object) { if (!$object || !is_object($object)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object must '. 'actually be an object, but is of some other type ("%s").', get_class($this), gettype($object))); } if (!($object instanceof PhabricatorApplicationTransactionInterface)) { throw new Exception( pht( 'EditEngine "%s" created or loaded an invalid object: object (of '. 'class "%s") must implement "%s", but does not.', get_class($this), get_class($object), 'PhabricatorApplicationTransactionInterface')); } } /* -( Responding to Web Requests )----------------------------------------- */ final public function buildResponse() { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $action = $this->getEditAction(); $capabilities = array(); $use_default = false; $require_create = true; switch ($action) { case 'comment': $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, ); $use_default = true; break; case 'parameters': $use_default = true; break; case 'nodefault': case 'nocreate': case 'nomanage': $require_create = false; break; default: break; } $object = $this->getTargetObject(); if (!$object) { $id = $request->getURIData('id'); if ($id) { $this->setIsCreate(false); $object = $this->newObjectFromID($id, $capabilities); if (!$object) { return new Aphront404Response(); } } else { // Make sure the viewer has permission to create new objects of // this type if we're going to create a new object. if ($require_create) { $this->requireCreateCapability(); } $this->setIsCreate(true); $object = $this->newEditableObject(); } } else { $id = $object->getID(); } $this->validateObject($object); if ($use_default) { $config = $this->loadDefaultConfiguration(); if (!$config) { return new Aphront404Response(); } } else { $form_key = $request->getURIData('formKey'); if (strlen($form_key)) { $config = $this->loadEditEngineConfigurationWithIdentifier($form_key); if (!$config) { return new Aphront404Response(); } if ($id && !$config->getIsEdit()) { return $this->buildNotEditFormRespose($object, $config); } } else { if ($id) { $config = $this->loadDefaultEditConfiguration($object); if (!$config) { return $this->buildNoEditResponse($object); } } else { $config = $this->loadDefaultCreateConfiguration(); if (!$config) { return $this->buildNoCreateResponse($object); } } } } if ($config->getIsDisabled()) { return $this->buildDisabledFormResponse($object, $config); } $page_key = $request->getURIData('pageKey'); if (!strlen($page_key)) { $pages = $this->getPages($object); if ($pages) { $page_key = head_key($pages); } } if (strlen($page_key)) { $page = $this->selectPage($object, $page_key); if (!$page) { return new Aphront404Response(); } } switch ($action) { case 'parameters': return $this->buildParametersResponse($object); case 'nodefault': return $this->buildNoDefaultResponse($object); case 'nocreate': return $this->buildNoCreateResponse($object); case 'nomanage': return $this->buildNoManageResponse($object); case 'comment': return $this->buildCommentResponse($object); default: return $this->buildEditResponse($object); } } private function buildCrumbs($object, $final = false) { $controller = $this->getController(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); if ($this->getIsCreate()) { $create_text = $this->getObjectCreateShortText(); if ($final) { $crumbs->addTextCrumb($create_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($create_text, $edit_uri); } } else { $crumbs->addTextCrumb( $this->getObjectEditShortText($object), $this->getEffectiveObjectViewURI($object)); $edit_text = pht('Edit'); if ($final) { $crumbs->addTextCrumb($edit_text); } else { $edit_uri = $this->getEditURI($object); $crumbs->addTextCrumb($edit_text, $edit_uri); } } return $crumbs; } private function buildEditResponse($object) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $template = $object->getApplicationTransactionTemplate(); $page_state = new PhabricatorEditEnginePageState(); if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); $page_state->setIsCreate(true); } else { $cancel_uri = $this->getEffectiveObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } $config = $this->getEditEngineConfiguration() ->attachEngine($this); // NOTE: Don't prompt users to override locks when creating objects, // even if the default settings would create a locked object. $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object); if (!$can_interact && !$this->getIsCreate() && !$request->getBool('editEngine') && !$request->getBool('overrideLock')) { $lock = PhabricatorEditEngineLock::newForObject($viewer, $object); $dialog = $this->getController() ->newDialog() ->addHiddenInput('overrideLock', true) ->setDisableWorkflowOnSubmit(true) ->addCancelButton($cancel_uri); return $lock->willPromptUserForLockOverrideWithDialog($dialog); } $validation_exception = null; if ($request->isFormOrHisecPost() && $request->getBool('editEngine')) { $page_state->setIsSubmit(true); $submit_fields = $fields; foreach ($submit_fields as $key => $field) { if (!$field->shouldGenerateTransactionsFromSubmit()) { unset($submit_fields[$key]); continue; } } // Before we read the submitted values, store a copy of what we would // use if the form was empty so we can figure out which transactions are // just setting things to their default values for the current form. $defaults = array(); foreach ($submit_fields as $key => $field) { $defaults[$key] = $field->getValueForTransaction(); } foreach ($submit_fields as $key => $field) { $field->setIsSubmittedForm(true); if (!$field->shouldReadValueFromSubmit()) { continue; } $field->readValueFromSubmit($request); } $xactions = array(); if ($this->getIsCreate()) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); if ($this->supportsSubtypes()) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_SUBTYPE) ->setNewValue($config->getSubtype()); } } foreach ($submit_fields as $key => $field) { $field_value = $field->getValueForTransaction(); $type_xactions = $field->generateTransactions( clone $template, array( 'value' => $field_value, )); foreach ($type_xactions as $type_xaction) { $default = $defaults[$key]; if ($default === $field->getValueForTransaction()) { $type_xaction->setIsDefaultTransaction(true); } $xactions[] = $type_xaction; } } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSourceFromRequest($request) ->setCancelURI($cancel_uri) ->setContinueOnNoEffect(true); try { $xactions = $this->willApplyTransactions($object, $xactions); $editor->applyTransactions($object, $xactions); $this->didApplyTransactions($object, $xactions); return $this->newEditResponse($request, $object, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { $validation_exception = $ex; foreach ($fields as $field) { $message = $this->getValidationExceptionShortMessage($ex, $field); if ($message === null) { continue; } $field->setControlError($message); } $page_state->setIsError(true); } } else { if ($this->getIsCreate()) { $template = $request->getStr('template'); if (strlen($template)) { $template_object = $this->newObjectFromIdentifier( $template, array( PhabricatorPolicyCapability::CAN_VIEW, )); if (!$template_object) { return new Aphront404Response(); } } else { $template_object = null; } if ($template_object) { $copy_fields = $this->buildEditFields($template_object); $copy_fields = mpull($copy_fields, null, 'getKey'); foreach ($copy_fields as $copy_key => $copy_field) { if (!$copy_field->getIsCopyable()) { unset($copy_fields[$copy_key]); } } } else { $copy_fields = array(); } foreach ($fields as $field) { if (!$field->shouldReadValueFromRequest()) { continue; } $field_key = $field->getKey(); if (isset($copy_fields[$field_key])) { $field->readValueFromField($copy_fields[$field_key]); } $field->readValueFromRequest($request); } } } $action_button = $this->buildEditFormActionButton($object); if ($this->getIsCreate()) { $header_text = $this->getFormHeaderText($object); } else { $header_text = $this->getObjectEditTitleText($object); } $show_preview = !$request->isAjax(); if ($show_preview) { $previews = array(); foreach ($fields as $field) { $preview = $field->getPreviewPanel(); if (!$preview) { continue; } $control_id = $field->getControlID(); $preview ->setControlID($control_id) ->setPreviewURI('/transactions/remarkuppreview/'); $previews[] = $preview; } } else { $previews = array(); } $form = $this->buildEditForm($object, $fields); $crumbs = $this->buildCrumbs($object, $final = true); $crumbs->setBorder(true); if ($request->isAjax()) { return $this->getController() ->newDialog() ->setWidth(AphrontDialogView::WIDTH_FULL) ->setTitle($header_text) ->setValidationException($validation_exception) ->appendForm($form) ->addCancelButton($cancel_uri) ->addSubmitButton($submit_button); } $box_header = id(new PHUIHeaderView()) ->setHeader($header_text); if ($action_button) { $box_header->addActionLink($action_button); } $request_submit_key = $request->getSubmitKey(); $engine_submit_key = $this->getEditEngineSubmitKey(); if ($request_submit_key === $engine_submit_key) { $page_state->setIsSubmit(true); $page_state->setIsSave(true); } $head = $this->newEditFormHeadContent($page_state); $tail = $this->newEditFormTailContent($page_state); $box = id(new PHUIObjectBoxView()) ->setUser($viewer) ->setHeader($box_header) ->setValidationException($validation_exception) ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) ->appendChild($form); $content = array( $head, $box, $previews, $tail, ); $view = new PHUITwoColumnView(); $page_header = $this->getPageHeader($object); if ($page_header) { $view->setHeader($page_header); } $view->setFooter($content); $page = $controller->newPage() ->setTitle($header_text) ->setCrumbs($crumbs) ->appendChild($view); $navigation = $this->getNavigation(); if ($navigation) { $page->setNavigation($navigation); } return $page; } protected function newEditFormHeadContent( PhabricatorEditEnginePageState $state) { return null; } protected function newEditFormTailContent( PhabricatorEditEnginePageState $state) { return null; } protected function newEditResponse( AphrontRequest $request, $object, array $xactions) { $submit_cookie = PhabricatorCookies::COOKIE_SUBMIT; $submit_key = $this->getEditEngineSubmitKey(); $request->setTemporaryCookie($submit_cookie, $submit_key); return id(new AphrontRedirectResponse()) ->setURI($this->getEffectiveObjectEditDoneURI($object)); } private function getEditEngineSubmitKey() { return 'edit-engine/'.$this->getEngineKey(); } private function buildEditForm($object, array $fields) { $viewer = $this->getViewer(); $controller = $this->getController(); $request = $controller->getRequest(); $fields = $this->willBuildEditForm($object, $fields); $request_path = $request->getPath(); $form = id(new AphrontFormView()) ->setUser($viewer) ->setAction($request_path) ->addHiddenInput('editEngine', 'true'); foreach ($this->contextParameters as $param) { $form->addHiddenInput($param, $request->getStr($param)); } $requires_mfa = false; if ($object instanceof PhabricatorEditEngineMFAInterface) { $mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) ->setViewer($viewer); $requires_mfa = $mfa_engine->shouldRequireMFA(); } if ($requires_mfa) { $message = pht( 'You will be required to provide multi-factor credentials to make '. 'changes.'); $form->appendChild( id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_MFA) ->setErrors(array($message))); // TODO: This should also set workflow on the form, so the user doesn't // lose any form data if they "Cancel". However, Maniphest currently // overrides "newEditResponse()" if the request is Ajax and returns a // bag of view data. This can reasonably be cleaned up when workboards // get their next iteration. } foreach ($fields as $field) { if (!$field->getIsFormField()) { continue; } $field->appendToForm($form); } if ($this->getIsCreate()) { $cancel_uri = $this->getObjectCreateCancelURI($object); $submit_button = $this->getObjectCreateButtonText($object); } else { $cancel_uri = $this->getEffectiveObjectEditCancelURI($object); $submit_button = $this->getObjectEditButtonText($object); } if (!$request->isAjax()) { $buttons = id(new AphrontFormSubmitControl()) ->setValue($submit_button); if ($cancel_uri) { $buttons->addCancelButton($cancel_uri); } $form->appendControl($buttons); } return $form; } protected function willBuildEditForm($object, array $fields) { return $fields; } private function buildEditFormActionButton($object) { if (!$this->isEngineConfigurable()) { return null; } $viewer = $this->getViewer(); $action_view = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($this->buildEditFormActions($object) as $action) { $action_view->addAction($action); } $action_button = id(new PHUIButtonView()) ->setTag('a') ->setText(pht('Configure Form')) ->setHref('#') ->setIcon('fa-gear') ->setDropdownMenu($action_view); return $action_button; } private function buildEditFormActions($object) { $actions = array(); if ($this->supportsEditEngineConfiguration()) { $engine_key = $this->getEngineKey(); $config = $this->getEditEngineConfiguration(); $can_manage = PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $config, PhabricatorPolicyCapability::CAN_EDIT); if ($can_manage) { $manage_uri = $config->getURI(); } else { $manage_uri = $this->getEditURI(null, 'nomanage/'); } $view_uri = "/transactions/editengine/{$engine_key}/"; $actions[] = id(new PhabricatorActionView()) ->setLabel(true) ->setName(pht('Configuration')); $actions[] = id(new PhabricatorActionView()) ->setName(pht('View Form Configurations')) ->setIcon('fa-list-ul') ->setHref($view_uri); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Edit Form Configuration')) ->setIcon('fa-pencil') ->setHref($manage_uri) ->setDisabled(!$can_manage) ->setWorkflow(!$can_manage); } $actions[] = id(new PhabricatorActionView()) ->setLabel(true) ->setName(pht('Documentation')); $actions[] = id(new PhabricatorActionView()) ->setName(pht('Using HTTP Parameters')) ->setIcon('fa-book') ->setHref($this->getEditURI($object, 'parameters/')); $doc_href = PhabricatorEnv::getDoclink('User Guide: Customizing Forms'); $actions[] = id(new PhabricatorActionView()) ->setName(pht('User Guide: Customizing Forms')) ->setIcon('fa-book') ->setHref($doc_href); return $actions; } public function newNUXButton($text) { $specs = $this->newCreateActionSpecifications(array()); $head = head($specs); return id(new PHUIButtonView()) ->setTag('a') ->setText($text) ->setHref($head['uri']) ->setDisabled($head['disabled']) ->setWorkflow($head['workflow']) ->setColor(PHUIButtonView::GREEN); } final public function addActionToCrumbs( PHUICrumbsView $crumbs, array $parameters = array()) { $viewer = $this->getViewer(); $specs = $this->newCreateActionSpecifications($parameters); $head = head($specs); $menu_uri = $head['uri']; $dropdown = null; if (count($specs) > 1) { $menu_icon = 'fa-caret-square-o-down'; $menu_name = $this->getObjectCreateShortText(); $workflow = false; $disabled = false; $dropdown = id(new PhabricatorActionListView()) ->setUser($viewer); foreach ($specs as $spec) { $dropdown->addAction( id(new PhabricatorActionView()) ->setName($spec['name']) ->setIcon($spec['icon']) ->setHref($spec['uri']) ->setDisabled($head['disabled']) ->setWorkflow($head['workflow'])); } } else { $menu_icon = $head['icon']; $menu_name = $head['name']; $workflow = $head['workflow']; $disabled = $head['disabled']; } $action = id(new PHUIListItemView()) ->setName($menu_name) ->setHref($menu_uri) ->setIcon($menu_icon) ->setWorkflow($workflow) ->setDisabled($disabled); if ($dropdown) { $action->setDropdownMenu($dropdown); } $crumbs->addAction($action); } /** * Build a raw description of available "Create New Object" UI options so * other methods can build menus or buttons. */ public function newCreateActionSpecifications(array $parameters) { $viewer = $this->getViewer(); $can_create = $this->hasCreateCapability(); if ($can_create) { $configs = $this->loadUsableConfigurationsForCreate(); } else { $configs = array(); } $disabled = false; $workflow = false; $menu_icon = 'fa-plus-square'; $specs = array(); if (!$configs) { if ($viewer->isLoggedIn()) { $disabled = true; } else { // If the viewer isn't logged in, assume they'll get hit with a login // dialog and are likely able to create objects after they log in. $disabled = false; } $workflow = true; if ($can_create) { $create_uri = $this->getEditURI(null, 'nodefault/'); } else { $create_uri = $this->getEditURI(null, 'nocreate/'); } $specs[] = array( 'name' => $this->getObjectCreateShortText(), 'uri' => $create_uri, 'icon' => $menu_icon, 'disabled' => $disabled, 'workflow' => $workflow, ); } else { foreach ($configs as $config) { $config_uri = $config->getCreateURI(); if ($parameters) { $config_uri = (string)new PhutilURI($config_uri, $parameters); } $specs[] = array( 'name' => $config->getDisplayName(), 'uri' => $config_uri, 'icon' => 'fa-plus', 'disabled' => false, 'workflow' => false, ); } } return $specs; } final public function buildEditEngineCommentView($object) { $config = $this->loadDefaultEditConfiguration($object); if (!$config) { // TODO: This just nukes the entire comment form if you don't have access // to any edit forms. We might want to tailor this UX a bit. return id(new PhabricatorApplicationTransactionCommentView()) ->setNoPermission(true); } $viewer = $this->getViewer(); $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object); if (!$can_interact) { $lock = PhabricatorEditEngineLock::newForObject($viewer, $object); return id(new PhabricatorApplicationTransactionCommentView()) ->setEditEngineLock($lock); } $object_phid = $object->getPHID(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); if ($is_serious) { $header_text = $this->getCommentViewSeriousHeaderText($object); $button_text = $this->getCommentViewSeriousButtonText($object); } else { $header_text = $this->getCommentViewHeaderText($object); $button_text = $this->getCommentViewButtonText($object); } $comment_uri = $this->getEditURI($object, 'comment/'); $requires_mfa = false; if ($object instanceof PhabricatorEditEngineMFAInterface) { $mfa_engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) ->setViewer($viewer); $requires_mfa = $mfa_engine->shouldRequireMFA(); } $view = id(new PhabricatorApplicationTransactionCommentView()) ->setUser($viewer) ->setObjectPHID($object_phid) ->setHeaderText($header_text) ->setAction($comment_uri) ->setRequiresMFA($requires_mfa) ->setSubmitButtonName($button_text); $draft = PhabricatorVersionedDraft::loadDraft( $object_phid, $viewer->getPHID()); if ($draft) { $view->setVersionedDraft($draft); } $view->setCurrentVersion($this->loadDraftVersion($object)); $fields = $this->buildEditFields($object); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $object, PhabricatorPolicyCapability::CAN_EDIT); $comment_actions = array(); foreach ($fields as $field) { if (!$field->shouldGenerateTransactionsFromComment()) { continue; } if (!$can_edit) { if (!$field->getCanApplyWithoutEditCapability()) { continue; } } $comment_action = $field->getCommentAction(); if (!$comment_action) { continue; } $key = $comment_action->getKey(); // TODO: Validate these better. $comment_actions[$key] = $comment_action; } $comment_actions = msortv($comment_actions, 'getSortVector'); $view->setCommentActions($comment_actions); $comment_groups = $this->newCommentActionGroups(); $view->setCommentActionGroups($comment_groups); return $view; } protected function loadDraftVersion($object) { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } $template = $object->getApplicationTransactionTemplate(); $conn_r = $template->establishConnection('r'); // Find the most recent transaction the user has written. We'll use this // as a version number to make sure that out-of-date drafts get discarded. $result = queryfx_one( $conn_r, 'SELECT id AS version FROM %T WHERE objectPHID = %s AND authorPHID = %s ORDER BY id DESC LIMIT 1', $template->getTableName(), $object->getPHID(), $viewer->getPHID()); if ($result) { return (int)$result['version']; } else { return null; } } /* -( Responding to HTTP Parameter Requests )------------------------------ */ /** * Respond to a request for documentation on HTTP parameters. * * @param object Editable object. * @return AphrontResponse Response object. * @task http */ private function buildParametersResponse($object) { $controller = $this->getController(); $viewer = $this->getViewer(); $request = $controller->getRequest(); $fields = $this->buildEditFields($object); $crumbs = $this->buildCrumbs($object); $crumbs->addTextCrumb(pht('HTTP Parameters')); $crumbs->setBorder(true); $header_text = pht( 'HTTP Parameters: %s', $this->getObjectCreateShortText()); $header = id(new PHUIHeaderView()) ->setHeader($header_text); $help_view = id(new PhabricatorApplicationEditHTTPParameterHelpView()) ->setUser($viewer) ->setFields($fields); $document = id(new PHUIDocumentView()) ->setUser($viewer) ->setHeader($header) ->appendChild($help_view); return $controller->newPage() ->setTitle(pht('HTTP Parameters')) ->setCrumbs($crumbs) ->appendChild($document); } private function buildError($object, $title, $body) { $cancel_uri = $this->getObjectCreateCancelURI($object); $dialog = $this->getController() ->newDialog() ->addCancelButton($cancel_uri); if ($title !== null) { $dialog->setTitle($title); } if ($body !== null) { $dialog->appendParagraph($body); } return $dialog; } private function buildNoDefaultResponse($object) { return $this->buildError( $object, pht('No Default Create Forms'), pht( 'This application is not configured with any forms for creating '. 'objects that are visible to you and enabled.')); } private function buildNoCreateResponse($object) { return $this->buildError( $object, pht('No Create Permission'), pht('You do not have permission to create these objects.')); } private function buildNoManageResponse($object) { return $this->buildError( $object, pht('No Manage Permission'), pht( 'You do not have permission to configure forms for this '. 'application.')); } private function buildNoEditResponse($object) { return $this->buildError( $object, pht('No Edit Forms'), pht( 'You do not have access to any forms which are enabled and marked '. 'as edit forms.')); } private function buildNotEditFormRespose($object, $config) { return $this->buildError( $object, pht('Not an Edit Form'), pht( 'This form ("%s") is not marked as an edit form, so '. 'it can not be used to edit objects.', $config->getName())); } private function buildDisabledFormResponse($object, $config) { return $this->buildError( $object, pht('Form Disabled'), pht( 'This form ("%s") has been disabled, so it can not be used.', $config->getName())); } private function buildLockedObjectResponse($object) { $dialog = $this->buildError($object, null, null); $viewer = $this->getViewer(); $lock = PhabricatorEditEngineLock::newForObject($viewer, $object); return $lock->willBlockUserInteractionWithDialog($dialog); } private function buildCommentResponse($object) { $viewer = $this->getViewer(); if ($this->getIsCreate()) { return new Aphront404Response(); } $controller = $this->getController(); $request = $controller->getRequest(); // NOTE: We handle hisec inside the transaction editor with "Sign With MFA" // comment actions. if (!$request->isFormOrHisecPost()) { return new Aphront400Response(); } $can_interact = PhabricatorPolicyFilter::canInteract($viewer, $object); if (!$can_interact) { return $this->buildLockedObjectResponse($object); } $config = $this->loadDefaultEditConfiguration($object); if (!$config) { return new Aphront404Response(); } $fields = $this->buildEditFields($object); $is_preview = $request->isPreviewRequest(); $view_uri = $this->getEffectiveObjectViewURI($object); $template = $object->getApplicationTransactionTemplate(); $comment_template = $template->getApplicationTransactionCommentObject(); $comment_text = $request->getStr('comment'); $actions = $request->getStr('editengine.actions'); if ($actions) { $actions = phutil_json_decode($actions); } if ($is_preview) { $version_key = PhabricatorVersionedDraft::KEY_VERSION; $request_version = $request->getInt($version_key); $current_version = $this->loadDraftVersion($object); if ($request_version >= $current_version) { $draft = PhabricatorVersionedDraft::loadOrCreateDraft( $object->getPHID(), $viewer->getPHID(), $current_version); $is_empty = (!strlen($comment_text) && !$actions); $draft ->setProperty('comment', $comment_text) ->setProperty('actions', $actions) ->save(); $draft_engine = $this->newDraftEngine($object); if ($draft_engine) { $draft_engine ->setVersionedDraft($draft) ->synchronize(); } } } $xactions = array(); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $object, PhabricatorPolicyCapability::CAN_EDIT); if ($actions) { $action_map = array(); foreach ($actions as $action) { $type = idx($action, 'type'); if (!$type) { continue; } if (empty($fields[$type])) { continue; } $action_map[$type] = $action; } foreach ($action_map as $type => $action) { $field = $fields[$type]; if (!$field->shouldGenerateTransactionsFromComment()) { continue; } // If you don't have edit permission on the object, you're limited in // which actions you can take via the comment form. Most actions // need edit permission, but some actions (like "Accept Revision") // can be applied by anyone with view permission. if (!$can_edit) { if (!$field->getCanApplyWithoutEditCapability()) { // We know the user doesn't have the capability, so this will // raise a policy exception. PhabricatorPolicyFilter::requireCapability( $viewer, $object, PhabricatorPolicyCapability::CAN_EDIT); } } if (array_key_exists('initialValue', $action)) { $field->setInitialValue($action['initialValue']); } $field->readValueFromComment(idx($action, 'value')); $type_xactions = $field->generateTransactions( clone $template, array( 'value' => $field->getValueForTransaction(), )); foreach ($type_xactions as $type_xaction) { $xactions[] = $type_xaction; } } } $auto_xactions = $this->newAutomaticCommentTransactions($object); foreach ($auto_xactions as $xaction) { $xactions[] = $xaction; } if (strlen($comment_text) || !$xactions) { $xactions[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->attachComment( id(clone $comment_template) ->setContent($comment_text)); } $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContinueOnNoEffect($request->isContinueRequest()) ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->setCancelURI($view_uri) ->setRaiseWarnings(!$request->getBool('editEngine.warnings')) ->setIsPreview($is_preview); try { $xactions = $editor->applyTransactions($object, $xactions); } catch (PhabricatorApplicationTransactionValidationException $ex) { return id(new PhabricatorApplicationTransactionValidationResponse()) ->setCancelURI($view_uri) ->setException($ex); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { return id(new PhabricatorApplicationTransactionNoEffectResponse()) ->setCancelURI($view_uri) ->setException($ex); } catch (PhabricatorApplicationTransactionWarningException $ex) { return id(new PhabricatorApplicationTransactionWarningResponse()) ->setObject($object) ->setCancelURI($view_uri) ->setException($ex); } if (!$is_preview) { PhabricatorVersionedDraft::purgeDrafts( $object->getPHID(), $viewer->getPHID()); $draft_engine = $this->newDraftEngine($object); if ($draft_engine) { $draft_engine ->setVersionedDraft(null) ->synchronize(); } } if ($request->isAjax() && $is_preview) { $preview_content = $this->newCommentPreviewContent($object, $xactions); $raw_view_data = $request->getStr('viewData'); try { $view_data = phutil_json_decode($raw_view_data); } catch (Exception $ex) { $view_data = array(); } return id(new PhabricatorApplicationTransactionResponse()) ->setObject($object) ->setViewer($viewer) ->setTransactions($xactions) ->setIsPreview($is_preview) ->setViewData($view_data) ->setPreviewContent($preview_content); } else { return id(new AphrontRedirectResponse()) ->setURI($view_uri); } } protected function newDraftEngine($object) { $viewer = $this->getViewer(); if ($object instanceof PhabricatorDraftInterface) { $engine = $object->newDraftEngine(); } else { $engine = new PhabricatorBuiltinDraftEngine(); } return $engine ->setObject($object) ->setViewer($viewer); } /* -( Conduit )------------------------------------------------------------ */ /** * Respond to a Conduit edit request. * * This method accepts a list of transactions to apply to an object, and * either edits an existing object or creates a new one. * * @task conduit */ final public function buildConduitResponse(ConduitAPIRequest $request) { $viewer = $this->getViewer(); $config = $this->loadDefaultConfiguration(); if (!$config) { throw new Exception( pht( 'Unable to load configuration for this EditEngine ("%s").', get_class($this))); } $raw_xactions = $this->getRawConduitTransactions($request); $identifier = $request->getValue('objectIdentifier'); if ($identifier) { $this->setIsCreate(false); // After T13186, each transaction can individually weaken or replace the // capabilities required to apply it, so we no longer need CAN_EDIT to // attempt to apply transactions to objects. In practice, almost all // transactions require CAN_EDIT so we won't get very far if we don't // have it. $capabilities = array( PhabricatorPolicyCapability::CAN_VIEW, ); $object = $this->newObjectFromIdentifier( $identifier, $capabilities); } else { $this->requireCreateCapability(); $this->setIsCreate(true); $object = $this->newEditableObjectFromConduit($raw_xactions); } $this->validateObject($object); $fields = $this->buildEditFields($object); $types = $this->getConduitEditTypesFromFields($fields); $template = $object->getApplicationTransactionTemplate(); $xactions = $this->getConduitTransactions( $request, $raw_xactions, $types, $template); $editor = $object->getApplicationTransactionEditor() ->setActor($viewer) ->setContentSource($request->newContentSource()) ->setContinueOnNoEffect(true); if (!$this->getIsCreate()) { $editor->setContinueOnMissingFields(true); } $xactions = $editor->applyTransactions($object, $xactions); $xactions_struct = array(); foreach ($xactions as $xaction) { $xactions_struct[] = array( 'phid' => $xaction->getPHID(), ); } return array( 'object' => array( 'id' => (int)$object->getID(), 'phid' => $object->getPHID(), ), 'transactions' => $xactions_struct, ); } private function getRawConduitTransactions(ConduitAPIRequest $request) { $transactions_key = 'transactions'; $xactions = $request->getValue($transactions_key); if (!is_array($xactions)) { throw new Exception( pht( 'Parameter "%s" is not a list of transactions.', $transactions_key)); } foreach ($xactions as $key => $xaction) { if (!is_array($xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is not a dictionary.', $transactions_key, $key)); } if (!array_key_exists('type', $xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is missing a "type" field. Each '. 'transaction must have a type field.', $transactions_key, $key)); } if (!array_key_exists('value', $xaction)) { throw new Exception( pht( 'Parameter "%s" must contain a list of transaction descriptions, '. 'but item with key "%s" is missing a "value" field. Each '. 'transaction must have a value field.', $transactions_key, $key)); } } return $xactions; } /** * Generate transactions which can be applied from edit actions in a Conduit * request. * * @param ConduitAPIRequest The request. * @param list Raw conduit transactions. * @param list Supported edit types. * @param PhabricatorApplicationTransaction Template transaction. * @return list Generated transactions. * @task conduit */ private function getConduitTransactions( ConduitAPIRequest $request, array $xactions, array $types, PhabricatorApplicationTransaction $template) { $viewer = $request->getUser(); $results = array(); foreach ($xactions as $key => $xaction) { $type = $xaction['type']; if (empty($types[$type])) { throw new Exception( pht( 'Transaction with key "%s" has invalid type "%s". This type is '. 'not recognized. Valid types are: %s.', $key, $type, implode(', ', array_keys($types)))); } } if ($this->getIsCreate()) { $results[] = id(clone $template) ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); } $is_strict = $request->getIsStrictlyTyped(); foreach ($xactions as $xaction) { $type = $types[$xaction['type']]; // Let the parameter type interpret the value. This allows you to // use usernames in list fields, for example. $parameter_type = $type->getConduitParameterType(); $parameter_type->setViewer($viewer); try { $value = $xaction['value']; $value = $parameter_type->getValue($xaction, 'value', $is_strict); $value = $type->getTransactionValueFromConduit($value); $xaction['value'] = $value; } catch (Exception $ex) { throw new PhutilProxyException( pht( 'Exception when processing transaction of type "%s": %s', $xaction['type'], $ex->getMessage()), $ex); } $type_xactions = $type->generateTransactions( clone $template, $xaction); foreach ($type_xactions as $type_xaction) { $results[] = $type_xaction; } } return $results; } /** * @return map * @task conduit */ private function getConduitEditTypesFromFields(array $fields) { $types = array(); foreach ($fields as $field) { $field_types = $field->getConduitEditTypes(); if ($field_types === null) { continue; } foreach ($field_types as $field_type) { $types[$field_type->getEditType()] = $field_type; } } return $types; } public function getConduitEditTypes() { $config = $this->loadDefaultConfiguration(); if (!$config) { return array(); } $object = $this->newEditableObjectForDocumentation(); $fields = $this->buildEditFields($object); return $this->getConduitEditTypesFromFields($fields); } final public static function getAllEditEngines() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getEngineKey') ->execute(); } final public static function getByKey(PhabricatorUser $viewer, $key) { return id(new PhabricatorEditEngineQuery()) ->setViewer($viewer) ->withEngineKeys(array($key)) ->executeOne(); } public function getIcon() { $application = $this->getApplication(); return $application->getIcon(); } private function loadUsableConfigurationsForCreate() { $viewer = $this->getViewer(); $configs = id(new PhabricatorEditEngineConfigurationQuery()) ->setViewer($viewer) ->withEngineKeys(array($this->getEngineKey())) ->withIsDefault(true) ->withIsDisabled(false) ->execute(); $configs = msort($configs, 'getCreateSortKey'); // Attach this specific engine to configurations we load so they can access // any runtime configuration. For example, this allows us to generate the // correct "Create Form" buttons when editing forms, see T12301. foreach ($configs as $config) { $config->attachEngine($this); } return $configs; } protected function getValidationExceptionShortMessage( PhabricatorApplicationTransactionValidationException $ex, PhabricatorEditField $field) { $xaction_type = $field->getTransactionType(); if ($xaction_type === null) { return null; } return $ex->getShortMessage($xaction_type); } protected function getCreateNewObjectPolicy() { return PhabricatorPolicies::POLICY_USER; } private function requireCreateCapability() { PhabricatorPolicyFilter::requireCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } private function hasCreateCapability() { return PhabricatorPolicyFilter::hasCapability( $this->getViewer(), $this, PhabricatorPolicyCapability::CAN_EDIT); } public function isCommentAction() { return ($this->getEditAction() == 'comment'); } public function getEditAction() { $controller = $this->getController(); $request = $controller->getRequest(); return $request->getURIData('editAction'); } protected function newCommentActionGroups() { return array(); } protected function newAutomaticCommentTransactions($object) { return array(); } protected function newCommentPreviewContent($object, array $xactions) { return null; } /* -( Form Pages )--------------------------------------------------------- */ public function getSelectedPage() { return $this->page; } private function selectPage($object, $page_key) { $pages = $this->getPages($object); if (empty($pages[$page_key])) { return null; } $this->page = $pages[$page_key]; return $this->page; } protected function newPages($object) { return array(); } protected function getPages($object) { if ($this->pages === null) { $pages = $this->newPages($object); assert_instances_of($pages, 'PhabricatorEditPage'); $pages = mpull($pages, null, 'getKey'); $this->pages = $pages; } return $this->pages; } private function applyPageToFields($object, array $fields) { $pages = $this->getPages($object); if (!$pages) { return $fields; } if (!$this->getSelectedPage()) { return $fields; } $page_picks = array(); $default_key = head($pages)->getKey(); foreach ($pages as $page_key => $page) { foreach ($page->getFieldKeys() as $field_key) { $page_picks[$field_key] = $page_key; } if ($page->getIsDefault()) { $default_key = $page_key; } } $page_map = array_fill_keys(array_keys($pages), array()); foreach ($fields as $field_key => $field) { if (isset($page_picks[$field_key])) { $page_map[$page_picks[$field_key]][$field_key] = $field; continue; } // TODO: Maybe let the field pick a page to associate itself with so // extensions can force themselves onto a particular page? $page_map[$default_key][$field_key] = $field; } $page = $this->getSelectedPage(); if (!$page) { $page = head($pages); } $selected_key = $page->getKey(); return $page_map[$selected_key]; } protected function willApplyTransactions($object, array $xactions) { return $xactions; } protected function didApplyTransactions($object, array $xactions) { return; } /* -( Bulk Edits )--------------------------------------------------------- */ final public function newBulkEditGroupMap() { $groups = $this->newBulkEditGroups(); $map = array(); foreach ($groups as $group) { $key = $group->getKey(); if (isset($map[$key])) { throw new Exception( pht( 'Two bulk edit groups have the same key ("%s"). Each bulk edit '. 'group must have a unique key.', $key)); } $map[$key] = $group; } if ($this->isEngineExtensible()) { $extensions = PhabricatorEditEngineExtension::getAllEnabledExtensions(); } else { $extensions = array(); } foreach ($extensions as $extension) { $extension_groups = $extension->newBulkEditGroups($this); foreach ($extension_groups as $group) { $key = $group->getKey(); if (isset($map[$key])) { throw new Exception( pht( 'Extension "%s" defines a bulk edit group with the same key '. '("%s") as the main editor or another extension. Each bulk '. - 'edit group must have a unique key.')); + 'edit group must have a unique key.', + get_class($extension), + $key)); } $map[$key] = $group; } } return $map; } protected function newBulkEditGroups() { return array( id(new PhabricatorBulkEditGroup()) ->setKey('default') ->setLabel(pht('Primary Fields')), id(new PhabricatorBulkEditGroup()) ->setKey('extension') ->setLabel(pht('Support Applications')), ); } final public function newBulkEditMap() { $viewer = $this->getViewer(); $config = $this->loadDefaultConfiguration(); if (!$config) { throw new Exception( pht('No default edit engine configuration for bulk edit.')); } $object = $this->newEditableObject(); $fields = $this->buildEditFields($object); $groups = $this->newBulkEditGroupMap(); $edit_types = $this->getBulkEditTypesFromFields($fields); $map = array(); foreach ($edit_types as $key => $type) { $bulk_type = $type->getBulkParameterType(); if ($bulk_type === null) { continue; } $bulk_type->setViewer($viewer); $bulk_label = $type->getBulkEditLabel(); if ($bulk_label === null) { continue; } $group_key = $type->getBulkEditGroupKey(); if (!$group_key) { $group_key = 'default'; } if (!isset($groups[$group_key])) { throw new Exception( pht( 'Field "%s" has a bulk edit group key ("%s") with no '. 'corresponding bulk edit group.', $key, $group_key)); } $map[] = array( 'label' => $bulk_label, 'xaction' => $key, 'group' => $group_key, 'control' => array( 'type' => $bulk_type->getPHUIXControlType(), 'spec' => (object)$bulk_type->getPHUIXControlSpecification(), ), ); } return $map; } final public function newRawBulkTransactions(array $xactions) { $config = $this->loadDefaultConfiguration(); if (!$config) { throw new Exception( pht('No default edit engine configuration for bulk edit.')); } $object = $this->newEditableObject(); $fields = $this->buildEditFields($object); $edit_types = $this->getBulkEditTypesFromFields($fields); $template = $object->getApplicationTransactionTemplate(); $raw_xactions = array(); foreach ($xactions as $key => $xaction) { PhutilTypeSpec::checkMap( $xaction, array( 'type' => 'string', 'value' => 'optional wild', )); $type = $xaction['type']; if (!isset($edit_types[$type])) { throw new Exception( pht( 'Unsupported bulk edit type "%s".', $type)); } $edit_type = $edit_types[$type]; // Replace the edit type with the underlying transaction type. Usually // these are 1:1 and the transaction type just has more internal noise, // but it's possible that this isn't the case. $xaction['type'] = $edit_type->getTransactionType(); $value = $xaction['value']; $value = $edit_type->getTransactionValueFromBulkEdit($value); $xaction['value'] = $value; $xaction_objects = $edit_type->generateTransactions( clone $template, $xaction); foreach ($xaction_objects as $xaction_object) { $raw_xaction = array( 'type' => $xaction_object->getTransactionType(), 'metadata' => $xaction_object->getMetadata(), 'new' => $xaction_object->getNewValue(), ); if ($xaction_object->hasOldValue()) { $raw_xaction['old'] = $xaction_object->getOldValue(); } if ($xaction_object->hasComment()) { $comment = $xaction_object->getComment(); $raw_xaction['comment'] = $comment->getContent(); } $raw_xactions[] = $raw_xaction; } } return $raw_xactions; } private function getBulkEditTypesFromFields(array $fields) { $types = array(); foreach ($fields as $field) { $field_types = $field->getBulkEditTypes(); if ($field_types === null) { continue; } foreach ($field_types as $field_type) { $types[$field_type->getEditType()] = $field_type; } } return $types; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getPHID() { return get_class($this); } public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::getMostOpenPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return $this->getCreateNewObjectPolicy(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php index f471fcd92f..d177595a2b 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php @@ -1,316 +1,317 @@ key = $key; return $this; } public function getKey() { return $this->key; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function getIcon() { return $this->icon; } public function setTagText($text) { $this->tagText = $text; return $this; } public function getTagText() { return $this->tagText; } public function setColor($color) { $this->color = $color; return $this; } public function getColor() { return $this->color; } public function setChildSubtypes(array $child_subtypes) { $this->childSubtypes = $child_subtypes; return $this; } public function getChildSubtypes() { return $this->childSubtypes; } public function setChildFormIdentifiers(array $child_identifiers) { $this->childIdentifiers = $child_identifiers; return $this; } public function getChildFormIdentifiers() { return $this->childIdentifiers; } public function setMutations($mutations) { $this->mutations = $mutations; return $this; } public function getMutations() { return $this->mutations; } public function hasTagView() { return (bool)strlen($this->getTagText()); } public function newTagView() { $view = id(new PHUITagView()) ->setType(PHUITagView::TYPE_OUTLINE) ->setName($this->getTagText()); $color = $this->getColor(); if ($color) { $view->setColor($color); } return $view; } public function setSubtypeFieldConfiguration( $subtype_key, array $configuration) { $this->fieldConfiguration[$subtype_key] = $configuration; return $this; } public function getSubtypeFieldConfiguration($subtype_key) { return idx($this->fieldConfiguration, $subtype_key); } public static function validateSubtypeKey($subtype) { if (strlen($subtype) > 64) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys must be no longer than '. '64 bytes.', $subtype)); } if (strlen($subtype) < 3) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys must have a minimum '. 'length of 3 bytes.', $subtype)); } if (!preg_match('/^[a-z]+\z/', $subtype)) { throw new Exception( pht( 'Subtype "%s" is not valid: subtype keys may only contain '. 'lowercase latin letters ("a" through "z").', $subtype)); } } public static function validateConfiguration($config) { if (!is_array($config)) { throw new Exception( pht( 'Subtype configuration is invalid: it must be a list of subtype '. 'specifications.')); } $map = array(); foreach ($config as $value) { PhutilTypeSpec::checkMap( $value, array( 'key' => 'string', 'name' => 'string', 'tag' => 'optional string', 'color' => 'optional string', 'icon' => 'optional string', 'children' => 'optional map', 'fields' => 'optional map', 'mutations' => 'optional list', )); $key = $value['key']; self::validateSubtypeKey($key); if (isset($map[$key])) { throw new Exception( pht( 'Subtype configuration is invalid: two subtypes use the same '. 'key ("%s"). Each subtype must have a unique key.', $key)); } $map[$key] = true; $name = $value['name']; if (!strlen($name)) { throw new Exception( pht( 'Subtype configuration is invalid: subtype with key "%s" has '. 'no name. Subtypes must have a name.', $key)); } $children = idx($value, 'children'); if ($children) { PhutilTypeSpec::checkMap( $children, array( 'subtypes' => 'optional list', 'forms' => 'optional list', )); $child_subtypes = idx($children, 'subtypes'); $child_forms = idx($children, 'forms'); if ($child_subtypes && $child_forms) { throw new Exception( pht( 'Subtype configuration is invalid: subtype with key "%s" '. 'specifies both child subtypes and child forms. Specify one '. - 'or the other, but not both.')); + 'or the other, but not both.', + $key)); } } $fields = idx($value, 'fields'); if ($fields) { foreach ($fields as $field_key => $configuration) { PhutilTypeSpec::checkMap( $configuration, array( 'disabled' => 'optional bool', 'name' => 'optional string', )); } } } if (!isset($map[self::SUBTYPE_DEFAULT])) { throw new Exception( pht( 'Subtype configuration is invalid: there is no subtype defined '. 'with key "%s". This subtype is required and must be defined.', self::SUBTYPE_DEFAULT)); } foreach ($config as $value) { $key = idx($value, 'key'); $mutations = idx($value, 'mutations'); if (!$mutations) { continue; } foreach ($mutations as $mutation) { if (!isset($map[$mutation])) { throw new Exception( pht( 'Subtype configuration is invalid: subtype with key "%s" '. 'specifies that it can mutate into subtype "%s", but that is '. 'not a valid subtype.', $key, $mutation)); } } } } public static function newSubtypeMap(array $config) { $map = array(); foreach ($config as $entry) { $key = $entry['key']; $name = $entry['name']; $tag_text = idx($entry, 'tag'); if ($tag_text === null) { if ($key != self::SUBTYPE_DEFAULT) { $tag_text = phutil_utf8_strtoupper($name); } } $color = idx($entry, 'color', 'blue'); $icon = idx($entry, 'icon', 'fa-drivers-license-o'); $subtype = id(new self()) ->setKey($key) ->setName($name) ->setTagText($tag_text) ->setIcon($icon); if ($color) { $subtype->setColor($color); } $children = idx($entry, 'children', array()); $child_subtypes = idx($children, 'subtypes'); $child_forms = idx($children, 'forms'); if ($child_subtypes) { $subtype->setChildSubtypes($child_subtypes); } if ($child_forms) { $subtype->setChildFormIdentifiers($child_forms); } $field_configurations = idx($entry, 'fields'); if ($field_configurations) { foreach ($field_configurations as $field_key => $field_configuration) { $subtype->setSubtypeFieldConfiguration( $field_key, $field_configuration); } } $subtype->setMutations(idx($entry, 'mutations')); $map[$key] = $subtype; } return new PhabricatorEditEngineSubtypeMap($map); } public function newIconView() { return id(new PHUIIconView()) ->setIcon($this->getIcon(), $this->getColor()); } } diff --git a/src/infrastructure/cluster/PhabricatorDatabaseRefParser.php b/src/infrastructure/cluster/PhabricatorDatabaseRefParser.php index 989095c52c..db498e8a82 100644 --- a/src/infrastructure/cluster/PhabricatorDatabaseRefParser.php +++ b/src/infrastructure/cluster/PhabricatorDatabaseRefParser.php @@ -1,219 +1,221 @@ defaultPort = $default_port; return $this; } public function getDefaultPort() { return $this->defaultPort; } public function setDefaultUser($default_user) { $this->defaultUser = $default_user; return $this; } public function getDefaultUser() { return $this->defaultUser; } public function setDefaultPass($default_pass) { $this->defaultPass = $default_pass; return $this; } public function getDefaultPass() { return $this->defaultPass; } public function newRefs(array $config) { $default_port = $this->getDefaultPort(); $default_user = $this->getDefaultUser(); $default_pass = $this->getDefaultPass(); $refs = array(); $master_count = 0; foreach ($config as $key => $server) { $host = $server['host']; $port = idx($server, 'port', $default_port); $user = idx($server, 'user', $default_user); $disabled = idx($server, 'disabled', false); $pass = idx($server, 'pass'); if ($pass) { $pass = new PhutilOpaqueEnvelope($pass); } else { $pass = clone $default_pass; } $role = $server['role']; $is_master = ($role == 'master'); $use_persistent = (bool)idx($server, 'persistent', false); $ref = id(new PhabricatorDatabaseRef()) ->setHost($host) ->setPort($port) ->setUser($user) ->setPass($pass) ->setDisabled($disabled) ->setIsMaster($is_master) ->setUsePersistentConnections($use_persistent); if ($is_master) { $master_count++; } $refs[$key] = $ref; } $is_partitioned = ($master_count > 1); if ($is_partitioned) { $default_ref = null; $partition_map = array(); foreach ($refs as $key => $ref) { if (!$ref->getIsMaster()) { continue; } $server = $config[$key]; $partition = idx($server, 'partition'); if (!is_array($partition)) { throw new Exception( pht( 'Phabricator is configured with multiple master databases, '. 'but master "%s" is missing a "partition" configuration key to '. 'define application partitioning.', $ref->getRefKey())); } $application_map = array(); foreach ($partition as $application) { if ($application === 'default') { if ($default_ref) { throw new Exception( pht( 'Multiple masters (databases "%s" and "%s") specify that '. 'they are the "default" partition. Only one master may be '. 'the default.', $ref->getRefKey(), $default_ref->getRefKey())); } else { $default_ref = $ref; $ref->setIsDefaultPartition(true); } } else if (isset($partition_map[$application])) { throw new Exception( pht( 'Multiple masters (databases "%s" and "%s") specify that '. 'they are the partition for application "%s". Each '. 'application may be allocated to only one partition.', $partition_map[$application]->getRefKey(), $ref->getRefKey(), $application)); } else { // TODO: We should check that the application is valid, to // prevent typos in application names. However, we do not // currently have an efficient way to enumerate all of the valid // application database names. $partition_map[$application] = $ref; $application_map[$application] = $application; } } $ref->setApplicationMap($application_map); } } else { // If we only have one master, make it the default. foreach ($refs as $ref) { if ($ref->getIsMaster()) { $ref->setIsDefaultPartition(true); } } } $ref_map = array(); $master_keys = array(); foreach ($refs as $ref) { $ref_key = $ref->getRefKey(); if (isset($ref_map[$ref_key])) { throw new Exception( pht( 'Multiple configured databases have the same internal '. 'key, "%s". You may have listed a database multiple times.', $ref_key)); } else { $ref_map[$ref_key] = $ref; if ($ref->getIsMaster()) { $master_keys[] = $ref_key; } } } foreach ($refs as $key => $ref) { if ($ref->getIsMaster()) { continue; } $server = $config[$key]; $partition = idx($server, 'partition'); if ($partition !== null) { throw new Exception( pht( 'Database "%s" is configured as a replica, but specifies a '. '"partition". Only master databases may have a partition '. 'configuration. Replicas use the same configuration as the '. 'master they follow.', $ref->getRefKey())); } $master_key = idx($server, 'master'); if ($master_key === null) { if ($is_partitioned) { throw new Exception( pht( 'Database "%s" is configured as a replica, but does not '. 'specify which "master" it follows in configuration. Valid '. 'masters are: %s.', $ref->getRefKey(), implode(', ', $master_keys))); } else if ($master_keys) { $master_key = head($master_keys); } else { throw new Exception( pht( 'Database "%s" is configured as a replica, but there is no '. 'master configured.', $ref->getRefKey())); } } if (!isset($ref_map[$master_key])) { throw new Exception( pht( 'Database "%s" is configured as a replica and specifies a '. 'master ("%s"), but that master is not a valid master. Valid '. 'masters are: %s.', + $ref->getRefKey(), + $master_key, implode(', ', $master_keys))); } $master_ref = $ref_map[$master_key]; $ref->setMasterRef($ref_map[$master_key]); $master_ref->addReplicaRef($ref); } return array_values($refs); } } diff --git a/src/infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchNoneFunctionDatasource.php b/src/infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchNoneFunctionDatasource.php index 591e4fda01..7302da3181 100644 --- a/src/infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchNoneFunctionDatasource.php +++ b/src/infrastructure/customfield/datasource/PhabricatorCustomFieldApplicationSearchNoneFunctionDatasource.php @@ -1,72 +1,72 @@ array( 'name' => pht('No Value'), 'summary' => pht('Find results with no value.'), 'description' => pht( "This function includes results which have no value. Use a query ". - "like this to find results with no value:\n\n%s\n\n", + "like this to find results with no value:\n\n%s\n\n". 'If you combine this function with other constraints, results '. 'which have no value or the specified values will be returned.', '> any()'), ), ); } public function loadResults() { $results = array( $this->newNoneFunction(), ); return $this->filterResultsAgainstTokens($results); } protected function evaluateFunction($function, array $argv_list) { $results = array(); foreach ($argv_list as $argv) { $results[] = new PhabricatorQueryConstraint( PhabricatorQueryConstraint::OPERATOR_NULL, null); } return $results; } public function renderFunctionTokens($function, array $argv_list) { $results = array(); foreach ($argv_list as $argv) { $results[] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( $this->newNoneFunction()); } return $results; } private function newNoneFunction() { $name = pht('No Value'); return $this->newFunctionResult() ->setName($name.' none') ->setDisplayName($name) ->setIcon('fa-ban') ->setPHID('none()') ->setUnique(true) ->addAttribute(pht('Select results with no value.')); } }