5 * Publishing and Managing State
8 * After applying changes, the Editor queues a worker to publish mail, feed,
9 * and notifications, and to perform other background work like updating search
10 * indexes. This allows it to do this work without impacting performance for
13 * When work is moved to the daemons, the Editor state is serialized by
14 * @{method:getWorkerState}, then reloaded in a daemon process by
15 * @{method:loadWorkerState}. **This is fragile.**
17 * State is not persisted into the daemons by default, because we can not send
18 * arbitrary objects into the queue. This means the default behavior of any
19 * state properties is to reset to their defaults without warning prior to
22 * The easiest way to avoid this is to keep Editors stateless: the overwhelming
23 * majority of Editors can be written statelessly. If you need to maintain
24 * state, you can either:
26 * - not require state to exist during publishing; or
27 * - pass state to the daemons by implementing @{method:getCustomWorkerState}
28 * and @{method:loadCustomWorkerState}.
30 * This architecture isn't ideal, and we may eventually split this class into
31 * "Editor" and "Publisher" parts to make it more robust. See T6367 for some
32 * discussion and context.
34 * @task mail Sending Mail
35 * @task feed Publishing Feed Stories
36 * @task search Search Index
37 * @task files Integration with Files
38 * @task workers Managing Workers
40 abstract class PhabricatorApplicationTransactionEditor
41 extends PhabricatorEditor
{
43 private $contentSource;
48 private $mentionedPHIDs;
49 private $continueOnNoEffect;
50 private $continueOnMissingFields;
51 private $raiseWarnings;
52 private $parentMessageID;
53 private $heraldAdapter;
54 private $heraldTranscript;
56 private $unmentionablePHIDMap = array();
57 private $transactionGroupID;
58 private $applicationEmail;
61 private $isHeraldEditor;
62 private $isInverseEdgeEditor;
63 private $actingAsPHID;
65 private $heraldEmailPHIDs = array();
66 private $heraldForcedEmailPHIDs = array();
67 private $heraldHeader;
68 private $mailToPHIDs = array();
69 private $mailCCPHIDs = array();
70 private $feedNotifyPHIDs = array();
71 private $feedRelatedPHIDs = array();
72 private $feedShouldPublish = false;
73 private $mailShouldSend = false;
74 private $modularTypes;
76 private $mustEncrypt = array();
77 private $stampTemplates = array();
78 private $mailStamps = array();
79 private $oldTo = array();
80 private $oldCC = array();
81 private $mailRemovedPHIDs = array();
82 private $mailUnexpandablePHIDs = array();
83 private $mailMutedPHIDs = array();
84 private $webhookMap = array();
86 private $transactionQueue = array();
87 private $sendHistory = false;
88 private $shouldRequireMFA = false;
89 private $hasRequiredMFA = false;
94 private $parentEditor;
95 private $subEditors = array();
96 private $publishableObject;
97 private $publishableTransactions;
99 const STORAGE_ENCODING_BINARY
= 'binary';
102 * Get the class name for the application this editor is a part of.
104 * Uninstalling the application will disable the editor.
106 * @return string Editor's application class name.
108 abstract public function getEditorApplicationClass();
112 * Get a description of the objects this editor edits, like "Differential
115 * @return string Human readable description of edited objects.
117 abstract public function getEditorObjectsDescription();
120 public function setActingAsPHID($acting_as_phid) {
121 $this->actingAsPHID
= $acting_as_phid;
125 public function getActingAsPHID() {
126 if ($this->actingAsPHID
) {
127 return $this->actingAsPHID
;
129 return $this->getActor()->getPHID();
134 * When the editor tries to apply transactions that have no effect, should
135 * it raise an exception (default) or drop them and continue?
137 * Generally, you will set this flag for edits coming from "Edit" interfaces,
138 * and leave it cleared for edits coming from "Comment" interfaces, so the
139 * user will get a useful error if they try to submit a comment that does
140 * nothing (e.g., empty comment with a status change that has already been
141 * performed by another user).
143 * @param bool True to drop transactions without effect and continue.
146 public function setContinueOnNoEffect($continue) {
147 $this->continueOnNoEffect
= $continue;
151 public function getContinueOnNoEffect() {
152 return $this->continueOnNoEffect
;
157 * When the editor tries to apply transactions which don't populate all of
158 * an object's required fields, should it raise an exception (default) or
159 * drop them and continue?
161 * For example, if a user adds a new required custom field (like "Severity")
162 * to a task, all existing tasks won't have it populated. When users
163 * manually edit existing tasks, it's usually desirable to have them provide
164 * a severity. However, other operations (like batch editing just the
165 * owner of a task) will fail by default.
167 * By setting this flag for edit operations which apply to specific fields
168 * (like the priority, batch, and merge editors in Maniphest), these
169 * operations can continue to function even if an object is outdated.
171 * @param bool True to continue when transactions don't completely satisfy
172 * all required fields.
175 public function setContinueOnMissingFields($continue_on_missing_fields) {
176 $this->continueOnMissingFields
= $continue_on_missing_fields;
180 public function getContinueOnMissingFields() {
181 return $this->continueOnMissingFields
;
186 * Not strictly necessary, but reply handlers ideally set this value to
187 * make email threading work better.
189 public function setParentMessageID($parent_message_id) {
190 $this->parentMessageID
= $parent_message_id;
193 public function getParentMessageID() {
194 return $this->parentMessageID
;
197 public function getIsNewObject() {
198 return $this->isNewObject
;
201 public function getMentionedPHIDs() {
202 return $this->mentionedPHIDs
;
205 public function setIsPreview($is_preview) {
206 $this->isPreview
= $is_preview;
210 public function getIsPreview() {
211 return $this->isPreview
;
214 public function setIsSilent($silent) {
215 $this->silent
= $silent;
219 public function getIsSilent() {
220 return $this->silent
;
223 public function getMustEncrypt() {
224 return $this->mustEncrypt
;
227 public function getHeraldRuleMonograms() {
228 // Convert the stored "<123>, <456>" string into a list: "H123", "H456".
229 $list = $this->heraldHeader
;
230 $list = preg_split('/[, ]+/', $list);
232 foreach ($list as $key => $item) {
233 $item = trim($item, '<>');
235 if (!is_numeric($item)) {
240 $list[$key] = 'H'.$item;
246 public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
247 $this->isInverseEdgeEditor
= $is_inverse_edge_editor;
251 public function getIsInverseEdgeEditor() {
252 return $this->isInverseEdgeEditor
;
255 public function setIsHeraldEditor($is_herald_editor) {
256 $this->isHeraldEditor
= $is_herald_editor;
260 public function getIsHeraldEditor() {
261 return $this->isHeraldEditor
;
264 public function addUnmentionablePHIDs(array $phids) {
265 foreach ($phids as $phid) {
266 $this->unmentionablePHIDMap
[$phid] = true;
271 private function getUnmentionablePHIDMap() {
272 return $this->unmentionablePHIDMap
;
275 protected function shouldEnableMentions(
276 PhabricatorLiskDAO
$object,
281 public function setApplicationEmail(
282 PhabricatorMetaMTAApplicationEmail
$email) {
283 $this->applicationEmail
= $email;
287 public function getApplicationEmail() {
288 return $this->applicationEmail
;
291 public function setRaiseWarnings($raise_warnings) {
292 $this->raiseWarnings
= $raise_warnings;
296 public function getRaiseWarnings() {
297 return $this->raiseWarnings
;
300 public function setShouldRequireMFA($should_require_mfa) {
301 if ($this->hasRequiredMFA
) {
304 'Call to setShouldRequireMFA() is too late: this Editor has already '.
305 'checked for MFA requirements.'));
308 $this->shouldRequireMFA
= $should_require_mfa;
312 public function getShouldRequireMFA() {
313 return $this->shouldRequireMFA
;
316 public function getTransactionTypesForObject($object) {
317 $old = $this->object;
319 $this->object = $object;
320 $result = $this->getTransactionTypes();
321 $this->object = $old;
322 } catch (Exception
$ex) {
323 $this->object = $old;
329 public function getTransactionTypes() {
332 $types[] = PhabricatorTransactions
::TYPE_CREATE
;
333 $types[] = PhabricatorTransactions
::TYPE_HISTORY
;
335 if ($this->object instanceof PhabricatorEditEngineSubtypeInterface
) {
336 $types[] = PhabricatorTransactions
::TYPE_SUBTYPE
;
339 if ($this->object instanceof PhabricatorSubscribableInterface
) {
340 $types[] = PhabricatorTransactions
::TYPE_SUBSCRIBERS
;
343 if ($this->object instanceof PhabricatorCustomFieldInterface
) {
344 $types[] = PhabricatorTransactions
::TYPE_CUSTOMFIELD
;
347 if ($this->object instanceof PhabricatorTokenReceiverInterface
) {
348 $types[] = PhabricatorTransactions
::TYPE_TOKEN
;
351 if ($this->object instanceof PhabricatorProjectInterface ||
352 $this->object instanceof PhabricatorMentionableInterface
) {
353 $types[] = PhabricatorTransactions
::TYPE_EDGE
;
356 if ($this->object instanceof PhabricatorSpacesInterface
) {
357 $types[] = PhabricatorTransactions
::TYPE_SPACE
;
360 $types[] = PhabricatorTransactions
::TYPE_MFA
;
362 $template = $this->object->getApplicationTransactionTemplate();
363 if ($template instanceof PhabricatorModularTransaction
) {
364 $xtypes = $template->newModularTransactionTypes();
365 foreach ($xtypes as $xtype) {
366 $types[] = $xtype->getTransactionTypeConstant();
371 $comment = $template->getApplicationTransactionCommentObject();
373 $types[] = PhabricatorTransactions
::TYPE_COMMENT
;
380 private function adjustTransactionValues(
381 PhabricatorLiskDAO
$object,
382 PhabricatorApplicationTransaction
$xaction) {
384 if ($xaction->shouldGenerateOldValue()) {
385 $old = $this->getTransactionOldValue($object, $xaction);
386 $xaction->setOldValue($old);
389 $new = $this->getTransactionNewValue($object, $xaction);
390 $xaction->setNewValue($new);
393 private function getTransactionOldValue(
394 PhabricatorLiskDAO
$object,
395 PhabricatorApplicationTransaction
$xaction) {
397 $type = $xaction->getTransactionType();
399 $xtype = $this->getModularTransactionType($type);
401 $xtype = clone $xtype;
402 $xtype->setStorage($xaction);
403 return $xtype->generateOldValue($object);
407 case PhabricatorTransactions
::TYPE_CREATE
:
408 case PhabricatorTransactions
::TYPE_HISTORY
:
410 case PhabricatorTransactions
::TYPE_SUBTYPE
:
411 return $object->getEditEngineSubtype();
412 case PhabricatorTransactions
::TYPE_MFA
:
414 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
415 return array_values($this->subscribers
);
416 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
417 if ($this->getIsNewObject()) {
420 return $object->getViewPolicy();
421 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
422 if ($this->getIsNewObject()) {
425 return $object->getEditPolicy();
426 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
427 if ($this->getIsNewObject()) {
430 return $object->getJoinPolicy();
431 case PhabricatorTransactions
::TYPE_SPACE
:
432 if ($this->getIsNewObject()) {
436 $space_phid = $object->getSpacePHID();
437 if ($space_phid === null) {
438 $default_space = PhabricatorSpacesNamespaceQuery
::getDefaultSpace();
439 if ($default_space) {
440 $space_phid = $default_space->getPHID();
445 case PhabricatorTransactions
::TYPE_EDGE
:
446 $edge_type = $xaction->getMetadataValue('edge:type');
450 "Edge transaction has no '%s'!",
454 // See T13082. If this is an inverse edit, the parent editor has
455 // already populated the transaction values correctly.
456 if ($this->getIsInverseEdgeEditor()) {
457 return $xaction->getOldValue();
460 $old_edges = array();
461 if ($object->getPHID()) {
462 $edge_src = $object->getPHID();
464 $old_edges = id(new PhabricatorEdgeQuery())
465 ->withSourcePHIDs(array($edge_src))
466 ->withEdgeTypes(array($edge_type))
470 $old_edges = $old_edges[$edge_src][$edge_type];
473 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
474 // NOTE: Custom fields have their old value pre-populated when they are
475 // built by PhabricatorCustomFieldList.
476 return $xaction->getOldValue();
477 case PhabricatorTransactions
::TYPE_COMMENT
:
480 return $this->getCustomTransactionOldValue($object, $xaction);
484 private function getTransactionNewValue(
485 PhabricatorLiskDAO
$object,
486 PhabricatorApplicationTransaction
$xaction) {
488 $type = $xaction->getTransactionType();
490 $xtype = $this->getModularTransactionType($type);
492 $xtype = clone $xtype;
493 $xtype->setStorage($xaction);
494 return $xtype->generateNewValue($object, $xaction->getNewValue());
498 case PhabricatorTransactions
::TYPE_CREATE
:
500 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
501 return $this->getPHIDTransactionNewValue($xaction);
502 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
503 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
504 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
505 case PhabricatorTransactions
::TYPE_TOKEN
:
506 case PhabricatorTransactions
::TYPE_INLINESTATE
:
507 case PhabricatorTransactions
::TYPE_SUBTYPE
:
508 case PhabricatorTransactions
::TYPE_HISTORY
:
509 return $xaction->getNewValue();
510 case PhabricatorTransactions
::TYPE_MFA
:
512 case PhabricatorTransactions
::TYPE_SPACE
:
513 $space_phid = $xaction->getNewValue();
514 if (!strlen($space_phid)) {
515 // If an install has no Spaces or the Spaces controls are not visible
516 // to the viewer, we might end up with the empty string here instead
517 // of a strict `null`, because some controller just used `getStr()`
518 // to read the space PHID from the request.
519 // Just make this work like callers might reasonably expect so we
520 // don't need to handle this specially in every EditController.
521 return $this->getActor()->getDefaultSpacePHID();
525 case PhabricatorTransactions
::TYPE_EDGE
:
526 // See T13082. If this is an inverse edit, the parent editor has
527 // already populated appropriate transaction values.
528 if ($this->getIsInverseEdgeEditor()) {
529 return $xaction->getNewValue();
532 $new_value = $this->getEdgeTransactionNewValue($xaction);
534 $edge_type = $xaction->getMetadataValue('edge:type');
535 $type_project = PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
;
536 if ($edge_type == $type_project) {
537 $new_value = $this->applyProjectConflictRules($new_value);
541 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
542 $field = $this->getCustomFieldForTransaction($object, $xaction);
543 return $field->getNewValueFromApplicationTransactions($xaction);
544 case PhabricatorTransactions
::TYPE_COMMENT
:
547 return $this->getCustomTransactionNewValue($object, $xaction);
551 protected function getCustomTransactionOldValue(
552 PhabricatorLiskDAO
$object,
553 PhabricatorApplicationTransaction
$xaction) {
554 throw new Exception(pht('Capability not supported!'));
557 protected function getCustomTransactionNewValue(
558 PhabricatorLiskDAO
$object,
559 PhabricatorApplicationTransaction
$xaction) {
560 throw new Exception(pht('Capability not supported!'));
563 protected function transactionHasEffect(
564 PhabricatorLiskDAO
$object,
565 PhabricatorApplicationTransaction
$xaction) {
567 switch ($xaction->getTransactionType()) {
568 case PhabricatorTransactions
::TYPE_CREATE
:
569 case PhabricatorTransactions
::TYPE_HISTORY
:
571 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
572 $field = $this->getCustomFieldForTransaction($object, $xaction);
573 return $field->getApplicationTransactionHasEffect($xaction);
574 case PhabricatorTransactions
::TYPE_EDGE
:
575 // A straight value comparison here doesn't always get the right
576 // result, because newly added edges aren't fully populated. Instead,
577 // compare the changes in a more granular way.
578 $old = $xaction->getOldValue();
579 $new = $xaction->getNewValue();
581 $old_dst = array_keys($old);
582 $new_dst = array_keys($new);
584 // NOTE: For now, we don't consider edge reordering to be a change.
585 // We have very few order-dependent edges and effectively no order
586 // oriented UI. This might change in the future.
590 if ($old_dst !== $new_dst) {
591 // We've added or removed edges, so this transaction definitely
596 // We haven't added or removed edges, but we might have changed
598 foreach ($old as $key => $old_value) {
599 $new_value = $new[$key];
600 if ($old_value['data'] !== $new_value['data']) {
608 $type = $xaction->getTransactionType();
609 $xtype = $this->getModularTransactionType($type);
611 return $xtype->getTransactionHasEffect(
613 $xaction->getOldValue(),
614 $xaction->getNewValue());
617 if ($xaction->hasComment()) {
621 return ($xaction->getOldValue() !== $xaction->getNewValue());
624 protected function shouldApplyInitialEffects(
625 PhabricatorLiskDAO
$object,
630 protected function applyInitialEffects(
631 PhabricatorLiskDAO
$object,
633 throw new PhutilMethodNotImplementedException();
636 private function applyInternalEffects(
637 PhabricatorLiskDAO
$object,
638 PhabricatorApplicationTransaction
$xaction) {
640 $type = $xaction->getTransactionType();
642 $xtype = $this->getModularTransactionType($type);
644 $xtype = clone $xtype;
645 $xtype->setStorage($xaction);
646 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
650 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
651 $field = $this->getCustomFieldForTransaction($object, $xaction);
652 return $field->applyApplicationTransactionInternalEffects($xaction);
653 case PhabricatorTransactions
::TYPE_CREATE
:
654 case PhabricatorTransactions
::TYPE_HISTORY
:
655 case PhabricatorTransactions
::TYPE_SUBTYPE
:
656 case PhabricatorTransactions
::TYPE_MFA
:
657 case PhabricatorTransactions
::TYPE_TOKEN
:
658 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
659 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
660 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
661 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
662 case PhabricatorTransactions
::TYPE_INLINESTATE
:
663 case PhabricatorTransactions
::TYPE_EDGE
:
664 case PhabricatorTransactions
::TYPE_SPACE
:
665 case PhabricatorTransactions
::TYPE_COMMENT
:
666 return $this->applyBuiltinInternalTransaction($object, $xaction);
669 return $this->applyCustomInternalTransaction($object, $xaction);
672 private function applyExternalEffects(
673 PhabricatorLiskDAO
$object,
674 PhabricatorApplicationTransaction
$xaction) {
676 $type = $xaction->getTransactionType();
678 $xtype = $this->getModularTransactionType($type);
680 $xtype = clone $xtype;
681 $xtype->setStorage($xaction);
682 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
686 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
687 $subeditor = id(new PhabricatorSubscriptionsEditor())
689 ->setActor($this->requireActor());
691 $old_map = array_fuse($xaction->getOldValue());
692 $new_map = array_fuse($xaction->getNewValue());
694 $subeditor->unsubscribe(
696 array_diff_key($old_map, $new_map)));
698 $subeditor->subscribeExplicit(
700 array_diff_key($new_map, $old_map)));
704 // for the rest of these edits, subscribers should include those just
705 // added as well as those just removed.
706 $subscribers = array_unique(array_merge(
708 $xaction->getOldValue(),
709 $xaction->getNewValue()));
710 $this->subscribers
= $subscribers;
711 return $this->applyBuiltinExternalTransaction($object, $xaction);
713 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
714 $field = $this->getCustomFieldForTransaction($object, $xaction);
715 return $field->applyApplicationTransactionExternalEffects($xaction);
716 case PhabricatorTransactions
::TYPE_CREATE
:
717 case PhabricatorTransactions
::TYPE_HISTORY
:
718 case PhabricatorTransactions
::TYPE_SUBTYPE
:
719 case PhabricatorTransactions
::TYPE_MFA
:
720 case PhabricatorTransactions
::TYPE_EDGE
:
721 case PhabricatorTransactions
::TYPE_TOKEN
:
722 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
723 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
724 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
725 case PhabricatorTransactions
::TYPE_INLINESTATE
:
726 case PhabricatorTransactions
::TYPE_SPACE
:
727 case PhabricatorTransactions
::TYPE_COMMENT
:
728 return $this->applyBuiltinExternalTransaction($object, $xaction);
731 return $this->applyCustomExternalTransaction($object, $xaction);
734 protected function applyCustomInternalTransaction(
735 PhabricatorLiskDAO
$object,
736 PhabricatorApplicationTransaction
$xaction) {
737 $type = $xaction->getTransactionType();
740 "Transaction type '%s' is missing an internal apply implementation!",
744 protected function applyCustomExternalTransaction(
745 PhabricatorLiskDAO
$object,
746 PhabricatorApplicationTransaction
$xaction) {
747 $type = $xaction->getTransactionType();
750 "Transaction type '%s' is missing an external apply implementation!",
755 * @{class:PhabricatorTransactions} provides many built-in transactions
756 * which should not require much - if any - code in specific applications.
758 * This method is a hook for the exceedingly-rare cases where you may need
759 * to do **additional** work for built-in transactions. Developers should
760 * extend this method, making sure to return the parent implementation
761 * regardless of handling any transactions.
763 * See also @{method:applyBuiltinExternalTransaction}.
765 protected function applyBuiltinInternalTransaction(
766 PhabricatorLiskDAO
$object,
767 PhabricatorApplicationTransaction
$xaction) {
769 switch ($xaction->getTransactionType()) {
770 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
771 $object->setViewPolicy($xaction->getNewValue());
773 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
774 $object->setEditPolicy($xaction->getNewValue());
776 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
777 $object->setJoinPolicy($xaction->getNewValue());
779 case PhabricatorTransactions
::TYPE_SPACE
:
780 $object->setSpacePHID($xaction->getNewValue());
782 case PhabricatorTransactions
::TYPE_SUBTYPE
:
783 $object->setEditEngineSubtype($xaction->getNewValue());
789 * See @{method::applyBuiltinInternalTransaction}.
791 protected function applyBuiltinExternalTransaction(
792 PhabricatorLiskDAO
$object,
793 PhabricatorApplicationTransaction
$xaction) {
795 switch ($xaction->getTransactionType()) {
796 case PhabricatorTransactions
::TYPE_EDGE
:
797 if ($this->getIsInverseEdgeEditor()) {
798 // If we're writing an inverse edge transaction, don't actually
799 // do anything. The initiating editor on the other side of the
800 // transaction will take care of the edge writes.
804 $old = $xaction->getOldValue();
805 $new = $xaction->getNewValue();
806 $src = $object->getPHID();
807 $const = $xaction->getMetadataValue('edge:type');
809 foreach ($new as $dst_phid => $edge) {
810 $new[$dst_phid]['src'] = $src;
813 $editor = new PhabricatorEdgeEditor();
815 foreach ($old as $dst_phid => $edge) {
816 if (!empty($new[$dst_phid])) {
817 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
821 $editor->removeEdge($src, $const, $dst_phid);
824 foreach ($new as $dst_phid => $edge) {
825 if (!empty($old[$dst_phid])) {
826 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
832 'data' => $edge['data'],
835 $editor->addEdge($src, $const, $dst_phid, $data);
840 $this->updateWorkboardColumns($object, $const, $old, $new);
842 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
843 case PhabricatorTransactions
::TYPE_SPACE
:
844 $this->scrambleFileSecrets($object);
846 case PhabricatorTransactions
::TYPE_HISTORY
:
847 $this->sendHistory
= true;
853 * Fill in a transaction's common values, like author and content source.
855 protected function populateTransaction(
856 PhabricatorLiskDAO
$object,
857 PhabricatorApplicationTransaction
$xaction) {
859 $actor = $this->getActor();
861 // TODO: This needs to be more sophisticated once we have meta-policies.
862 $xaction->setViewPolicy(PhabricatorPolicies
::POLICY_PUBLIC
);
864 if ($actor->isOmnipotent()) {
865 $xaction->setEditPolicy(PhabricatorPolicies
::POLICY_NOONE
);
867 $xaction->setEditPolicy($this->getActingAsPHID());
870 // If the transaction already has an explicit author PHID, allow it to
871 // stand. This is used by applications like Owners that hook into the
872 // post-apply change pipeline.
873 if (!$xaction->getAuthorPHID()) {
874 $xaction->setAuthorPHID($this->getActingAsPHID());
877 $xaction->setContentSource($this->getContentSource());
878 $xaction->attachViewer($actor);
879 $xaction->attachObject($object);
881 if ($object->getPHID()) {
882 $xaction->setObjectPHID($object->getPHID());
885 if ($this->getIsSilent()) {
886 $xaction->setIsSilentTransaction(true);
892 protected function didApplyInternalEffects(
893 PhabricatorLiskDAO
$object,
898 protected function applyFinalEffects(
899 PhabricatorLiskDAO
$object,
904 final protected function didCommitTransactions(
905 PhabricatorLiskDAO
$object,
908 foreach ($xactions as $xaction) {
909 $type = $xaction->getTransactionType();
911 // See T13082. When we're writing edges that imply corresponding inverse
912 // transactions, apply those inverse transactions now. We have to wait
913 // until the object we're editing (with this editor) has committed its
914 // transactions to do this. If we don't, the inverse editor may race,
915 // build a mail before we actually commit this object, and render "alice
916 // added an edge: Unknown Object".
918 if ($type === PhabricatorTransactions
::TYPE_EDGE
) {
919 // Don't do anything if we're already an inverse edge editor.
920 if ($this->getIsInverseEdgeEditor()) {
924 $edge_const = $xaction->getMetadataValue('edge:type');
925 $edge_type = PhabricatorEdgeType
::getByConstant($edge_const);
926 if ($edge_type->shouldWriteInverseTransactions()) {
927 $this->applyInverseEdgeTransactions(
930 $edge_type->getInverseEdgeConstant());
935 $xtype = $this->getModularTransactionType($type);
940 $xtype = clone $xtype;
941 $xtype->setStorage($xaction);
942 $xtype->didCommitTransaction($object, $xaction->getNewValue());
946 public function setContentSource(PhabricatorContentSource
$content_source) {
947 $this->contentSource
= $content_source;
951 public function setContentSourceFromRequest(AphrontRequest
$request) {
952 $this->setRequest($request);
953 return $this->setContentSource(
954 PhabricatorContentSource
::newFromRequest($request));
957 public function getContentSource() {
958 return $this->contentSource
;
961 public function setRequest(AphrontRequest
$request) {
962 $this->request
= $request;
966 public function getRequest() {
967 return $this->request
;
970 public function setCancelURI($cancel_uri) {
971 $this->cancelURI
= $cancel_uri;
975 public function getCancelURI() {
976 return $this->cancelURI
;
979 protected function getTransactionGroupID() {
980 if ($this->transactionGroupID
=== null) {
981 $this->transactionGroupID
= Filesystem
::readRandomCharacters(32);
984 return $this->transactionGroupID
;
987 final public function applyTransactions(
988 PhabricatorLiskDAO
$object,
991 $is_new = ($object->getID() === null);
992 $this->isNewObject
= $is_new;
994 $is_preview = $this->getIsPreview();
995 $read_locking = false;
996 $transaction_open = false;
998 // If we're attempting to apply transactions, lock and reload the object
999 // before we go anywhere. If we don't do this at the very beginning, we
1000 // may be looking at an older version of the object when we populate and
1001 // filter the transactions. See PHI1165 for an example.
1005 $this->buildOldRecipientLists($object, $xactions);
1007 $object->openTransaction();
1008 $transaction_open = true;
1010 $object->beginReadLocking();
1011 $read_locking = true;
1018 $this->object = $object;
1019 $this->xactions
= $xactions;
1021 $this->validateEditParameters($object, $xactions);
1022 $xactions = $this->newMFATransactions($object, $xactions);
1024 $actor = $this->requireActor();
1026 // NOTE: Some transaction expansion requires that the edited object be
1028 foreach ($xactions as $xaction) {
1029 $xaction->attachObject($object);
1030 $xaction->attachViewer($actor);
1033 $xactions = $this->expandTransactions($object, $xactions);
1034 $xactions = $this->expandSupportTransactions($object, $xactions);
1035 $xactions = $this->combineTransactions($xactions);
1037 foreach ($xactions as $xaction) {
1038 $xaction = $this->populateTransaction($object, $xaction);
1043 $type_map = mgroup($xactions, 'getTransactionType');
1044 foreach ($this->getTransactionTypes() as $type) {
1045 $type_xactions = idx($type_map, $type, array());
1046 $errors[] = $this->validateTransaction(
1052 $errors[] = $this->validateAllTransactions($object, $xactions);
1053 $errors[] = $this->validateTransactionsWithExtensions(
1056 $errors = array_mergev($errors);
1058 $continue_on_missing = $this->getContinueOnMissingFields();
1059 foreach ($errors as $key => $error) {
1060 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1061 unset($errors[$key]);
1066 throw new PhabricatorApplicationTransactionValidationException(
1070 if ($this->raiseWarnings
) {
1071 $warnings = array();
1072 foreach ($xactions as $xaction) {
1073 if ($this->hasWarnings($object, $xaction)) {
1074 $warnings[] = $xaction;
1078 throw new PhabricatorApplicationTransactionWarningException(
1084 foreach ($xactions as $xaction) {
1085 $this->adjustTransactionValues($object, $xaction);
1088 // Now that we've merged and combined transactions, check for required
1089 // capabilities. Note that we're doing this before filtering
1090 // transactions: if you try to apply an edit which you do not have
1091 // permission to apply, we want to give you a permissions error even
1092 // if the edit would have no effect.
1093 $this->applyCapabilityChecks($object, $xactions);
1095 $xactions = $this->filterTransactions($object, $xactions);
1098 $this->hasRequiredMFA
= true;
1099 if ($this->getShouldRequireMFA()) {
1100 $this->requireMFA($object, $xactions);
1103 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1104 if (!$transaction_open) {
1105 $object->openTransaction();
1106 $transaction_open = true;
1111 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1112 $this->applyInitialEffects($object, $xactions);
1115 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1116 // figure this out instead.
1117 $mark_as_create = false;
1118 $create_type = PhabricatorTransactions
::TYPE_CREATE
;
1119 foreach ($xactions as $xaction) {
1120 if ($xaction->getTransactionType() == $create_type) {
1121 $mark_as_create = true;
1125 if ($mark_as_create) {
1126 foreach ($xactions as $xaction) {
1127 $xaction->setIsCreateTransaction(true);
1131 $xactions = $this->sortTransactions($xactions);
1132 $file_phids = $this->extractFilePHIDs($object, $xactions);
1135 $this->loadHandles($xactions);
1139 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1141 ->setActingAsPHID($this->getActingAsPHID())
1142 ->setContentSource($this->getContentSource())
1143 ->setIsNewComment(true);
1145 if (!$transaction_open) {
1146 $object->openTransaction();
1147 $transaction_open = true;
1150 // We can technically test any object for CAN_INTERACT, but we can
1151 // run into some issues in doing so (for example, in project unit tests).
1152 // For now, only test for CAN_INTERACT if the object is explicitly a
1155 $was_locked = false;
1156 if ($object instanceof PhabricatorEditEngineLockableInterface
) {
1157 $was_locked = !PhabricatorPolicyFilter
::canInteract($actor, $object);
1160 foreach ($xactions as $xaction) {
1161 $this->applyInternalEffects($object, $xaction);
1164 $xactions = $this->didApplyInternalEffects($object, $xactions);
1168 } catch (AphrontDuplicateKeyQueryException
$ex) {
1169 // This callback has an opportunity to throw a better exception,
1170 // so execution may end here.
1171 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1176 $group_id = $this->getTransactionGroupID();
1178 foreach ($xactions as $xaction) {
1180 $is_override = $this->isLockOverrideTransaction($xaction);
1182 $xaction->setIsLockOverrideTransaction(true);
1186 $xaction->setObjectPHID($object->getPHID());
1187 $xaction->setTransactionGroupID($group_id);
1189 if ($xaction->getComment()) {
1190 $xaction->setPHID($xaction->generatePHID());
1191 $comment_editor->applyEdit($xaction, $xaction->getComment());
1194 // TODO: This is a transitional hack to let us migrate edge
1195 // transactions to a more efficient storage format. For now, we're
1196 // going to write a new slim format to the database but keep the old
1197 // bulky format on the objects so we don't have to upgrade all the
1198 // edit logic to the new format yet. See T13051.
1200 $edge_type = PhabricatorTransactions
::TYPE_EDGE
;
1201 if ($xaction->getTransactionType() == $edge_type) {
1202 $bulky_old = $xaction->getOldValue();
1203 $bulky_new = $xaction->getNewValue();
1205 $record = PhabricatorEdgeChangeRecord
::newFromTransaction($xaction);
1206 $slim_old = $record->getModernOldEdgeTransactionData();
1207 $slim_new = $record->getModernNewEdgeTransactionData();
1209 $xaction->setOldValue($slim_old);
1210 $xaction->setNewValue($slim_new);
1213 $xaction->setOldValue($bulky_old);
1214 $xaction->setNewValue($bulky_new);
1222 $this->attachFiles($object, $file_phids);
1225 foreach ($xactions as $xaction) {
1226 $this->applyExternalEffects($object, $xaction);
1229 $xactions = $this->applyFinalEffects($object, $xactions);
1231 if ($read_locking) {
1232 $object->endReadLocking();
1233 $read_locking = false;
1236 if ($transaction_open) {
1237 $object->saveTransaction();
1238 $transaction_open = false;
1241 $this->didCommitTransactions($object, $xactions);
1243 } catch (Exception
$ex) {
1244 if ($read_locking) {
1245 $object->endReadLocking();
1246 $read_locking = false;
1249 if ($transaction_open) {
1250 $object->killTransaction();
1251 $transaction_open = false;
1257 // If we need to perform cache engine updates, execute them now.
1258 id(new PhabricatorCacheEngine())
1259 ->updateObject($object);
1261 // Now that we've completely applied the core transaction set, try to apply
1262 // Herald rules. Herald rules are allowed to either take direct actions on
1263 // the database (like writing flags), or take indirect actions (like saving
1264 // some targets for CC when we generate mail a little later), or return
1265 // transactions which we'll apply normally using another Editor.
1267 // First, check if *this* is a sub-editor which is itself applying Herald
1268 // rules: if it is, stop working and return so we don't descend into
1271 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1272 // using a Herald editor to apply resulting transactions) and then send out
1273 // mail, notifications, and feed updates about everything.
1275 if ($this->getIsHeraldEditor()) {
1276 // We are the Herald editor, so stop work here and return the updated
1279 } else if ($this->getIsInverseEdgeEditor()) {
1280 // Do not run Herald if we're just recording that this object was
1281 // mentioned elsewhere. This tends to create Herald side effects which
1282 // feel arbitrary, and can really slow down edits which mention a large
1283 // number of other objects. See T13114.
1284 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1285 // We are not the Herald editor, so try to apply Herald rules.
1286 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1288 if ($herald_xactions) {
1289 $xscript_id = $this->getHeraldTranscript()->getID();
1290 foreach ($herald_xactions as $herald_xaction) {
1291 // Don't set a transcript ID if this is a transaction from another
1292 // application or source, like Owners.
1293 if ($herald_xaction->getAuthorPHID()) {
1297 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1300 // NOTE: We're acting as the omnipotent user because rules deal with
1301 // their own policy issues. We use a synthetic author PHID (the
1302 // Herald application) as the author of record, so that transactions
1303 // will render in a reasonable way ("Herald assigned this task ...").
1304 $herald_actor = PhabricatorUser
::getOmnipotentUser();
1305 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1307 // TODO: It would be nice to give transactions a more specific source
1308 // which points at the rule which generated them. You can figure this
1309 // out from transcripts, but it would be cleaner if you didn't have to.
1311 $herald_source = PhabricatorContentSource
::newForSource(
1312 PhabricatorHeraldContentSource
::SOURCECONST
);
1314 $herald_editor = $this->newEditorCopy()
1315 ->setContinueOnNoEffect(true)
1316 ->setContinueOnMissingFields(true)
1317 ->setIsHeraldEditor(true)
1318 ->setActor($herald_actor)
1319 ->setActingAsPHID($herald_phid)
1320 ->setContentSource($herald_source);
1322 $herald_xactions = $herald_editor->applyTransactions(
1326 // Merge the new transactions into the transaction list: we want to
1327 // send email and publish feed stories about them, too.
1328 $xactions = array_merge($xactions, $herald_xactions);
1331 // If Herald did not generate transactions, we may still need to handle
1332 // "Send an Email" rules.
1333 $adapter = $this->getHeraldAdapter();
1334 $this->heraldEmailPHIDs
= $adapter->getEmailPHIDs();
1335 $this->heraldForcedEmailPHIDs
= $adapter->getForcedEmailPHIDs();
1336 $this->webhookMap
= $adapter->getWebhookMap();
1339 $xactions = $this->didApplyTransactions($object, $xactions);
1341 if ($object instanceof PhabricatorCustomFieldInterface
) {
1342 // Maybe this makes more sense to move into the search index itself? For
1343 // now I'm putting it here since I think we might end up with things that
1344 // need it to be up to date once the next page loads, but if we don't go
1345 // there we could move it into search once search moves to the daemons.
1347 // It now happens in the search indexer as well, but the search indexer is
1348 // always daemonized, so the logic above still potentially holds. We could
1349 // possibly get rid of this. The major motivation for putting it in the
1350 // indexer was to enable reindexing to work.
1352 $fields = PhabricatorCustomField
::getObjectFields(
1354 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1355 $fields->readFieldsFromStorage($object);
1356 $fields->rebuildIndexes($object);
1359 $herald_xscript = $this->getHeraldTranscript();
1360 if ($herald_xscript) {
1361 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1362 $herald_header = HeraldTranscript
::saveXHeraldRulesHeader(
1366 $herald_header = HeraldTranscript
::loadXHeraldRulesHeader(
1367 $object->getPHID());
1369 $this->heraldHeader
= $herald_header;
1371 // See PHI1134. If we're a subeditor, we don't publish information about
1372 // the edit yet. Our parent editor still needs to finish applying
1373 // transactions and execute Herald, which may change the information we
1376 // For example, Herald actions may change the parent object's title or
1377 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1380 // Once the parent finishes work, it will queue its own publish step and
1381 // then queue publish steps for its children.
1383 $this->publishableObject
= $object;
1384 $this->publishableTransactions
= $xactions;
1385 if (!$this->parentEditor
) {
1386 $this->queuePublishing();
1392 private function queuePublishing() {
1393 $object = $this->publishableObject
;
1394 $xactions = $this->publishableTransactions
;
1397 throw new Exception(
1399 'Editor method "queuePublishing()" was called, but no publishable '.
1400 'object is present. This Editor is not ready to publish.'));
1403 // We're going to compute some of the data we'll use to publish these
1404 // transactions here, before queueing a worker.
1406 // Primarily, this is more correct: we want to publish the object as it
1407 // exists right now. The worker may not execute for some time, and we want
1408 // to use the current To/CC list, not respect any changes which may occur
1409 // between now and when the worker executes.
1411 // As a secondary benefit, this tends to reduce the amount of state that
1412 // Editors need to pass into workers.
1413 $object = $this->willPublish($object, $xactions);
1415 if (!$this->getIsSilent()) {
1416 if ($this->shouldSendMail($object, $xactions)) {
1417 $this->mailShouldSend
= true;
1418 $this->mailToPHIDs
= $this->getMailTo($object);
1419 $this->mailCCPHIDs
= $this->getMailCC($object);
1420 $this->mailUnexpandablePHIDs
= $this->newMailUnexpandablePHIDs($object);
1422 // Add any recipients who were previously on the notification list
1423 // but were removed by this change.
1424 $this->applyOldRecipientLists();
1426 if ($object instanceof PhabricatorSubscribableInterface
) {
1427 $this->mailMutedPHIDs
= PhabricatorEdgeQuery
::loadDestinationPHIDs(
1429 PhabricatorMutedByEdgeType
::EDGECONST
);
1431 $this->mailMutedPHIDs
= array();
1434 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1435 $stamps = $this->newMailStamps($object, $xactions);
1436 foreach ($stamps as $stamp) {
1437 $this->mailStamps
[] = $stamp->toDictionary();
1441 if ($this->shouldPublishFeedStory($object, $xactions)) {
1442 $this->feedShouldPublish
= true;
1443 $this->feedRelatedPHIDs
= $this->getFeedRelatedPHIDs(
1446 $this->feedNotifyPHIDs
= $this->getFeedNotifyPHIDs(
1452 PhabricatorWorker
::scheduleTask(
1453 'PhabricatorApplicationTransactionPublishWorker',
1455 'objectPHID' => $object->getPHID(),
1456 'actorPHID' => $this->getActingAsPHID(),
1457 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1458 'state' => $this->getWorkerState(),
1461 'objectPHID' => $object->getPHID(),
1462 'priority' => PhabricatorWorker
::PRIORITY_ALERTS
,
1465 foreach ($this->subEditors
as $sub_editor) {
1466 $sub_editor->queuePublishing();
1469 $this->flushTransactionQueue($object);
1472 protected function didCatchDuplicateKeyException(
1473 PhabricatorLiskDAO
$object,
1479 public function publishTransactions(
1480 PhabricatorLiskDAO
$object,
1483 $this->object = $object;
1484 $this->xactions
= $xactions;
1486 // Hook for edges or other properties that may need (re-)loading
1487 $object = $this->willPublish($object, $xactions);
1489 // The object might have changed, so reassign it.
1490 $this->object = $object;
1492 $messages = array();
1493 if ($this->mailShouldSend
) {
1494 $messages = $this->buildMail($object, $xactions);
1497 if ($this->supportsSearch()) {
1498 PhabricatorSearchWorker
::queueDocumentForIndexing(
1501 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1505 if ($this->feedShouldPublish
) {
1507 foreach ($messages as $mail) {
1508 foreach ($mail->buildRecipientList() as $phid) {
1509 $mailed[$phid] = $phid;
1513 $this->publishFeedStory($object, $xactions, $mailed);
1516 if ($this->sendHistory
) {
1517 $history_mail = $this->buildHistoryMail($object);
1518 if ($history_mail) {
1519 $messages[] = $history_mail;
1523 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1524 $messages[] = $message;
1527 // NOTE: This actually sends the mail. We do this last to reduce the chance
1528 // that we send some mail, hit an exception, then send the mail again when
1530 foreach ($messages as $mail) {
1534 $this->queueWebhooks($object, $xactions);
1539 protected function didApplyTransactions($object, array $xactions) {
1540 // Hook for subclasses.
1544 private function loadHandles(array $xactions) {
1546 foreach ($xactions as $key => $xaction) {
1547 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1550 $merged = array_mergev($phids);
1552 $handles = id(new PhabricatorHandleQuery())
1553 ->setViewer($this->requireActor())
1554 ->withPHIDs($merged)
1557 foreach ($xactions as $key => $xaction) {
1558 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1562 private function loadSubscribers(PhabricatorLiskDAO
$object) {
1563 if ($object->getPHID() &&
1564 ($object instanceof PhabricatorSubscribableInterface
)) {
1565 $subs = PhabricatorSubscribersQuery
::loadSubscribersForPHID(
1566 $object->getPHID());
1567 $this->subscribers
= array_fuse($subs);
1569 $this->subscribers
= array();
1573 private function validateEditParameters(
1574 PhabricatorLiskDAO
$object,
1577 if (!$this->getContentSource()) {
1578 throw new PhutilInvalidStateException('setContentSource');
1581 // Do a bunch of sanity checks that the incoming transactions are fresh.
1582 // They should be unsaved and have only "transactionType" and "newValue"
1585 $types = array_fill_keys($this->getTransactionTypes(), true);
1587 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1588 foreach ($xactions as $xaction) {
1589 if ($xaction->getPHID() ||
$xaction->getID()) {
1590 throw new PhabricatorApplicationTransactionStructureException(
1592 pht('You can not apply transactions which already have IDs/PHIDs!'));
1595 if ($xaction->getObjectPHID()) {
1596 throw new PhabricatorApplicationTransactionStructureException(
1599 'You can not apply transactions which already have %s!',
1603 if ($xaction->getCommentPHID()) {
1604 throw new PhabricatorApplicationTransactionStructureException(
1607 'You can not apply transactions which already have %s!',
1611 if ($xaction->getCommentVersion() !== 0) {
1612 throw new PhabricatorApplicationTransactionStructureException(
1615 'You can not apply transactions which already have '.
1616 'commentVersions!'));
1619 $expect_value = !$xaction->shouldGenerateOldValue();
1620 $has_value = $xaction->hasOldValue();
1622 // See T13082. In the narrow case of applying inverse edge edits, we
1623 // expect the old value to be populated.
1624 if ($this->getIsInverseEdgeEditor()) {
1625 $expect_value = true;
1628 if ($expect_value && !$has_value) {
1629 throw new PhabricatorApplicationTransactionStructureException(
1632 'This transaction is supposed to have an %s set, but it does not!',
1636 if ($has_value && !$expect_value) {
1637 throw new PhabricatorApplicationTransactionStructureException(
1640 'This transaction should generate its %s automatically, '.
1641 'but has already had one set!',
1645 $type = $xaction->getTransactionType();
1646 if (empty($types[$type])) {
1647 throw new PhabricatorApplicationTransactionStructureException(
1650 'Transaction has type "%s", but that transaction type is not '.
1651 'supported by this editor (%s).',
1658 private function applyCapabilityChecks(
1659 PhabricatorLiskDAO
$object,
1661 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1663 $can_edit = PhabricatorPolicyCapability
::CAN_EDIT
;
1665 if ($this->getIsNewObject()) {
1666 // If we're creating a new object, we don't need any special capabilities
1667 // on the object. The actor has already made it through creation checks,
1668 // and objects which haven't been created yet often can not be
1669 // meaningfully tested for capabilities anyway.
1670 $required_capabilities = array();
1672 if (!$xactions && !$this->xactions
) {
1673 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1674 $required_capabilities = array($can_edit);
1676 $required_capabilities = array();
1678 foreach ($xactions as $xaction) {
1679 $type = $xaction->getTransactionType();
1681 $xtype = $this->getModularTransactionType($type);
1683 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1685 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1688 // For convenience, we allow flexibility in the return types because
1689 // it's very unusual that a transaction actually requires multiple
1690 // capability checks.
1691 if ($capabilities === null) {
1692 $capabilities = array();
1694 $capabilities = (array)$capabilities;
1697 foreach ($capabilities as $capability) {
1698 $required_capabilities[$capability] = $capability;
1704 $required_capabilities = array_fuse($required_capabilities);
1705 $actor = $this->getActor();
1707 if ($required_capabilities) {
1708 id(new PhabricatorPolicyFilter())
1710 ->requireCapabilities($required_capabilities)
1711 ->raisePolicyExceptions(true)
1712 ->apply(array($object));
1716 private function getLegacyRequiredCapabilities(
1717 PhabricatorApplicationTransaction
$xaction) {
1719 $type = $xaction->getTransactionType();
1721 case PhabricatorTransactions
::TYPE_COMMENT
:
1722 // TODO: Comments technically require CAN_INTERACT, but this is
1723 // currently somewhat special and handled through EditEngine. For now,
1724 // don't enforce it here.
1726 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1727 // Anyone can subscribe to or unsubscribe from anything they can view,
1728 // with no other permissions.
1730 $old = array_fuse($xaction->getOldValue());
1731 $new = array_fuse($xaction->getNewValue());
1733 // To remove users other than yourself, you must be able to edit the
1735 $rem = array_diff_key($old, $new);
1736 foreach ($rem as $phid) {
1737 if ($phid !== $this->getActingAsPHID()) {
1738 return PhabricatorPolicyCapability
::CAN_EDIT
;
1742 // To add users other than yourself, you must be able to interact.
1743 // This allows "@mentioning" users to work as long as you can comment
1746 // If you can edit, we return that policy instead so that you can
1747 // override a soft lock and still make edits.
1749 // TODO: This is a little bit hacky. We really want to be able to say
1750 // "this requires either interact or edit", but there's currently no
1751 // way to specify this kind of requirement.
1753 $can_edit = PhabricatorPolicyFilter
::hasCapability(
1756 PhabricatorPolicyCapability
::CAN_EDIT
);
1758 $add = array_diff_key($new, $old);
1759 foreach ($add as $phid) {
1760 if ($phid !== $this->getActingAsPHID()) {
1762 return PhabricatorPolicyCapability
::CAN_EDIT
;
1764 return PhabricatorPolicyCapability
::CAN_INTERACT
;
1770 case PhabricatorTransactions
::TYPE_TOKEN
:
1771 // TODO: This technically requires CAN_INTERACT, like comments.
1773 case PhabricatorTransactions
::TYPE_HISTORY
:
1774 // This is a special magic transaction which sends you history via
1775 // email and is only partially supported in the upstream. You don't
1776 // need any capabilities to apply it.
1778 case PhabricatorTransactions
::TYPE_MFA
:
1779 // Signing a transaction group with MFA does not require permissions
1782 case PhabricatorTransactions
::TYPE_EDGE
:
1783 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1785 // For other older (non-modular) transactions, always require exactly
1786 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1787 // capabilities must move to ModularTransactions.
1788 return PhabricatorPolicyCapability
::CAN_EDIT
;
1792 private function getLegacyRequiredEdgeCapabilities(
1793 PhabricatorApplicationTransaction
$xaction) {
1795 // You don't need to have edit permission on an object to mention it or
1796 // otherwise add a relationship pointing toward it.
1797 if ($this->getIsInverseEdgeEditor()) {
1801 $edge_type = $xaction->getMetadataValue('edge:type');
1802 switch ($edge_type) {
1803 case PhabricatorMutedByEdgeType
::EDGECONST
:
1804 // At time of writing, you can only write this edge for yourself, so
1805 // you don't need permissions. If you can eventually mute an object
1806 // for other users, this would need to be revisited.
1808 case PhabricatorProjectSilencedEdgeType
::EDGECONST
:
1809 // At time of writing, you can only write this edge for yourself, so
1810 // you don't need permissions. If you can eventually silence project
1811 // for other users, this would need to be revisited.
1813 case PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
:
1815 case PhabricatorProjectProjectHasMemberEdgeType
::EDGECONST
:
1816 $old = $xaction->getOldValue();
1817 $new = $xaction->getNewValue();
1819 $add = array_keys(array_diff_key($new, $old));
1820 $rem = array_keys(array_diff_key($old, $new));
1822 $actor_phid = $this->requireActor()->getPHID();
1824 $is_join = (($add === array($actor_phid)) && !$rem);
1825 $is_leave = (($rem === array($actor_phid)) && !$add);
1828 // You need CAN_JOIN to join a project.
1829 return PhabricatorPolicyCapability
::CAN_JOIN
;
1833 $object = $this->object;
1834 // You usually don't need any capabilities to leave a project...
1835 if ($object->getIsMembershipLocked()) {
1836 // ...you must be able to edit to leave locked projects, though.
1837 return PhabricatorPolicyCapability
::CAN_EDIT
;
1843 // You need CAN_EDIT to change members other than yourself.
1844 return PhabricatorPolicyCapability
::CAN_EDIT
;
1845 case PhabricatorObjectHasWatcherEdgeType
::EDGECONST
:
1846 // See PHI1024. Watching a project does not require CAN_EDIT.
1849 return PhabricatorPolicyCapability
::CAN_EDIT
;
1854 private function buildSubscribeTransaction(
1855 PhabricatorLiskDAO
$object,
1859 if (!($object instanceof PhabricatorSubscribableInterface
)) {
1863 if ($this->shouldEnableMentions($object, $xactions)) {
1864 // Identify newly mentioned users. We ignore users who were previously
1865 // mentioned so that we don't re-subscribe users after an edit of text
1866 // which mentions them.
1867 $old_texts = mpull($changes, 'getOldValue');
1868 $new_texts = mpull($changes, 'getNewValue');
1870 $old_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
1874 $new_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
1878 $phids = array_diff($new_phids, $old_phids);
1883 $this->mentionedPHIDs
= $phids;
1885 if ($object->getPHID()) {
1886 // Don't try to subscribe already-subscribed mentions: we want to generate
1887 // a dialog about an action having no effect if the user explicitly adds
1888 // existing CCs, but not if they merely mention existing subscribers.
1889 $phids = array_diff($phids, $this->subscribers
);
1893 $users = id(new PhabricatorPeopleQuery())
1894 ->setViewer($this->getActor())
1897 $users = mpull($users, null, 'getPHID');
1899 foreach ($phids as $key => $phid) {
1900 $user = idx($users, $phid);
1902 // Don't subscribe invalid users.
1904 unset($phids[$key]);
1908 // Don't subscribe bots that get mentioned. If users truly intend
1909 // to subscribe them, they can add them explicitly, but it's generally
1910 // not useful to subscribe bots to objects.
1911 if ($user->getIsSystemAgent()) {
1912 unset($phids[$key]);
1916 // Do not subscribe mentioned users who do not have permission to see
1918 if ($object instanceof PhabricatorPolicyInterface
) {
1919 $can_view = PhabricatorPolicyFilter
::hasCapability(
1922 PhabricatorPolicyCapability
::CAN_VIEW
);
1924 unset($phids[$key]);
1929 // Don't subscribe users who are already automatically subscribed.
1930 if ($object->isAutomaticallySubscribed($phid)) {
1931 unset($phids[$key]);
1936 $phids = array_values($phids);
1943 $xaction = $object->getApplicationTransactionTemplate()
1944 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
1945 ->setNewValue(array('+' => $phids));
1950 protected function mergeTransactions(
1951 PhabricatorApplicationTransaction
$u,
1952 PhabricatorApplicationTransaction
$v) {
1954 $type = $u->getTransactionType();
1956 $xtype = $this->getModularTransactionType($type);
1958 $object = $this->object;
1959 return $xtype->mergeTransactions($object, $u, $v);
1963 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1964 return $this->mergePHIDOrEdgeTransactions($u, $v);
1965 case PhabricatorTransactions
::TYPE_EDGE
:
1966 $u_type = $u->getMetadataValue('edge:type');
1967 $v_type = $v->getMetadataValue('edge:type');
1968 if ($u_type == $v_type) {
1969 return $this->mergePHIDOrEdgeTransactions($u, $v);
1974 // By default, do not merge the transactions.
1979 * Optionally expand transactions which imply other effects. For example,
1980 * resigning from a revision in Differential implies removing yourself as
1983 protected function expandTransactions(
1984 PhabricatorLiskDAO
$object,
1988 foreach ($xactions as $xaction) {
1989 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
1990 $results[] = $expanded;
1997 protected function expandTransaction(
1998 PhabricatorLiskDAO
$object,
1999 PhabricatorApplicationTransaction
$xaction) {
2000 return array($xaction);
2004 public function getExpandedSupportTransactions(
2005 PhabricatorLiskDAO
$object,
2006 PhabricatorApplicationTransaction
$xaction) {
2008 $xactions = array($xaction);
2009 $xactions = $this->expandSupportTransactions(
2013 if (count($xactions) == 1) {
2017 foreach ($xactions as $index => $cxaction) {
2018 if ($cxaction === $xaction) {
2019 unset($xactions[$index]);
2027 private function expandSupportTransactions(
2028 PhabricatorLiskDAO
$object,
2030 $this->loadSubscribers($object);
2032 $xactions = $this->applyImplicitCC($object, $xactions);
2034 $changes = $this->getRemarkupChanges($xactions);
2036 $subscribe_xaction = $this->buildSubscribeTransaction(
2040 if ($subscribe_xaction) {
2041 $xactions[] = $subscribe_xaction;
2044 // TODO: For now, this is just a placeholder.
2045 $engine = PhabricatorMarkupEngine
::getEngine('extract');
2046 $engine->setConfig('viewer', $this->requireActor());
2048 $block_xactions = $this->expandRemarkupBlockTransactions(
2054 foreach ($block_xactions as $xaction) {
2055 $xactions[] = $xaction;
2061 private function getRemarkupChanges(array $xactions) {
2064 foreach ($xactions as $key => $xaction) {
2065 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2066 $changes[] = $change;
2073 private function getRemarkupChangesFromTransaction(
2074 PhabricatorApplicationTransaction
$transaction) {
2075 return $transaction->getRemarkupChanges();
2078 private function expandRemarkupBlockTransactions(
2079 PhabricatorLiskDAO
$object,
2082 PhutilMarkupEngine
$engine) {
2084 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2090 $mentioned_phids = array();
2091 if ($this->shouldEnableMentions($object, $xactions)) {
2092 foreach ($changes as $change) {
2093 // Here, we don't care about processing only new mentions after an edit
2094 // because there is no way for an object to ever "unmention" itself on
2095 // another object, so we can ignore the old value.
2096 $engine->markupText($change->getNewValue());
2098 $mentioned_phids +
= $engine->getTextMetadata(
2099 PhabricatorObjectRemarkupRule
::KEY_MENTIONED_OBJECTS
,
2104 if (!$mentioned_phids) {
2105 return $block_xactions;
2108 $mentioned_objects = id(new PhabricatorObjectQuery())
2109 ->setViewer($this->getActor())
2110 ->withPHIDs($mentioned_phids)
2113 $unmentionable_map = $this->getUnmentionablePHIDMap();
2115 $mentionable_phids = array();
2116 if ($this->shouldEnableMentions($object, $xactions)) {
2117 foreach ($mentioned_objects as $mentioned_object) {
2118 if ($mentioned_object instanceof PhabricatorMentionableInterface
) {
2119 $mentioned_phid = $mentioned_object->getPHID();
2120 if (isset($unmentionable_map[$mentioned_phid])) {
2123 // don't let objects mention themselves
2124 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2127 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2132 if ($mentionable_phids) {
2133 $edge_type = PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
;
2134 $block_xactions[] = newv(get_class(head($xactions)), array())
2135 ->setIgnoreOnNoEffect(true)
2136 ->setTransactionType(PhabricatorTransactions
::TYPE_EDGE
)
2137 ->setMetadataValue('edge:type', $edge_type)
2138 ->setNewValue(array('+' => $mentionable_phids));
2141 return $block_xactions;
2144 protected function expandCustomRemarkupBlockTransactions(
2145 PhabricatorLiskDAO
$object,
2148 PhutilMarkupEngine
$engine) {
2154 * Attempt to combine similar transactions into a smaller number of total
2155 * transactions. For example, two transactions which edit the title of an
2156 * object can be merged into a single edit.
2158 private function combineTransactions(array $xactions) {
2159 $stray_comments = array();
2163 foreach ($xactions as $key => $xaction) {
2164 $type = $xaction->getTransactionType();
2165 if (isset($types[$type])) {
2166 foreach ($types[$type] as $other_key) {
2167 $other_xaction = $result[$other_key];
2169 // Don't merge transactions with different authors. For example,
2170 // don't merge Herald transactions and owners transactions.
2171 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2175 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2177 $result[$other_key] = $merged;
2179 if ($xaction->getComment() &&
2180 ($xaction->getComment() !== $merged->getComment())) {
2181 $stray_comments[] = $xaction->getComment();
2184 if ($result[$other_key]->getComment() &&
2185 ($result[$other_key]->getComment() !== $merged->getComment())) {
2186 $stray_comments[] = $result[$other_key]->getComment();
2189 // Move on to the next transaction.
2194 $result[$key] = $xaction;
2195 $types[$type][] = $key;
2198 // If we merged any comments away, restore them.
2199 foreach ($stray_comments as $comment) {
2200 $xaction = newv(get_class(head($result)), array());
2201 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_COMMENT
);
2202 $xaction->setComment($comment);
2203 $result[] = $xaction;
2206 return array_values($result);
2209 public function mergePHIDOrEdgeTransactions(
2210 PhabricatorApplicationTransaction
$u,
2211 PhabricatorApplicationTransaction
$v) {
2213 $result = $u->getNewValue();
2214 foreach ($v->getNewValue() as $key => $value) {
2215 if ($u->getTransactionType() == PhabricatorTransactions
::TYPE_EDGE
) {
2216 if (empty($result[$key])) {
2217 $result[$key] = $value;
2219 // We're merging two lists of edge adds, sets, or removes. Merge
2220 // them by merging individual PHIDs within them.
2221 $merged = $result[$key];
2223 foreach ($value as $dst => $v_spec) {
2224 if (empty($merged[$dst])) {
2225 $merged[$dst] = $v_spec;
2227 // Two transactions are trying to perform the same operation on
2228 // the same edge. Normalize the edge data and then merge it. This
2229 // allows transactions to specify how data merges execute in a
2232 $u_spec = $merged[$dst];
2234 if (!is_array($u_spec)) {
2235 $u_spec = array('dst' => $u_spec);
2237 if (!is_array($v_spec)) {
2238 $v_spec = array('dst' => $v_spec);
2241 $ux_data = idx($u_spec, 'data', array());
2242 $vx_data = idx($v_spec, 'data', array());
2244 $merged_data = $this->mergeEdgeData(
2245 $u->getMetadataValue('edge:type'),
2249 $u_spec['data'] = $merged_data;
2250 $merged[$dst] = $u_spec;
2254 $result[$key] = $merged;
2257 $result[$key] = array_merge($value, idx($result, $key, array()));
2260 $u->setNewValue($result);
2262 // When combining an "ignore" transaction with a normal transaction, make
2263 // sure we don't propagate the "ignore" flag.
2264 if (!$v->getIgnoreOnNoEffect()) {
2265 $u->setIgnoreOnNoEffect(false);
2271 protected function mergeEdgeData($type, array $u, array $v) {
2275 protected function getPHIDTransactionNewValue(
2276 PhabricatorApplicationTransaction
$xaction,
2279 if ($old !== null) {
2280 $old = array_fuse($old);
2282 $old = array_fuse($xaction->getOldValue());
2285 return $this->getPHIDList($old, $xaction->getNewValue());
2288 public function getPHIDList(array $old, array $new) {
2289 $new_add = idx($new, '+', array());
2291 $new_rem = idx($new, '-', array());
2293 $new_set = idx($new, '=', null);
2294 if ($new_set !== null) {
2295 $new_set = array_fuse($new_set);
2300 throw new Exception(
2302 "Invalid '%s' value for PHID transaction. Value should contain only ".
2303 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2312 foreach ($old as $phid) {
2313 if ($new_set !== null && empty($new_set[$phid])) {
2316 $result[$phid] = $phid;
2319 if ($new_set !== null) {
2320 foreach ($new_set as $phid) {
2321 $result[$phid] = $phid;
2325 foreach ($new_add as $phid) {
2326 $result[$phid] = $phid;
2329 foreach ($new_rem as $phid) {
2330 unset($result[$phid]);
2333 return array_values($result);
2336 protected function getEdgeTransactionNewValue(
2337 PhabricatorApplicationTransaction
$xaction) {
2339 $new = $xaction->getNewValue();
2340 $new_add = idx($new, '+', array());
2342 $new_rem = idx($new, '-', array());
2344 $new_set = idx($new, '=', null);
2348 throw new Exception(
2350 "Invalid '%s' value for Edge transaction. Value should contain only ".
2351 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2358 $old = $xaction->getOldValue();
2360 $lists = array($new_set, $new_add, $new_rem);
2361 foreach ($lists as $list) {
2362 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2366 foreach ($old as $dst_phid => $edge) {
2367 if ($new_set !== null && empty($new_set[$dst_phid])) {
2370 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2376 if ($new_set !== null) {
2377 foreach ($new_set as $dst_phid => $edge) {
2378 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2385 foreach ($new_add as $dst_phid => $edge) {
2386 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2392 foreach ($new_rem as $dst_phid => $edge) {
2393 unset($result[$dst_phid]);
2399 private function checkEdgeList($list, $edge_type) {
2403 foreach ($list as $key => $item) {
2404 if (phid_get_type($key) === PhabricatorPHIDConstants
::PHID_TYPE_UNKNOWN
) {
2405 throw new Exception(
2407 'Edge transactions must have destination PHIDs as in edge '.
2408 'lists (found key "%s" on transaction of type "%s").',
2412 if (!is_array($item) && $item !== $key) {
2413 throw new Exception(
2415 'Edge transactions must have PHIDs or edge specs as values '.
2416 '(found value "%s" on transaction of type "%s").',
2423 private function normalizeEdgeTransactionValue(
2424 PhabricatorApplicationTransaction
$xaction,
2428 if (!is_array($edge)) {
2429 if ($edge != $dst_phid) {
2430 throw new Exception(
2432 'Transaction edge data must either be the edge PHID or an edge '.
2433 'specification dictionary.'));
2437 foreach ($edge as $key => $value) {
2444 case 'dateModified':
2449 throw new Exception(
2451 'Transaction edge specification contains unexpected key "%s".',
2457 $edge['dst'] = $dst_phid;
2459 $edge_type = $xaction->getMetadataValue('edge:type');
2460 if (empty($edge['type'])) {
2461 $edge['type'] = $edge_type;
2463 if ($edge['type'] != $edge_type) {
2464 $this_type = $edge['type'];
2465 throw new Exception(
2467 "Edge transaction includes edge of type '%s', but ".
2468 "transaction is of type '%s'. Each edge transaction ".
2469 "must alter edges of only one type.",
2475 if (!isset($edge['data'])) {
2476 $edge['data'] = array();
2482 protected function sortTransactions(array $xactions) {
2486 // Move bare comments to the end, so the actions precede them.
2487 foreach ($xactions as $xaction) {
2488 $type = $xaction->getTransactionType();
2489 if ($type == PhabricatorTransactions
::TYPE_COMMENT
) {
2496 return array_values(array_merge($head, $tail));
2500 protected function filterTransactions(
2501 PhabricatorLiskDAO
$object,
2504 $type_comment = PhabricatorTransactions
::TYPE_COMMENT
;
2505 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
2507 $no_effect = array();
2508 $has_comment = false;
2509 $any_effect = false;
2511 $meta_xactions = array();
2512 foreach ($xactions as $key => $xaction) {
2513 if ($xaction->getTransactionType() === $type_mfa) {
2514 $meta_xactions[$key] = $xaction;
2518 if ($this->transactionHasEffect($object, $xaction)) {
2519 if ($xaction->getTransactionType() != $type_comment) {
2522 } else if ($xaction->getIgnoreOnNoEffect()) {
2523 unset($xactions[$key]);
2525 $no_effect[$key] = $xaction;
2528 if ($xaction->hasComment()) {
2529 $has_comment = true;
2533 // If every transaction is a meta-transaction applying to the transaction
2534 // group, these transactions are junk.
2535 if (count($meta_xactions) == count($xactions)) {
2536 $no_effect = $xactions;
2537 $any_effect = false;
2544 // If none of the transactions have an effect, the meta-transactions also
2545 // have no effect. Add them to the "no effect" list so we get a full set
2546 // of errors for everything.
2547 if (!$any_effect && !$has_comment) {
2548 $no_effect +
= $meta_xactions;
2551 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2552 throw new PhabricatorApplicationTransactionNoEffectException(
2558 if (!$any_effect && !$has_comment) {
2559 // If we only have empty comment transactions, just drop them all.
2563 foreach ($no_effect as $key => $xaction) {
2564 if ($xaction->hasComment()) {
2565 $xaction->setTransactionType($type_comment);
2566 $xaction->setOldValue(null);
2567 $xaction->setNewValue(null);
2569 unset($xactions[$key]);
2578 * Hook for validating transactions. This callback will be invoked for each
2579 * available transaction type, even if an edit does not apply any transactions
2580 * of that type. This allows you to raise exceptions when required fields are
2581 * missing, by detecting that the object has no field value and there is no
2582 * transaction which sets one.
2584 * @param PhabricatorLiskDAO Object being edited.
2585 * @param string Transaction type to validate.
2586 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2587 * which may be empty if the edit does not apply any transactions of the
2589 * @return list<PhabricatorApplicationTransactionValidationError> List of
2590 * validation errors.
2592 protected function validateTransaction(
2593 PhabricatorLiskDAO
$object,
2599 $xtype = $this->getModularTransactionType($type);
2601 $errors[] = $xtype->validateTransactions($object, $xactions);
2605 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
2606 $errors[] = $this->validatePolicyTransaction(
2610 PhabricatorPolicyCapability
::CAN_VIEW
);
2612 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
2613 $errors[] = $this->validatePolicyTransaction(
2617 PhabricatorPolicyCapability
::CAN_EDIT
);
2619 case PhabricatorTransactions
::TYPE_SPACE
:
2620 $errors[] = $this->validateSpaceTransactions(
2625 case PhabricatorTransactions
::TYPE_SUBTYPE
:
2626 $errors[] = $this->validateSubtypeTransactions(
2631 case PhabricatorTransactions
::TYPE_MFA
:
2632 $errors[] = $this->validateMFATransactions(
2637 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
2639 foreach ($xactions as $xaction) {
2640 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2643 $field_list = PhabricatorCustomField
::getObjectFields(
2645 PhabricatorCustomField
::ROLE_EDIT
);
2646 $field_list->setViewer($this->getActor());
2648 $role_xactions = PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
;
2649 foreach ($field_list->getFields() as $field) {
2650 if (!$field->shouldEnableForRole($role_xactions)) {
2653 $errors[] = $field->validateApplicationTransactions(
2656 idx($groups, $field->getFieldKey(), array()));
2661 return array_mergev($errors);
2664 public function validatePolicyTransaction(
2665 PhabricatorLiskDAO
$object,
2670 $actor = $this->requireActor();
2672 // Note $this->xactions is necessary; $xactions is $this->xactions of
2673 // $transaction_type
2674 $policy_object = $this->adjustObjectForPolicyChecks(
2678 // Make sure the user isn't editing away their ability to $capability this
2680 foreach ($xactions as $xaction) {
2682 PhabricatorPolicyFilter
::requireCapabilityWithForcedPolicy(
2686 $xaction->getNewValue());
2687 } catch (PhabricatorPolicyException
$ex) {
2688 $errors[] = new PhabricatorApplicationTransactionValidationError(
2692 'You can not select this %s policy, because you would no longer '.
2693 'be able to %s the object.',
2700 if ($this->getIsNewObject()) {
2702 $has_capability = PhabricatorPolicyFilter
::hasCapability(
2706 if (!$has_capability) {
2707 $errors[] = new PhabricatorApplicationTransactionValidationError(
2711 'The selected %s policy excludes you. Choose a %s policy '.
2712 'which allows you to %s the object.',
2724 private function validateSpaceTransactions(
2725 PhabricatorLiskDAO
$object,
2727 $transaction_type) {
2730 $actor = $this->getActor();
2732 $has_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpacesExist($actor);
2733 $actor_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpaces($actor);
2734 $active_spaces = PhabricatorSpacesNamespaceQuery
::getViewerActiveSpaces(
2736 foreach ($xactions as $xaction) {
2737 $space_phid = $xaction->getNewValue();
2739 if ($space_phid === null) {
2741 // The install doesn't have any spaces, so this is fine.
2745 // The install has some spaces, so every object needs to be put
2746 // in a valid space.
2747 $errors[] = new PhabricatorApplicationTransactionValidationError(
2750 pht('You must choose a space for this object.'),
2755 // If the PHID isn't `null`, it needs to be a valid space that the
2757 if (empty($actor_spaces[$space_phid])) {
2758 $errors[] = new PhabricatorApplicationTransactionValidationError(
2762 'You can not shift this object in the selected space, because '.
2763 'the space does not exist or you do not have access to it.'),
2765 } else if (empty($active_spaces[$space_phid])) {
2767 // It's OK to edit objects in an archived space, so just move on if
2768 // we aren't adjusting the value.
2769 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
2770 if ($space_phid == $old_space_phid) {
2774 $errors[] = new PhabricatorApplicationTransactionValidationError(
2778 'You can not shift this object into the selected space, because '.
2779 'the space is archived. Objects can not be created inside (or '.
2780 'moved into) archived spaces.'),
2788 private function validateSubtypeTransactions(
2789 PhabricatorLiskDAO
$object,
2791 $transaction_type) {
2794 $map = $object->newEditEngineSubtypeMap();
2795 $old = $object->getEditEngineSubtype();
2796 foreach ($xactions as $xaction) {
2797 $new = $xaction->getNewValue();
2803 if (!$map->isValidSubtype($new)) {
2804 $errors[] = new PhabricatorApplicationTransactionValidationError(
2808 'The subtype "%s" is not a valid subtype.',
2818 private function validateMFATransactions(
2819 PhabricatorLiskDAO
$object,
2821 $transaction_type) {
2824 $factors = id(new PhabricatorAuthFactorConfigQuery())
2825 ->setViewer($this->getActor())
2826 ->withUserPHIDs(array($this->getActingAsPHID()))
2827 ->withFactorProviderStatuses(
2829 PhabricatorAuthFactorProviderStatus
::STATUS_ACTIVE
,
2830 PhabricatorAuthFactorProviderStatus
::STATUS_DEPRECATED
,
2834 foreach ($xactions as $xaction) {
2836 $errors[] = new PhabricatorApplicationTransactionValidationError(
2840 'You do not have any MFA factors attached to your account, so '.
2841 'you can not sign this transaction group with MFA. Add MFA to '.
2842 'your account in Settings.'),
2848 $this->setShouldRequireMFA(true);
2854 protected function adjustObjectForPolicyChecks(
2855 PhabricatorLiskDAO
$object,
2858 $copy = clone $object;
2860 foreach ($xactions as $xaction) {
2861 switch ($xaction->getTransactionType()) {
2862 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
2863 $clone_xaction = clone $xaction;
2864 $clone_xaction->setOldValue(array_values($this->subscribers
));
2865 $clone_xaction->setNewValue(
2866 $this->getPHIDTransactionNewValue(
2869 PhabricatorPolicyRule
::passTransactionHintToRule(
2871 new PhabricatorSubscriptionsSubscribersPolicyRule(),
2872 array_fuse($clone_xaction->getNewValue()));
2875 case PhabricatorTransactions
::TYPE_SPACE
:
2876 $space_phid = $this->getTransactionNewValue($object, $xaction);
2877 $copy->setSpacePHID($space_phid);
2885 protected function validateAllTransactions(
2886 PhabricatorLiskDAO
$object,
2892 * Check for a missing text field.
2894 * A text field is missing if the object has no value and there are no
2895 * transactions which set a value, or if the transactions remove the value.
2896 * This method is intended to make implementing @{method:validateTransaction}
2899 * $missing = $this->validateIsEmptyTextField(
2900 * $object->getName(),
2903 * This will return `true` if the net effect of the object and transactions
2904 * is an empty field.
2906 * @param wild Current field value.
2907 * @param list<PhabricatorApplicationTransaction> Transactions editing the
2909 * @return bool True if the field will be an empty text field after edits.
2911 protected function validateIsEmptyTextField($field_value, array $xactions) {
2912 if (strlen($field_value) && empty($xactions)) {
2916 if ($xactions && strlen(last($xactions)->getNewValue())) {
2924 /* -( Implicit CCs )------------------------------------------------------- */
2928 * When a user interacts with an object, we might want to add them to CC.
2930 final public function applyImplicitCC(
2931 PhabricatorLiskDAO
$object,
2934 if (!($object instanceof PhabricatorSubscribableInterface
)) {
2935 // If the object isn't subscribable, we can't CC them.
2939 $actor_phid = $this->getActingAsPHID();
2941 $type_user = PhabricatorPeopleUserPHIDType
::TYPECONST
;
2942 if (phid_get_type($actor_phid) != $type_user) {
2943 // Transactions by application actors like Herald, Harbormaster and
2944 // Diffusion should not CC the applications.
2948 if ($object->isAutomaticallySubscribed($actor_phid)) {
2949 // If they're auto-subscribed, don't CC them.
2954 foreach ($xactions as $xaction) {
2955 if ($this->shouldImplyCC($object, $xaction)) {
2962 // Only some types of actions imply a CC (like adding a comment).
2966 if ($object->getPHID()) {
2967 if (isset($this->subscribers
[$actor_phid])) {
2968 // If the user is already subscribed, don't implicitly CC them.
2972 $unsub = PhabricatorEdgeQuery
::loadDestinationPHIDs(
2974 PhabricatorObjectHasUnsubscriberEdgeType
::EDGECONST
);
2975 $unsub = array_fuse($unsub);
2976 if (isset($unsub[$actor_phid])) {
2977 // If the user has previously unsubscribed from this object explicitly,
2978 // don't implicitly CC them.
2983 $actor = $this->getActor();
2985 $user = id(new PhabricatorPeopleQuery())
2987 ->withPHIDs(array($actor_phid))
2993 // When a bot acts (usually via the API), don't automatically subscribe
2994 // them as a side effect. They can always subscribe explicitly if they
2995 // want, and bot subscriptions normally just clutter things up since bots
2996 // usually do not read email.
2997 if ($user->getIsSystemAgent()) {
3001 $xaction = newv(get_class(head($xactions)), array());
3002 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
);
3003 $xaction->setNewValue(array('+' => array($actor_phid)));
3005 array_unshift($xactions, $xaction);
3010 protected function shouldImplyCC(
3011 PhabricatorLiskDAO
$object,
3012 PhabricatorApplicationTransaction
$xaction) {
3014 return $xaction->isCommentTransaction();
3018 /* -( Sending Mail )------------------------------------------------------- */
3024 protected function shouldSendMail(
3025 PhabricatorLiskDAO
$object,
3034 private function buildMail(
3035 PhabricatorLiskDAO
$object,
3038 $email_to = $this->mailToPHIDs
;
3039 $email_cc = $this->mailCCPHIDs
;
3040 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs
);
3042 $unexpandable = $this->mailUnexpandablePHIDs
;
3043 if (!is_array($unexpandable)) {
3044 $unexpandable = array();
3047 $messages = $this->buildMailWithRecipients(
3054 $this->runHeraldMailRules($messages);
3059 private function buildMailWithRecipients(
3060 PhabricatorLiskDAO
$object,
3064 array $unexpandable) {
3066 $targets = $this->buildReplyHandler($object)
3067 ->setUnexpandablePHIDs($unexpandable)
3068 ->getMailTargets($email_to, $email_cc);
3070 // Set this explicitly before we start swapping out the effective actor.
3071 $this->setActingAsPHID($this->getActingAsPHID());
3073 $xaction_phids = mpull($xactions, 'getPHID');
3075 $messages = array();
3076 foreach ($targets as $target) {
3077 $original_actor = $this->getActor();
3079 $viewer = $target->getViewer();
3080 $this->setActor($viewer);
3081 $locale = PhabricatorEnv
::beginScopedLocale($viewer->getTranslation());
3086 // Reload the transactions for the current viewer.
3087 if ($xaction_phids) {
3088 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject(
3091 $mail_xactions = $query
3092 ->setViewer($viewer)
3093 ->withObjectPHIDs(array($object->getPHID()))
3094 ->withPHIDs($xaction_phids)
3097 // Sort the mail transactions in the input order.
3098 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3099 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3100 $mail_xactions = array_values($mail_xactions);
3102 $mail_xactions = array();
3105 // Reload handles for the current viewer. This covers older code which
3106 // emits a list of handle PHIDs upfront.
3107 $this->loadHandles($mail_xactions);
3109 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3112 if ($this->mustEncrypt
) {
3114 ->setMustEncrypt(true)
3115 ->setMustEncryptReasons($this->mustEncrypt
);
3118 } catch (Exception
$ex) {
3122 $this->setActor($original_actor);
3130 $messages[] = $mail;
3137 protected function getTransactionsForMail(
3138 PhabricatorLiskDAO
$object,
3143 private function buildMailForTarget(
3144 PhabricatorLiskDAO
$object,
3146 PhabricatorMailTarget
$target) {
3148 // Check if any of the transactions are visible for this viewer. If we
3149 // don't have any visible transactions, don't send the mail.
3151 $any_visible = false;
3152 foreach ($xactions as $xaction) {
3153 if (!$xaction->shouldHideForMail($xactions)) {
3154 $any_visible = true;
3159 if (!$any_visible) {
3163 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3165 $mail = $this->buildMailTemplate($object);
3166 $body = $this->buildMailBody($object, $mail_xactions);
3168 $mail_tags = $this->getMailTags($object, $mail_xactions);
3169 $action = $this->getMailAction($object, $mail_xactions);
3170 $stamps = $this->generateMailStamps($object, $this->mailStamps
);
3172 if (PhabricatorEnv
::getEnvConfig('metamta.email-preferences')) {
3173 $this->addEmailPreferenceSectionToMailBody(
3179 $muted_phids = $this->mailMutedPHIDs
;
3180 if (!is_array($muted_phids)) {
3181 $muted_phids = array();
3185 ->setSensitiveContent(false)
3186 ->setFrom($this->getActingAsPHID())
3187 ->setSubjectPrefix($this->getMailSubjectPrefix())
3188 ->setVarySubjectPrefix('['.$action.']')
3189 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3190 ->setRelatedPHID($object->getPHID())
3191 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3192 ->setMutedPHIDs($muted_phids)
3193 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs
)
3194 ->setMailTags($mail_tags)
3196 ->setBody($body->render())
3197 ->setHTMLBody($body->renderHTML());
3199 foreach ($body->getAttachments() as $attachment) {
3200 $mail->addAttachment($attachment);
3203 if ($this->heraldHeader
) {
3204 $mail->addHeader('X-Herald-Rules', $this->heraldHeader
);
3207 if ($object instanceof PhabricatorProjectInterface
) {
3208 $this->addMailProjectMetadata($object, $mail);
3211 if ($this->getParentMessageID()) {
3212 $mail->setParentMessageID($this->getParentMessageID());
3215 // If we have stamps, attach the raw dictionary version (not the actual
3216 // objects) to the mail so that debugging tools can see what we used to
3217 // render the final list.
3218 if ($this->mailStamps
) {
3219 $mail->setMailStampMetadata($this->mailStamps
);
3222 // If we have rendered stamps, attach them to the mail.
3224 $mail->setMailStamps($stamps);
3227 return $target->willSendMail($mail);
3230 private function addMailProjectMetadata(
3231 PhabricatorLiskDAO
$object,
3232 PhabricatorMetaMTAMail
$template) {
3234 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3236 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3238 if (!$project_phids) {
3242 // TODO: This viewer isn't quite right. It would be slightly better to use
3243 // the mail recipient, but that's not very easy given the way rendering
3246 $handles = id(new PhabricatorHandleQuery())
3247 ->setViewer($this->requireActor())
3248 ->withPHIDs($project_phids)
3251 $project_tags = array();
3252 foreach ($handles as $handle) {
3253 if (!$handle->isComplete()) {
3256 $project_tags[] = '<'.$handle->getObjectName().'>';
3259 if (!$project_tags) {
3263 $project_tags = implode(', ', $project_tags);
3264 $template->addHeader('X-Phabricator-Projects', $project_tags);
3268 protected function getMailThreadID(PhabricatorLiskDAO
$object) {
3269 return $object->getPHID();
3276 protected function getStrongestAction(
3277 PhabricatorLiskDAO
$object,
3279 return head(msortv($xactions, 'newActionStrengthSortVector'));
3286 protected function buildReplyHandler(PhabricatorLiskDAO
$object) {
3287 throw new Exception(pht('Capability not supported.'));
3293 protected function getMailSubjectPrefix() {
3294 throw new Exception(pht('Capability not supported.'));
3301 protected function getMailTags(
3302 PhabricatorLiskDAO
$object,
3306 foreach ($xactions as $xaction) {
3307 $tags[] = $xaction->getMailTags();
3310 return array_mergev($tags);
3316 public function getMailTagsMap() {
3317 // TODO: We should move shared mail tags, like "comment", here.
3325 protected function getMailAction(
3326 PhabricatorLiskDAO
$object,
3328 return $this->getStrongestAction($object, $xactions)->getActionName();
3335 protected function buildMailTemplate(PhabricatorLiskDAO
$object) {
3336 throw new Exception(pht('Capability not supported.'));
3343 protected function getMailTo(PhabricatorLiskDAO
$object) {
3344 throw new Exception(pht('Capability not supported.'));
3348 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO
$object) {
3356 protected function getMailCC(PhabricatorLiskDAO
$object) {
3358 $has_support = false;
3360 if ($object instanceof PhabricatorSubscribableInterface
) {
3361 $phid = $object->getPHID();
3362 $phids[] = PhabricatorSubscribersQuery
::loadSubscribersForPHID($phid);
3363 $has_support = true;
3366 if ($object instanceof PhabricatorProjectInterface
) {
3367 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3369 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3371 if ($project_phids) {
3372 $projects = id(new PhabricatorProjectQuery())
3373 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3374 ->withPHIDs($project_phids)
3375 ->needWatchers(true)
3378 $watcher_phids = array();
3379 foreach ($projects as $project) {
3380 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3381 $watcher_phids[$phid] = $phid;
3385 if ($watcher_phids) {
3386 // We need to do a visibility check for all the watchers, as
3387 // watching a project is not a guarantee that you can see objects
3388 // associated with it.
3389 $users = id(new PhabricatorPeopleQuery())
3390 ->setViewer($this->requireActor())
3391 ->withPHIDs($watcher_phids)
3394 $watchers = array();
3395 foreach ($users as $user) {
3396 $can_see = PhabricatorPolicyFilter
::hasCapability(
3399 PhabricatorPolicyCapability
::CAN_VIEW
);
3401 $watchers[] = $user->getPHID();
3404 $phids[] = $watchers;
3408 $has_support = true;
3411 if (!$has_support) {
3412 throw new Exception(
3413 pht('The object being edited does not implement any standard '.
3414 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3415 'CCs to be generated automatically. Override the "getMailCC()" '.
3416 'method and generate CCs explicitly.'));
3419 return array_mergev($phids);
3426 protected function buildMailBody(
3427 PhabricatorLiskDAO
$object,
3430 $body = id(new PhabricatorMetaMTAMailBody())
3431 ->setViewer($this->requireActor())
3432 ->setContextObject($object);
3434 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3435 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3437 $this->addHeadersAndCommentsToMailBody(
3443 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3448 protected function getObjectLinkButtonLabelForMail(
3449 PhabricatorLiskDAO
$object) {
3453 protected function getObjectLinkButtonURIForMail(
3454 PhabricatorLiskDAO
$object) {
3456 // Most objects define a "getURI()" method which does what we want, but
3457 // this isn't formally part of an interface at time of writing. Try to
3458 // call the method, expecting an exception if it does not exist.
3461 $uri = $object->getURI();
3462 return PhabricatorEnv
::getProductionURI($uri);
3463 } catch (Exception
$ex) {
3471 protected function addEmailPreferenceSectionToMailBody(
3472 PhabricatorMetaMTAMailBody
$body,
3473 PhabricatorLiskDAO
$object,
3476 $href = PhabricatorEnv
::getProductionURI(
3477 '/settings/panel/emailpreferences/');
3478 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3485 protected function addHeadersAndCommentsToMailBody(
3486 PhabricatorMetaMTAMailBody
$body,
3488 $object_label = null,
3489 $object_uri = null) {
3491 // First, remove transactions which shouldn't be rendered in mail.
3492 foreach ($xactions as $key => $xaction) {
3493 if ($xaction->shouldHideForMail($xactions)) {
3494 unset($xactions[$key]);
3499 $headers_html = array();
3500 $comments = array();
3503 $seen_comment = false;
3504 foreach ($xactions as $xaction) {
3506 // Most mail has zero or one comments. In these cases, we render the
3507 // "alice added a comment." transaction in the header, like a normal
3510 // Some mail, like Differential undraft mail or "!history" mail, may
3511 // have two or more comments. In these cases, we'll put the first
3512 // "alice added a comment." transaction in the header normally, but
3513 // move the other transactions down so they provide context above the
3516 $comment = $this->getBodyForTextMail($xaction);
3517 if ($comment !== null) {
3519 $comments[] = array(
3520 'xaction' => $xaction,
3521 'comment' => $comment,
3522 'initial' => !$seen_comment,
3525 $is_comment = false;
3528 if (!$is_comment ||
!$seen_comment) {
3529 $header = $this->getTitleForTextMail($xaction);
3530 if ($header !== null) {
3531 $headers[] = $header;
3534 $header_html = $this->getTitleForHTMLMail($xaction);
3535 if ($header_html !== null) {
3536 $headers_html[] = $header_html;
3540 if ($xaction->hasChangeDetailsForMail()) {
3541 $details[] = $xaction;
3545 $seen_comment = true;
3549 $headers_text = implode("\n", $headers);
3550 $body->addRawPlaintextSection($headers_text);
3552 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3554 $header_button = null;
3555 if ($object_label !== null && $object_uri !== null) {
3556 $button_style = array(
3557 'text-decoration: none;',
3558 'padding: 4px 8px;',
3559 'margin: 0 8px 8px;',
3562 'font-weight: bold;',
3563 'border-radius: 3px;',
3564 'background-color: #F7F7F9;',
3565 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3566 'display: inline-block;',
3567 'border: 1px solid rgba(71,87,120,.2);',
3570 $header_button = phutil_tag(
3573 'style' => implode(' ', $button_style),
3574 'href' => $object_uri,
3579 $xactions_style = array();
3581 $header_action = phutil_tag(
3586 $header_action = phutil_tag(
3589 'style' => implode(' ', $xactions_style),
3593 // Add an extra newline to prevent the "View Object" button from
3594 // running into the transaction text in Mail.app text snippet
3599 $headers_html = phutil_tag(
3602 phutil_tag('tr', array(), array($header_action, $header_button)));
3604 $body->addRawHTMLSection($headers_html);
3606 foreach ($comments as $spec) {
3607 $xaction = $spec['xaction'];
3608 $comment = $spec['comment'];
3609 $is_initial = $spec['initial'];
3611 // If this is not the first comment in the mail, add the header showing
3612 // who wrote the comment immediately above the comment.
3614 $header = $this->getTitleForTextMail($xaction);
3615 if ($header !== null) {
3616 $body->addRawPlaintextSection($header);
3619 $header_html = $this->getTitleForHTMLMail($xaction);
3620 if ($header_html !== null) {
3621 $body->addRawHTMLSection($header_html);
3625 $body->addRemarkupSection(null, $comment);
3628 foreach ($details as $xaction) {
3629 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
3630 if ($details !== null) {
3631 $label = $this->getMailDiffSectionHeader($xaction);
3632 $body->addHTMLSection($label, $details);
3638 private function getMailDiffSectionHeader($xaction) {
3639 $type = $xaction->getTransactionType();
3641 $xtype = $this->getModularTransactionType($type);
3643 return $xtype->getMailDiffSectionHeader();
3646 return pht('EDIT DETAILS');
3652 protected function addCustomFieldsToMailBody(
3653 PhabricatorMetaMTAMailBody
$body,
3654 PhabricatorLiskDAO
$object,
3657 if ($object instanceof PhabricatorCustomFieldInterface
) {
3658 $field_list = PhabricatorCustomField
::getObjectFields(
3660 PhabricatorCustomField
::ROLE_TRANSACTIONMAIL
);
3661 $field_list->setViewer($this->getActor());
3662 $field_list->readFieldsFromStorage($object);
3664 foreach ($field_list->getFields() as $field) {
3665 $field->updateTransactionMailBody(
3677 private function runHeraldMailRules(array $messages) {
3678 foreach ($messages as $message) {
3679 $engine = new HeraldEngine();
3680 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
3681 ->setObject($message);
3683 $rules = $engine->loadRulesForAdapter($adapter);
3684 $effects = $engine->applyRules($rules, $adapter);
3685 $engine->applyEffects($effects, $adapter, $rules);
3690 /* -( Publishing Feed Stories )-------------------------------------------- */
3696 protected function shouldPublishFeedStory(
3697 PhabricatorLiskDAO
$object,
3706 protected function getFeedStoryType() {
3707 return 'PhabricatorApplicationTransactionFeedStory';
3714 protected function getFeedRelatedPHIDs(
3715 PhabricatorLiskDAO
$object,
3720 $this->getActingAsPHID(),
3723 if ($object instanceof PhabricatorProjectInterface
) {
3724 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3726 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3727 foreach ($project_phids as $project_phid) {
3728 $phids[] = $project_phid;
3739 protected function getFeedNotifyPHIDs(
3740 PhabricatorLiskDAO
$object,
3743 // If some transactions are forcing notification delivery, add the forced
3744 // recipients to the notify list.
3745 $force_list = array();
3746 foreach ($xactions as $xaction) {
3747 $force_phids = $xaction->getForceNotifyPHIDs();
3749 if (!$force_phids) {
3753 foreach ($force_phids as $force_phid) {
3754 $force_list[] = $force_phid;
3758 $to_list = $this->getMailTo($object);
3759 $cc_list = $this->getMailCC($object);
3761 $full_list = array_merge($force_list, $to_list, $cc_list);
3762 $full_list = array_fuse($full_list);
3764 return array_keys($full_list);
3771 protected function getFeedStoryData(
3772 PhabricatorLiskDAO
$object,
3775 $xactions = msortv($xactions, 'newActionStrengthSortVector');
3778 'objectPHID' => $object->getPHID(),
3779 'transactionPHIDs' => mpull($xactions, 'getPHID'),
3787 protected function publishFeedStory(
3788 PhabricatorLiskDAO
$object,
3790 array $mailed_phids) {
3792 // Remove transactions which don't publish feed stories or notifications.
3793 // These never show up anywhere, so we don't need to do anything with them.
3794 foreach ($xactions as $key => $xaction) {
3795 if (!$xaction->shouldHideForFeed()) {
3799 if (!$xaction->shouldHideForNotifications()) {
3803 unset($xactions[$key]);
3810 $related_phids = $this->feedRelatedPHIDs
;
3811 $subscribed_phids = $this->feedNotifyPHIDs
;
3813 // Remove muted users from the subscription list so they don't get
3814 // notifications, either.
3815 $muted_phids = $this->mailMutedPHIDs
;
3816 if (!is_array($muted_phids)) {
3817 $muted_phids = array();
3819 $subscribed_phids = array_fuse($subscribed_phids);
3820 foreach ($muted_phids as $muted_phid) {
3821 unset($subscribed_phids[$muted_phid]);
3823 $subscribed_phids = array_values($subscribed_phids);
3825 $story_type = $this->getFeedStoryType();
3826 $story_data = $this->getFeedStoryData($object, $xactions);
3828 $unexpandable_phids = $this->mailUnexpandablePHIDs
;
3829 if (!is_array($unexpandable_phids)) {
3830 $unexpandable_phids = array();
3833 id(new PhabricatorFeedStoryPublisher())
3834 ->setStoryType($story_type)
3835 ->setStoryData($story_data)
3836 ->setStoryTime(time())
3837 ->setStoryAuthorPHID($this->getActingAsPHID())
3838 ->setRelatedPHIDs($related_phids)
3839 ->setPrimaryObjectPHID($object->getPHID())
3840 ->setSubscribedPHIDs($subscribed_phids)
3841 ->setUnexpandablePHIDs($unexpandable_phids)
3842 ->setMailRecipientPHIDs($mailed_phids)
3843 ->setMailTags($this->getMailTags($object, $xactions))
3848 /* -( Search Index )------------------------------------------------------- */
3854 protected function supportsSearch() {
3859 /* -( Herald Integration )-------------------------------------------------- */
3862 protected function shouldApplyHeraldRules(
3863 PhabricatorLiskDAO
$object,
3868 protected function buildHeraldAdapter(
3869 PhabricatorLiskDAO
$object,
3871 throw new Exception(pht('No herald adapter specified.'));
3874 private function setHeraldAdapter(HeraldAdapter
$adapter) {
3875 $this->heraldAdapter
= $adapter;
3879 protected function getHeraldAdapter() {
3880 return $this->heraldAdapter
;
3883 private function setHeraldTranscript(HeraldTranscript
$transcript) {
3884 $this->heraldTranscript
= $transcript;
3888 protected function getHeraldTranscript() {
3889 return $this->heraldTranscript
;
3892 private function applyHeraldRules(
3893 PhabricatorLiskDAO
$object,
3896 $adapter = $this->buildHeraldAdapter($object, $xactions)
3897 ->setContentSource($this->getContentSource())
3898 ->setIsNewObject($this->getIsNewObject())
3899 ->setActingAsPHID($this->getActingAsPHID())
3900 ->setAppliedTransactions($xactions);
3902 if ($this->getApplicationEmail()) {
3903 $adapter->setApplicationEmail($this->getApplicationEmail());
3906 // If this editor is operating in silent mode, tell Herald that we aren't
3907 // going to send any mail. This allows it to skip "the first time this
3908 // rule matches, send me an email" rules which would otherwise match even
3909 // though we aren't going to send any mail.
3910 if ($this->getIsSilent()) {
3911 $adapter->setForbiddenAction(
3912 HeraldMailableState
::STATECONST
,
3913 HeraldCoreStateReasons
::REASON_SILENT
);
3916 $xscript = HeraldEngine
::loadAndApplyRules($adapter);
3918 $this->setHeraldAdapter($adapter);
3919 $this->setHeraldTranscript($xscript);
3921 if ($adapter instanceof HarbormasterBuildableAdapterInterface
) {
3922 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
3924 HarbormasterBuildable
::applyBuildPlans(
3926 $adapter->getHarbormasterContainerPHID(),
3927 $adapter->getQueuedHarbormasterBuildRequests());
3929 // Whether we queued any builds or not, any automatic buildable for this
3930 // object is now done preparing builds and can transition into a
3931 // completed status.
3932 $buildables = id(new HarbormasterBuildableQuery())
3933 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3934 ->withManualBuildables(false)
3935 ->withBuildablePHIDs(array($buildable_phid))
3937 foreach ($buildables as $buildable) {
3938 // If this buildable has already moved beyond preparation, we don't
3939 // need to nudge it again.
3940 if (!$buildable->isPreparing()) {
3943 $buildable->sendMessage(
3945 HarbormasterMessageType
::BUILDABLE_BUILD
,
3950 $this->mustEncrypt
= $adapter->getMustEncryptReasons();
3952 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
3953 foreach ($this->subEditors
as $sub_editor) {
3954 $sub_editor->mustEncrypt
= $this->mustEncrypt
;
3957 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
3958 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
3960 $queue_xactions = $adapter->getQueuedTransactions();
3963 array_values($apply_xactions),
3964 array_values($queue_xactions));
3967 protected function didApplyHeraldRules(
3968 PhabricatorLiskDAO
$object,
3969 HeraldAdapter
$adapter,
3970 HeraldTranscript
$transcript) {
3975 /* -( Custom Fields )------------------------------------------------------ */
3981 private function getCustomFieldForTransaction(
3982 PhabricatorLiskDAO
$object,
3983 PhabricatorApplicationTransaction
$xaction) {
3985 $field_key = $xaction->getMetadataValue('customfield:key');
3987 throw new Exception(
3989 "Custom field transaction has no '%s'!",
3990 'customfield:key'));
3993 $field = PhabricatorCustomField
::getObjectField(
3995 PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
,
3999 throw new Exception(
4001 "Custom field transaction has invalid '%s'; field '%s' ".
4002 "is disabled or does not exist.",
4007 if (!$field->shouldAppearInApplicationTransactions()) {
4008 throw new Exception(
4010 "Custom field transaction '%s' does not implement ".
4011 "integration for %s.",
4013 'ApplicationTransactions'));
4016 $field->setViewer($this->getActor());
4022 /* -( Files )-------------------------------------------------------------- */
4026 * Extract the PHIDs of any files which these transactions attach.
4030 private function extractFilePHIDs(
4031 PhabricatorLiskDAO
$object,
4034 $changes = $this->getRemarkupChanges($xactions);
4035 $blocks = mpull($changes, 'getNewValue');
4039 $phids[] = PhabricatorMarkupEngine
::extractFilePHIDsFromEmbeddedFiles(
4044 foreach ($xactions as $xaction) {
4045 $type = $xaction->getTransactionType();
4047 $xtype = $this->getModularTransactionType($type);
4049 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4051 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4057 $phids = array_unique(array_filter(array_mergev($phids)));
4062 // Only let a user attach files they can actually see, since this would
4063 // otherwise let you access any file by attaching it to an object you have
4064 // view permission on.
4066 $files = id(new PhabricatorFileQuery())
4067 ->setViewer($this->getActor())
4071 return mpull($files, 'getPHID');
4077 protected function extractFilePHIDsFromCustomTransaction(
4078 PhabricatorLiskDAO
$object,
4079 PhabricatorApplicationTransaction
$xaction) {
4087 private function attachFiles(
4088 PhabricatorLiskDAO
$object,
4089 array $file_phids) {
4095 $editor = new PhabricatorEdgeEditor();
4097 $src = $object->getPHID();
4098 $type = PhabricatorObjectHasFileEdgeType
::EDGECONST
;
4099 foreach ($file_phids as $dst) {
4100 $editor->addEdge($src, $type, $dst);
4106 private function applyInverseEdgeTransactions(
4107 PhabricatorLiskDAO
$object,
4108 PhabricatorApplicationTransaction
$xaction,
4111 $old = $xaction->getOldValue();
4112 $new = $xaction->getNewValue();
4114 $add = array_keys(array_diff_key($new, $old));
4115 $rem = array_keys(array_diff_key($old, $new));
4117 $add = array_fuse($add);
4118 $rem = array_fuse($rem);
4121 $nodes = id(new PhabricatorObjectQuery())
4122 ->setViewer($this->requireActor())
4126 $object_phid = $object->getPHID();
4128 foreach ($nodes as $node) {
4129 if (!($node instanceof PhabricatorApplicationTransactionInterface
)) {
4133 if ($node instanceof PhabricatorUser
) {
4134 // TODO: At least for now, don't record inverse edge transactions
4135 // for users (for example, "alincoln joined project X"): Feed fills
4136 // this role instead.
4140 $node_phid = $node->getPHID();
4141 $editor = $node->getApplicationTransactionEditor();
4142 $template = $node->getApplicationTransactionTemplate();
4144 // See T13082. We have to build these transactions with synthetic values
4145 // because we've already applied the actual edit to the edge database
4146 // table. If we try to apply this transaction naturally, it will no-op
4147 // itself because it doesn't have any effect.
4149 $edge_query = id(new PhabricatorEdgeQuery())
4150 ->withSourcePHIDs(array($node_phid))
4151 ->withEdgeTypes(array($inverse_type));
4153 $edge_query->execute();
4155 $edge_phids = $edge_query->getDestinationPHIDs();
4156 $edge_phids = array_fuse($edge_phids);
4158 $new_phids = $edge_phids;
4159 $old_phids = $edge_phids;
4161 if (isset($add[$node_phid])) {
4162 unset($old_phids[$object_phid]);
4164 $old_phids[$object_phid] = $object_phid;
4168 ->setTransactionType($xaction->getTransactionType())
4169 ->setMetadataValue('edge:type', $inverse_type)
4170 ->setOldValue($old_phids)
4171 ->setNewValue($new_phids);
4173 $editor = $this->newSubEditor($editor)
4174 ->setContinueOnNoEffect(true)
4175 ->setContinueOnMissingFields(true)
4176 ->setIsInverseEdgeEditor(true);
4178 $editor->applyTransactions($node, array($template));
4183 /* -( Workers )------------------------------------------------------------ */
4187 * Load any object state which is required to publish transactions.
4189 * This hook is invoked in the main process before we compute data related
4190 * to publishing transactions (like email "To" and "CC" lists), and again in
4191 * the worker before publishing occurs.
4193 * @return object Publishable object.
4196 protected function willPublish(PhabricatorLiskDAO
$object, array $xactions) {
4202 * Convert the editor state to a serializable dictionary which can be passed
4205 * This data will be loaded with @{method:loadWorkerState} in the worker.
4207 * @return dict<string, wild> Serializable editor state.
4210 private function getWorkerState() {
4212 foreach ($this->getAutomaticStateProperties() as $property) {
4213 $state[$property] = $this->$property;
4216 $custom_state = $this->getCustomWorkerState();
4217 $custom_encoding = $this->getCustomWorkerStateEncoding();
4220 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4221 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4222 'custom.encoding' => $custom_encoding,
4230 * Hook; return custom properties which need to be passed to workers.
4232 * @return dict<string, wild> Custom properties.
4235 protected function getCustomWorkerState() {
4241 * Hook; return storage encoding for custom properties which need to be
4242 * passed to workers.
4244 * This primarily allows binary data to be passed to workers and survive
4247 * @return dict<string, string> Property encodings.
4250 protected function getCustomWorkerStateEncoding() {
4256 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4258 * This method is used to load state when running worker operations.
4260 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4264 final public function loadWorkerState(array $state) {
4265 foreach ($this->getAutomaticStateProperties() as $property) {
4266 $this->$property = idx($state, $property);
4269 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4270 $this->setExcludeMailRecipientPHIDs($exclude);
4272 $custom_state = idx($state, 'custom', array());
4273 $custom_encodings = idx($state, 'custom.encoding', array());
4274 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4276 $this->loadCustomWorkerState($custom);
4283 * Hook; set custom properties on the editor from data emitted by
4284 * @{method:getCustomWorkerState}.
4286 * @param dict<string, wild> Custom state,
4287 * from @{method:getCustomWorkerState}.
4291 protected function loadCustomWorkerState(array $state) {
4297 * Get a list of object properties which should be automatically sent to
4298 * workers in the state data.
4300 * These properties will be automatically stored and loaded by the editor in
4303 * @return list<string> List of properties.
4306 private function getAutomaticStateProperties() {
4311 'heraldForcedEmailPHIDs',
4317 'feedShouldPublish',
4321 'mailUnexpandablePHIDs',
4330 * Apply encodings prior to storage.
4332 * See @{method:getCustomWorkerStateEncoding}.
4334 * @param map<string, wild> Map of values to encode.
4335 * @param map<string, string> Map of encodings to apply.
4336 * @return map<string, wild> Map of encoded values.
4339 private function encodeStateForStorage(
4343 foreach ($state as $key => $value) {
4344 $encoding = idx($encodings, $key);
4345 switch ($encoding) {
4346 case self
::STORAGE_ENCODING_BINARY
:
4347 // The mechanics of this encoding (serialize + base64) are a little
4348 // awkward, but it allows us encode arrays and still be JSON-safe
4349 // with binary data.
4351 $value = @serialize
($value);
4352 if ($value === false) {
4353 throw new Exception(
4355 'Failed to serialize() value for key "%s".',
4359 $value = base64_encode($value);
4360 if ($value === false) {
4361 throw new Exception(
4363 'Failed to base64 encode value for key "%s".',
4368 $state[$key] = $value;
4376 * Undo storage encoding applied when storing state.
4378 * See @{method:getCustomWorkerStateEncoding}.
4380 * @param map<string, wild> Map of encoded values.
4381 * @param map<string, string> Map of encodings.
4382 * @return map<string, wild> Map of decoded values.
4385 private function decodeStateFromStorage(
4389 foreach ($state as $key => $value) {
4390 $encoding = idx($encodings, $key);
4391 switch ($encoding) {
4392 case self
::STORAGE_ENCODING_BINARY
:
4393 $value = base64_decode($value);
4394 if ($value === false) {
4395 throw new Exception(
4397 'Failed to base64_decode() value for key "%s".',
4401 $value = unserialize($value);
4404 $state[$key] = $value;
4412 * Remove conflicts from a list of projects.
4414 * Objects aren't allowed to be tagged with multiple milestones in the same
4415 * group, nor projects such that one tag is the ancestor of any other tag.
4416 * If the list of PHIDs include mutually exclusive projects, remove the
4417 * conflicting projects.
4419 * @param list<phid> List of project PHIDs.
4420 * @return list<phid> List with conflicts removed.
4422 private function applyProjectConflictRules(array $phids) {
4427 // Overall, the last project in the list wins in cases of conflict (so when
4428 // you add something, the thing you just added sticks and removes older
4431 // Beyond that, there are two basic cases:
4433 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4434 // If multiple projects are milestones of the same parent, we only keep the
4437 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4438 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4439 // remove "A > B" and keep "A".
4441 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4442 // if one project is an ancestor of another. It's OK to have something
4443 // tagged with multiple projects which share a common ancestor, so long as
4444 // they are not mutual ancestors.
4446 $viewer = PhabricatorUser
::getOmnipotentUser();
4448 $projects = id(new PhabricatorProjectQuery())
4449 ->setViewer($viewer)
4450 ->withPHIDs(array_keys($phids))
4452 $projects = mpull($projects, null, 'getPHID');
4454 // We're going to build a map from each project with milestones to the last
4455 // milestone in the list. This last milestone is the milestone we'll keep.
4456 $milestone_map = array();
4458 // We're going to build a set of the projects which have no descendants
4459 // later in the list. This allows us to apply both ancestor rules.
4460 $ancestor_map = array();
4462 foreach ($phids as $phid => $ignored) {
4463 $project = idx($projects, $phid);
4468 // This is the last milestone we've seen, so set it as the selection for
4469 // the project's parent. This might be setting a new value or overwriting
4470 // an earlier value.
4471 if ($project->isMilestone()) {
4472 $parent_phid = $project->getParentProjectPHID();
4473 $milestone_map[$parent_phid] = $phid;
4476 // Since this is the last item in the list we've examined so far, add it
4477 // to the set of projects with no later descendants.
4478 $ancestor_map[$phid] = $phid;
4480 // Remove any ancestors from the set, since this is a later descendant.
4481 foreach ($project->getAncestorProjects() as $ancestor) {
4482 $ancestor_phid = $ancestor->getPHID();
4483 unset($ancestor_map[$ancestor_phid]);
4487 // Now that we've built the maps, we can throw away all the projects which
4489 foreach ($phids as $phid => $ignored) {
4490 $project = idx($projects, $phid);
4493 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4494 // but leaving it untouched is less likely to cause collateral damage.
4498 // If this was a milestone, check if it was the last milestone from its
4499 // group in the list. If not, remove it from the list.
4500 if ($project->isMilestone()) {
4501 $parent_phid = $project->getParentProjectPHID();
4502 if ($milestone_map[$parent_phid] !== $phid) {
4503 unset($phids[$phid]);
4508 // If a later project in the list is a subproject of this one, it will
4509 // have removed ancestors from the map. If this project does not point
4510 // at itself in the ancestor map, it should be discarded in favor of a
4511 // subproject that comes later.
4512 if (idx($ancestor_map, $phid) !== $phid) {
4513 unset($phids[$phid]);
4517 // If a later project in the list is an ancestor of this one, it will
4518 // have added itself to the map. If any ancestor of this project points
4519 // at itself in the map, this project should be discarded in favor of
4520 // that later ancestor.
4521 foreach ($project->getAncestorProjects() as $ancestor) {
4522 $ancestor_phid = $ancestor->getPHID();
4523 if (isset($ancestor_map[$ancestor_phid])) {
4524 unset($phids[$phid]);
4534 * When the view policy for an object is changed, scramble the secret keys
4535 * for attached files to invalidate existing URIs.
4537 private function scrambleFileSecrets($object) {
4538 // If this is a newly created object, we don't need to scramble anything
4539 // since it couldn't have been previously published.
4540 if ($this->getIsNewObject()) {
4544 // If the object is a file itself, scramble it.
4545 if ($object instanceof PhabricatorFile
) {
4546 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4547 $object->scrambleSecret();
4552 $phid = $object->getPHID();
4554 $attached_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
4556 PhabricatorObjectHasFileEdgeType
::EDGECONST
);
4557 if (!$attached_phids) {
4561 $omnipotent_viewer = PhabricatorUser
::getOmnipotentUser();
4563 $files = id(new PhabricatorFileQuery())
4564 ->setViewer($omnipotent_viewer)
4565 ->withPHIDs($attached_phids)
4567 foreach ($files as $file) {
4568 $view_policy = $file->getViewPolicy();
4569 if ($this->shouldScramblePolicy($view_policy)) {
4570 $file->scrambleSecret();
4578 * Check if a policy is strong enough to justify scrambling. Objects which
4579 * are set to very open policies don't need to scramble their files, and
4580 * files with very open policies don't need to be scrambled when associated
4583 private function shouldScramblePolicy($policy) {
4585 case PhabricatorPolicies
::POLICY_PUBLIC
:
4586 case PhabricatorPolicies
::POLICY_USER
:
4593 private function updateWorkboardColumns($object, $const, $old, $new) {
4594 // If an object is removed from a project, remove it from any proxy
4595 // columns for that project. This allows a task which is moved up from a
4596 // milestone to the parent to move back into the "Backlog" column on the
4597 // parent workboard.
4599 if ($const != PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
) {
4603 // TODO: This should likely be some future WorkboardInterface.
4604 $appears_on_workboards = ($object instanceof ManiphestTask
);
4605 if (!$appears_on_workboards) {
4609 $removed_phids = array_keys(array_diff_key($old, $new));
4610 if (!$removed_phids) {
4614 // Find any proxy columns for the removed projects.
4615 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4616 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4617 ->withProxyPHIDs($removed_phids)
4619 if (!$proxy_columns) {
4623 $proxy_phids = mpull($proxy_columns, 'getPHID');
4625 $position_table = new PhabricatorProjectColumnPosition();
4626 $conn_w = $position_table->establishConnection('w');
4630 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4631 $position_table->getTableName(),
4636 private function getModularTransactionTypes() {
4637 if ($this->modularTypes
=== null) {
4638 $template = $this->object->getApplicationTransactionTemplate();
4639 if ($template instanceof PhabricatorModularTransaction
) {
4640 $xtypes = $template->newModularTransactionTypes();
4641 foreach ($xtypes as $key => $xtype) {
4642 $xtype = clone $xtype;
4643 $xtype->setEditor($this);
4644 $xtypes[$key] = $xtype;
4650 $this->modularTypes
= $xtypes;
4653 return $this->modularTypes
;
4656 private function getModularTransactionType($type) {
4657 $types = $this->getModularTransactionTypes();
4658 return idx($types, $type);
4661 public function getCreateObjectTitle($author, $object) {
4662 return pht('%s created this object.', $author);
4665 public function getCreateObjectTitleForFeed($author, $object) {
4666 return pht('%s created an object: %s.', $author, $object);
4669 /* -( Queue )-------------------------------------------------------------- */
4671 protected function queueTransaction(
4672 PhabricatorApplicationTransaction
$xaction) {
4673 $this->transactionQueue
[] = $xaction;
4677 private function flushTransactionQueue($object) {
4678 if (!$this->transactionQueue
) {
4682 $xactions = $this->transactionQueue
;
4683 $this->transactionQueue
= array();
4685 $editor = $this->newEditorCopy();
4687 return $editor->applyTransactions($object, $xactions);
4690 final protected function newSubEditor(
4691 PhabricatorApplicationTransactionEditor
$template = null) {
4692 $editor = $this->newEditorCopy($template);
4694 $editor->parentEditor
= $this;
4695 $this->subEditors
[] = $editor;
4700 private function newEditorCopy(
4701 PhabricatorApplicationTransactionEditor
$template = null) {
4702 if ($template === null) {
4703 $template = newv(get_class($this), array());
4706 $editor = id(clone $template)
4707 ->setActor($this->getActor())
4708 ->setContentSource($this->getContentSource())
4709 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
4710 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
4711 ->setParentMessageID($this->getParentMessageID())
4712 ->setIsSilent($this->getIsSilent());
4714 if ($this->actingAsPHID
!== null) {
4715 $editor->setActingAsPHID($this->actingAsPHID
);
4718 $editor->mustEncrypt
= $this->mustEncrypt
;
4719 $editor->transactionGroupID
= $this->getTransactionGroupID();
4725 /* -( Stamps )------------------------------------------------------------- */
4728 public function newMailStampTemplates($object) {
4729 $actor = $this->getActor();
4731 $templates = array();
4733 $extensions = $this->newMailExtensions($object);
4734 foreach ($extensions as $extension) {
4735 $stamps = $extension->newMailStampTemplates($object);
4736 foreach ($stamps as $stamp) {
4737 $key = $stamp->getKey();
4738 if (isset($templates[$key])) {
4739 throw new Exception(
4741 'Mail extension ("%s") defines a stamp template with the '.
4742 'same key ("%s") as another template. Each stamp template '.
4743 'must have a unique key.',
4744 get_class($extension),
4748 $stamp->setViewer($actor);
4750 $templates[$key] = $stamp;
4757 final public function getMailStamp($key) {
4758 if (!isset($this->stampTemplates
)) {
4759 throw new PhutilInvalidStateException('newMailStampTemplates');
4762 if (!isset($this->stampTemplates
[$key])) {
4763 throw new Exception(
4765 'Editor ("%s") has no mail stamp template with provided key ("%s").',
4770 return $this->stampTemplates
[$key];
4773 private function newMailStamps($object, array $xactions) {
4774 $actor = $this->getActor();
4776 $this->stampTemplates
= $this->newMailStampTemplates($object);
4778 $extensions = $this->newMailExtensions($object);
4780 foreach ($extensions as $extension) {
4781 $extension->newMailStamps($object, $xactions);
4784 return $this->stampTemplates
;
4787 private function newMailExtensions($object) {
4788 $actor = $this->getActor();
4790 $all_extensions = PhabricatorMailEngineExtension
::getAllExtensions();
4792 $extensions = array();
4793 foreach ($all_extensions as $key => $template) {
4794 $extension = id(clone $template)
4798 if ($extension->supportsObject($object)) {
4799 $extensions[$key] = $extension;
4806 protected function newAuxiliaryMail($object, array $xactions) {
4810 private function generateMailStamps($object, $data) {
4811 if (!$data ||
!is_array($data)) {
4815 $templates = $this->newMailStampTemplates($object);
4816 foreach ($data as $spec) {
4817 if (!is_array($spec)) {
4821 $key = idx($spec, 'key');
4822 if (!isset($templates[$key])) {
4826 $type = idx($spec, 'type');
4827 if ($templates[$key]->getStampType() !== $type) {
4831 $value = idx($spec, 'value');
4832 $templates[$key]->setValueFromDictionary($value);
4836 foreach ($templates as $template) {
4837 $value = $template->getValueForRendering();
4839 $rendered = $template->renderStamps($value);
4840 if ($rendered === null) {
4844 $rendered = (array)$rendered;
4845 foreach ($rendered as $stamp) {
4846 $results[] = $stamp;
4850 natcasesort($results);
4855 public function getRemovedRecipientPHIDs() {
4856 return $this->mailRemovedPHIDs
;
4859 private function buildOldRecipientLists($object, $xactions) {
4860 // See T4776. Before we start making any changes, build a list of the old
4861 // recipients. If a change removes a user from the recipient list for an
4862 // object we still want to notify the user about that change. This allows
4863 // them to respond if they didn't want to be removed.
4865 if (!$this->shouldSendMail($object, $xactions)) {
4869 $this->oldTo
= $this->getMailTo($object);
4870 $this->oldCC
= $this->getMailCC($object);
4875 private function applyOldRecipientLists() {
4876 $actor_phid = $this->getActingAsPHID();
4878 // If you took yourself off the recipient list (for example, by
4879 // unsubscribing or resigning) assume that you know what you did and
4880 // don't need to be notified.
4882 // If you just moved from "To" to "Cc" (or vice versa), you're still a
4883 // recipient so we don't need to add you back in.
4885 $map = array_fuse($this->mailToPHIDs
) +
array_fuse($this->mailCCPHIDs
);
4887 foreach ($this->oldTo
as $phid) {
4888 if ($phid === $actor_phid) {
4892 if (isset($map[$phid])) {
4896 $this->mailToPHIDs
[] = $phid;
4897 $this->mailRemovedPHIDs
[] = $phid;
4900 foreach ($this->oldCC
as $phid) {
4901 if ($phid === $actor_phid) {
4905 if (isset($map[$phid])) {
4909 $this->mailCCPHIDs
[] = $phid;
4910 $this->mailRemovedPHIDs
[] = $phid;
4916 private function queueWebhooks($object, array $xactions) {
4917 $hook_viewer = PhabricatorUser
::getOmnipotentUser();
4919 $webhook_map = $this->webhookMap
;
4920 if (!is_array($webhook_map)) {
4921 $webhook_map = array();
4924 // Add any "Firehose" hooks to the list of hooks we're going to call.
4925 $firehose_hooks = id(new HeraldWebhookQuery())
4926 ->setViewer($hook_viewer)
4929 HeraldWebhook
::HOOKSTATUS_FIREHOSE
,
4932 foreach ($firehose_hooks as $firehose_hook) {
4933 // This is "the hook itself is the reason this hook is being called",
4934 // since we're including it because it's configured as a firehose
4936 $hook_phid = $firehose_hook->getPHID();
4937 $webhook_map[$hook_phid][] = $hook_phid;
4940 if (!$webhook_map) {
4944 // NOTE: We're going to queue calls to disabled webhooks, they'll just
4945 // immediately fail in the worker queue. This makes the behavior more
4948 $call_hooks = id(new HeraldWebhookQuery())
4949 ->setViewer($hook_viewer)
4950 ->withPHIDs(array_keys($webhook_map))
4953 foreach ($call_hooks as $call_hook) {
4954 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
4956 $request = HeraldWebhookRequest
::initializeNewWebhookRequest($call_hook)
4957 ->setObjectPHID($object->getPHID())
4958 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
4959 ->setTriggerPHIDs($trigger_phids)
4960 ->setRetryMode(HeraldWebhookRequest
::RETRY_FOREVER
)
4961 ->setIsSilentAction((bool)$this->getIsSilent())
4962 ->setIsSecureAction((bool)$this->getMustEncrypt())
4965 $request->queueCall();
4969 private function hasWarnings($object, $xaction) {
4970 // TODO: For the moment, this is a very un-modular hack to support
4971 // a small number of warnings related to draft revisions. See PHI433.
4973 if (!($object instanceof DifferentialRevision
)) {
4977 $type = $xaction->getTransactionType();
4979 // TODO: This doesn't warn for inlines in Audit, even though they have
4980 // the same overall workflow.
4981 if ($type === DifferentialTransaction
::TYPE_INLINE
) {
4982 return (bool)$xaction->getComment()->getAttribute('editing', false);
4985 if (!$object->isDraft()) {
4989 if ($type != PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
4993 // We're only going to raise a warning if the transaction adds subscribers
4994 // other than the acting user. (This implementation is clumsy because the
4995 // code runs before a lot of normalization occurs.)
4997 $old = $this->getTransactionOldValue($object, $xaction);
4998 $new = $this->getPHIDTransactionNewValue($xaction, $old);
4999 $old = array_fuse($old);
5000 $new = array_fuse($new);
5001 $add = array_diff_key($new, $old);
5003 unset($add[$this->getActingAsPHID()]);
5012 private function buildHistoryMail(PhabricatorLiskDAO
$object) {
5013 $viewer = $this->requireActor();
5014 $recipient_phid = $this->getActingAsPHID();
5016 // Load every transaction so we can build a mail message with a complete
5017 // history for the object.
5018 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject($object);
5020 ->setViewer($viewer)
5021 ->withObjectPHIDs(array($object->getPHID()))
5023 $xactions = array_reverse($xactions);
5025 $mail_messages = $this->buildMailWithRecipients(
5028 array($recipient_phid),
5031 $mail = head($mail_messages);
5033 // Since the user explicitly requested "!history", force delivery of this
5034 // message regardless of their other mail settings.
5035 $mail->setForceDelivery(true);
5040 public function newAutomaticInlineTransactions(
5041 PhabricatorLiskDAO
$object,
5043 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5045 $actor = $this->getActor();
5047 $inlines = id(clone $query_template)
5049 ->withObjectPHIDs(array($object->getPHID()))
5050 ->withPublishableComments(true)
5051 ->needAppliedDrafts(true)
5052 ->needReplyToComments(true)
5054 $inlines = msort($inlines, 'getID');
5056 $xactions = array();
5058 foreach ($inlines as $key => $inline) {
5059 $xactions[] = $object->getApplicationTransactionTemplate()
5060 ->setTransactionType($transaction_type)
5061 ->attachComment($inline);
5064 $state_xaction = $this->newInlineStateTransaction(
5068 if ($state_xaction) {
5069 $xactions[] = $state_xaction;
5075 protected function newInlineStateTransaction(
5076 PhabricatorLiskDAO
$object,
5077 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5079 $actor_phid = $this->getActingAsPHID();
5080 $author_phid = $object->getAuthorPHID();
5081 $actor_is_author = ($actor_phid == $author_phid);
5083 $state_map = PhabricatorTransactions
::getInlineStateMap();
5085 $inline_query = id(clone $query_template)
5086 ->setViewer($this->getActor())
5087 ->withObjectPHIDs(array($object->getPHID()))
5088 ->withFixedStates(array_keys($state_map))
5089 ->withPublishableComments(true);
5091 if ($actor_is_author) {
5092 $inline_query->withPublishedComments(true);
5095 $inlines = $inline_query->execute();
5101 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5102 $new_value = array();
5103 foreach ($old_value as $key => $state) {
5104 $new_value[$key] = $state_map[$state];
5107 // See PHI995. Copy some information about the inlines into the transaction
5108 // so we can tailor rendering behavior. In particular, we don't want to
5109 // render transactions about users marking their own inlines as "Done".
5111 $inline_details = array();
5112 foreach ($inlines as $inline) {
5113 $inline_details[$inline->getPHID()] = array(
5114 'authorPHID' => $inline->getAuthorPHID(),
5118 return $object->getApplicationTransactionTemplate()
5119 ->setTransactionType(PhabricatorTransactions
::TYPE_INLINESTATE
)
5120 ->setIgnoreOnNoEffect(true)
5121 ->setMetadataValue('inline.details', $inline_details)
5122 ->setOldValue($old_value)
5123 ->setNewValue($new_value);
5126 private function requireMFA(PhabricatorLiskDAO
$object, array $xactions) {
5127 $actor = $this->getActor();
5129 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5130 if ($actor->isOmnipotent()) {
5134 $editor_class = get_class($this);
5136 $object_phid = $object->getPHID();
5138 $workflow_key = sprintf(
5139 'editor(%s).phid(%s)',
5143 $workflow_key = sprintf(
5148 $request = $this->getRequest();
5149 if ($request === null) {
5150 $source_type = $this->getContentSource()->getSourceTypeConstant();
5151 $conduit_type = PhabricatorConduitContentSource
::SOURCECONST
;
5152 $is_conduit = ($source_type === $conduit_type);
5154 throw new Exception(
5156 'This transaction group requires MFA to apply, but you can not '.
5157 'provide an MFA response via Conduit. Edit this object via the '.
5160 throw new Exception(
5162 'This transaction group requires MFA to apply, but the Editor was '.
5163 'not configured with a Request. This workflow can not perform an '.
5168 $cancel_uri = $this->getCancelURI();
5169 if ($cancel_uri === null) {
5170 throw new Exception(
5172 'This transaction group requires MFA to apply, but the Editor was '.
5173 'not configured with a Cancel URI. This workflow can not perform '.
5177 $token = id(new PhabricatorAuthSessionEngine())
5178 ->setWorkflowKey($workflow_key)
5179 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5181 if (!$token->getIsUnchallengedToken()) {
5182 foreach ($xactions as $xaction) {
5183 $xaction->setIsMFATransaction(true);
5188 private function newMFATransactions(
5189 PhabricatorLiskDAO
$object,
5192 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface
);
5194 $engine = PhabricatorEditEngineMFAEngine
::newEngineForObject($object)
5195 ->setViewer($this->getActor());
5196 $require_mfa = $engine->shouldRequireMFA();
5197 $try_mfa = $engine->shouldTryMFA();
5199 $require_mfa = false;
5203 // If the user is mentioning an MFA object on another object or creating
5204 // a relationship like "parent" or "child" to this object, we always
5205 // allow the edit to move forward without requiring MFA.
5206 if ($this->getIsInverseEdgeEditor()) {
5210 if (!$require_mfa) {
5211 // If the object hasn't already opted into MFA, see if any of the
5212 // transactions want it.
5214 foreach ($xactions as $xaction) {
5215 $type = $xaction->getTransactionType();
5217 $xtype = $this->getModularTransactionType($type);
5219 $xtype = clone $xtype;
5220 $xtype->setStorage($xaction);
5221 if ($xtype->shouldTryMFA($object, $xaction)) {
5230 $this->setShouldRequireMFA(true);
5236 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
5239 foreach ($xactions as $xaction) {
5240 if ($xaction->getTransactionType() === $type_mfa) {
5250 $template = $object->getApplicationTransactionTemplate();
5252 $mfa_xaction = id(clone $template)
5253 ->setTransactionType($type_mfa)
5254 ->setNewValue(true);
5256 array_unshift($xactions, $mfa_xaction);
5261 private function getTitleForTextMail(
5262 PhabricatorApplicationTransaction
$xaction) {
5263 $type = $xaction->getTransactionType();
5265 $xtype = $this->getModularTransactionType($type);
5267 $xtype = clone $xtype;
5268 $xtype->setStorage($xaction);
5269 $comment = $xtype->getTitleForTextMail();
5270 if ($comment !== false) {
5275 return $xaction->getTitleForTextMail();
5278 private function getTitleForHTMLMail(
5279 PhabricatorApplicationTransaction
$xaction) {
5280 $type = $xaction->getTransactionType();
5282 $xtype = $this->getModularTransactionType($type);
5284 $xtype = clone $xtype;
5285 $xtype->setStorage($xaction);
5286 $comment = $xtype->getTitleForHTMLMail();
5287 if ($comment !== false) {
5292 return $xaction->getTitleForHTMLMail();
5296 private function getBodyForTextMail(
5297 PhabricatorApplicationTransaction
$xaction) {
5298 $type = $xaction->getTransactionType();
5300 $xtype = $this->getModularTransactionType($type);
5302 $xtype = clone $xtype;
5303 $xtype->setStorage($xaction);
5304 $comment = $xtype->getBodyForTextMail();
5305 if ($comment !== false) {
5310 return $xaction->getBodyForMail();
5313 private function isLockOverrideTransaction(
5314 PhabricatorApplicationTransaction
$xaction) {
5316 // See PHI1209. When an object is locked, certain types of transactions
5317 // can still be applied without requiring a policy check, like subscribing
5318 // or unsubscribing. We don't want these transactions to show the "Lock
5319 // Override" icon in the transaction timeline.
5321 // We could test if a transaction did no direct policy checks, but it may
5322 // have done additional policy checks during validation, so this is not a
5323 // reliable test (and could cause false negatives, where edits which did
5324 // override a lock are not marked properly).
5326 // For now, do this in a narrow way and just check against a hard-coded
5327 // list of non-override transaction situations. Some day, this should
5328 // likely be modularized.
5331 // Inverse edge edits don't interact with locks.
5332 if ($this->getIsInverseEdgeEditor()) {
5336 // For now, all edits other than subscribes always override locks.
5337 $type = $xaction->getTransactionType();
5338 if ($type !== PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5342 // Subscribes override locks if they affect any users other than the
5345 $acting_phid = $this->getActingAsPHID();
5347 $old = array_fuse($xaction->getOldValue());
5348 $new = array_fuse($xaction->getNewValue());
5349 $add = array_diff_key($new, $old);
5350 $rem = array_diff_key($old, $new);
5353 foreach ($all as $phid) {
5354 if ($phid !== $acting_phid) {
5363 /* -( Extensions )--------------------------------------------------------- */
5366 private function validateTransactionsWithExtensions(
5367 PhabricatorLiskDAO
$object,
5371 $extensions = $this->getEditorExtensions();
5372 foreach ($extensions as $extension) {
5373 $extension_errors = $extension
5374 ->setObject($object)
5375 ->validateTransactions($object, $xactions);
5377 assert_instances_of(
5379 'PhabricatorApplicationTransactionValidationError');
5381 $errors[] = $extension_errors;
5384 return array_mergev($errors);
5387 private function getEditorExtensions() {
5388 if ($this->extensions
=== null) {
5389 $this->extensions
= $this->newEditorExtensions();
5391 return $this->extensions
;
5394 private function newEditorExtensions() {
5395 $extensions = PhabricatorEditorExtension
::getAllExtensions();
5397 $actor = $this->getActor();
5398 $object = $this->object;
5399 foreach ($extensions as $key => $extension) {
5401 $extension = id(clone $extension)
5404 ->setObject($object);
5406 if (!$extension->supportsObject($this, $object)) {
5407 unset($extensions[$key]);
5411 $extensions[$key] = $extension;