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 = phutil_string_cast($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 $types[] = PhabricatorTransactions
::TYPE_FILE
;
337 if ($this->object instanceof PhabricatorEditEngineSubtypeInterface
) {
338 $types[] = PhabricatorTransactions
::TYPE_SUBTYPE
;
341 if ($this->object instanceof PhabricatorSubscribableInterface
) {
342 $types[] = PhabricatorTransactions
::TYPE_SUBSCRIBERS
;
345 if ($this->object instanceof PhabricatorCustomFieldInterface
) {
346 $types[] = PhabricatorTransactions
::TYPE_CUSTOMFIELD
;
349 if ($this->object instanceof PhabricatorTokenReceiverInterface
) {
350 $types[] = PhabricatorTransactions
::TYPE_TOKEN
;
353 if ($this->object instanceof PhabricatorProjectInterface ||
354 $this->object instanceof PhabricatorMentionableInterface
) {
355 $types[] = PhabricatorTransactions
::TYPE_EDGE
;
358 if ($this->object instanceof PhabricatorSpacesInterface
) {
359 $types[] = PhabricatorTransactions
::TYPE_SPACE
;
362 $types[] = PhabricatorTransactions
::TYPE_MFA
;
364 $template = $this->object->getApplicationTransactionTemplate();
365 if ($template instanceof PhabricatorModularTransaction
) {
366 $xtypes = $template->newModularTransactionTypes();
367 foreach ($xtypes as $xtype) {
368 $types[] = $xtype->getTransactionTypeConstant();
373 $comment = $template->getApplicationTransactionCommentObject();
375 $types[] = PhabricatorTransactions
::TYPE_COMMENT
;
382 private function adjustTransactionValues(
383 PhabricatorLiskDAO
$object,
384 PhabricatorApplicationTransaction
$xaction) {
386 if ($xaction->shouldGenerateOldValue()) {
387 $old = $this->getTransactionOldValue($object, $xaction);
388 $xaction->setOldValue($old);
391 $new = $this->getTransactionNewValue($object, $xaction);
392 $xaction->setNewValue($new);
394 // Apply an optional transformation to convert "external" tranaction
395 // values (provided by APIs) into "internal" values.
397 $old = $xaction->getOldValue();
398 $new = $xaction->getNewValue();
400 $type = $xaction->getTransactionType();
401 $xtype = $this->getModularTransactionType($object, $type);
403 $xtype = clone $xtype;
404 $xtype->setStorage($xaction);
407 // TODO: Provide a modular hook for modern transactions to do a
409 list($old, $new) = array($old, $new);
414 case PhabricatorTransactions
::TYPE_FILE
:
415 list($old, $new) = $this->newFileTransactionInternalValues(
424 $xaction->setOldValue($old);
425 $xaction->setNewValue($new);
428 private function newFileTransactionInternalValues(
429 PhabricatorLiskDAO
$object,
430 PhabricatorApplicationTransaction
$xaction,
436 if (!$this->getIsNewObject()) {
437 $phid = $object->getPHID();
439 $attachment_table = new PhabricatorFileAttachment();
440 $attachment_conn = $attachment_table->establishConnection('w');
444 'SELECT filePHID, attachmentMode FROM %R WHERE objectPHID = %s',
447 $old_map = ipull($rows, 'attachmentMode', 'filePHID');
450 $mode_ref = PhabricatorFileAttachment
::MODE_REFERENCE
;
451 $mode_detach = PhabricatorFileAttachment
::MODE_DETACH
;
455 foreach ($new as $file_phid => $attachment_mode) {
456 $is_ref = ($attachment_mode === $mode_ref);
457 $is_detach = ($attachment_mode === $mode_detach);
460 unset($new_map[$file_phid]);
464 $old_mode = idx($old_map, $file_phid);
466 // If we're adding a reference to a file but it is already attached,
470 if ($old_mode !== null) {
475 $new_map[$file_phid] = $attachment_mode;
478 foreach (array_keys($old_map +
$new_map) as $key) {
479 if (isset($old_map[$key]) && isset($new_map[$key])) {
480 if ($old_map[$key] === $new_map[$key]) {
481 unset($old_map[$key]);
482 unset($new_map[$key]);
487 return array($old_map, $new_map);
490 private function getTransactionOldValue(
491 PhabricatorLiskDAO
$object,
492 PhabricatorApplicationTransaction
$xaction) {
494 $type = $xaction->getTransactionType();
496 $xtype = $this->getModularTransactionType($object, $type);
498 $xtype = clone $xtype;
499 $xtype->setStorage($xaction);
500 return $xtype->generateOldValue($object);
504 case PhabricatorTransactions
::TYPE_CREATE
:
505 case PhabricatorTransactions
::TYPE_HISTORY
:
507 case PhabricatorTransactions
::TYPE_SUBTYPE
:
508 return $object->getEditEngineSubtype();
509 case PhabricatorTransactions
::TYPE_MFA
:
511 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
512 return array_values($this->subscribers
);
513 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
514 if ($this->getIsNewObject()) {
517 return $object->getViewPolicy();
518 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
519 if ($this->getIsNewObject()) {
522 return $object->getEditPolicy();
523 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
524 if ($this->getIsNewObject()) {
527 return $object->getJoinPolicy();
528 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
529 if ($this->getIsNewObject()) {
532 return $object->getInteractPolicy();
533 case PhabricatorTransactions
::TYPE_SPACE
:
534 if ($this->getIsNewObject()) {
538 $space_phid = $object->getSpacePHID();
539 if ($space_phid === null) {
540 $default_space = PhabricatorSpacesNamespaceQuery
::getDefaultSpace();
541 if ($default_space) {
542 $space_phid = $default_space->getPHID();
547 case PhabricatorTransactions
::TYPE_EDGE
:
548 $edge_type = $xaction->getMetadataValue('edge:type');
552 "Edge transaction has no '%s'!",
556 // See T13082. If this is an inverse edit, the parent editor has
557 // already populated the transaction values correctly.
558 if ($this->getIsInverseEdgeEditor()) {
559 return $xaction->getOldValue();
562 $old_edges = array();
563 if ($object->getPHID()) {
564 $edge_src = $object->getPHID();
566 $old_edges = id(new PhabricatorEdgeQuery())
567 ->withSourcePHIDs(array($edge_src))
568 ->withEdgeTypes(array($edge_type))
572 $old_edges = $old_edges[$edge_src][$edge_type];
575 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
576 // NOTE: Custom fields have their old value pre-populated when they are
577 // built by PhabricatorCustomFieldList.
578 return $xaction->getOldValue();
579 case PhabricatorTransactions
::TYPE_COMMENT
:
581 case PhabricatorTransactions
::TYPE_FILE
:
584 return $this->getCustomTransactionOldValue($object, $xaction);
588 private function getTransactionNewValue(
589 PhabricatorLiskDAO
$object,
590 PhabricatorApplicationTransaction
$xaction) {
592 $type = $xaction->getTransactionType();
594 $xtype = $this->getModularTransactionType($object, $type);
596 $xtype = clone $xtype;
597 $xtype->setStorage($xaction);
598 return $xtype->generateNewValue($object, $xaction->getNewValue());
602 case PhabricatorTransactions
::TYPE_CREATE
:
604 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
605 return $this->getPHIDTransactionNewValue($xaction);
606 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
607 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
608 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
609 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
610 case PhabricatorTransactions
::TYPE_TOKEN
:
611 case PhabricatorTransactions
::TYPE_INLINESTATE
:
612 case PhabricatorTransactions
::TYPE_SUBTYPE
:
613 case PhabricatorTransactions
::TYPE_HISTORY
:
614 case PhabricatorTransactions
::TYPE_FILE
:
615 return $xaction->getNewValue();
616 case PhabricatorTransactions
::TYPE_MFA
:
618 case PhabricatorTransactions
::TYPE_SPACE
:
619 $space_phid = $xaction->getNewValue();
620 if ($space_phid === null ||
!strlen($space_phid)) {
621 // If an install has no Spaces or the Spaces controls are not visible
622 // to the viewer, we might end up with the empty string here instead
623 // of a strict `null`, because some controller just used `getStr()`
624 // to read the space PHID from the request.
625 // Just make this work like callers might reasonably expect so we
626 // don't need to handle this specially in every EditController.
627 return $this->getActor()->getDefaultSpacePHID();
631 case PhabricatorTransactions
::TYPE_EDGE
:
632 // See T13082. If this is an inverse edit, the parent editor has
633 // already populated appropriate transaction values.
634 if ($this->getIsInverseEdgeEditor()) {
635 return $xaction->getNewValue();
638 $new_value = $this->getEdgeTransactionNewValue($xaction);
640 $edge_type = $xaction->getMetadataValue('edge:type');
641 $type_project = PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
;
642 if ($edge_type == $type_project) {
643 $new_value = $this->applyProjectConflictRules($new_value);
647 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
648 $field = $this->getCustomFieldForTransaction($object, $xaction);
649 return $field->getNewValueFromApplicationTransactions($xaction);
650 case PhabricatorTransactions
::TYPE_COMMENT
:
653 return $this->getCustomTransactionNewValue($object, $xaction);
657 protected function getCustomTransactionOldValue(
658 PhabricatorLiskDAO
$object,
659 PhabricatorApplicationTransaction
$xaction) {
660 throw new Exception(pht('Capability not supported!'));
663 protected function getCustomTransactionNewValue(
664 PhabricatorLiskDAO
$object,
665 PhabricatorApplicationTransaction
$xaction) {
666 throw new Exception(pht('Capability not supported!'));
669 protected function transactionHasEffect(
670 PhabricatorLiskDAO
$object,
671 PhabricatorApplicationTransaction
$xaction) {
673 switch ($xaction->getTransactionType()) {
674 case PhabricatorTransactions
::TYPE_CREATE
:
675 case PhabricatorTransactions
::TYPE_HISTORY
:
677 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
678 $field = $this->getCustomFieldForTransaction($object, $xaction);
679 return $field->getApplicationTransactionHasEffect($xaction);
680 case PhabricatorTransactions
::TYPE_EDGE
:
681 // A straight value comparison here doesn't always get the right
682 // result, because newly added edges aren't fully populated. Instead,
683 // compare the changes in a more granular way.
684 $old = $xaction->getOldValue();
685 $new = $xaction->getNewValue();
687 $old_dst = array_keys($old);
688 $new_dst = array_keys($new);
690 // NOTE: For now, we don't consider edge reordering to be a change.
691 // We have very few order-dependent edges and effectively no order
692 // oriented UI. This might change in the future.
696 if ($old_dst !== $new_dst) {
697 // We've added or removed edges, so this transaction definitely
702 // We haven't added or removed edges, but we might have changed
704 foreach ($old as $key => $old_value) {
705 $new_value = $new[$key];
706 if ($old_value['data'] !== $new_value['data']) {
714 $type = $xaction->getTransactionType();
715 $xtype = $this->getModularTransactionType($object, $type);
717 return $xtype->getTransactionHasEffect(
719 $xaction->getOldValue(),
720 $xaction->getNewValue());
723 if ($xaction->hasComment()) {
727 return ($xaction->getOldValue() !== $xaction->getNewValue());
730 protected function shouldApplyInitialEffects(
731 PhabricatorLiskDAO
$object,
736 protected function applyInitialEffects(
737 PhabricatorLiskDAO
$object,
739 throw new PhutilMethodNotImplementedException();
742 private function applyInternalEffects(
743 PhabricatorLiskDAO
$object,
744 PhabricatorApplicationTransaction
$xaction) {
746 $type = $xaction->getTransactionType();
748 $xtype = $this->getModularTransactionType($object, $type);
750 $xtype = clone $xtype;
751 $xtype->setStorage($xaction);
752 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
756 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
757 $field = $this->getCustomFieldForTransaction($object, $xaction);
758 return $field->applyApplicationTransactionInternalEffects($xaction);
759 case PhabricatorTransactions
::TYPE_CREATE
:
760 case PhabricatorTransactions
::TYPE_HISTORY
:
761 case PhabricatorTransactions
::TYPE_SUBTYPE
:
762 case PhabricatorTransactions
::TYPE_MFA
:
763 case PhabricatorTransactions
::TYPE_TOKEN
:
764 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
765 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
766 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
767 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
768 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
769 case PhabricatorTransactions
::TYPE_INLINESTATE
:
770 case PhabricatorTransactions
::TYPE_EDGE
:
771 case PhabricatorTransactions
::TYPE_SPACE
:
772 case PhabricatorTransactions
::TYPE_COMMENT
:
773 case PhabricatorTransactions
::TYPE_FILE
:
774 return $this->applyBuiltinInternalTransaction($object, $xaction);
777 return $this->applyCustomInternalTransaction($object, $xaction);
780 private function applyExternalEffects(
781 PhabricatorLiskDAO
$object,
782 PhabricatorApplicationTransaction
$xaction) {
784 $type = $xaction->getTransactionType();
786 $xtype = $this->getModularTransactionType($object, $type);
788 $xtype = clone $xtype;
789 $xtype->setStorage($xaction);
790 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
794 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
795 $subeditor = id(new PhabricatorSubscriptionsEditor())
797 ->setActor($this->requireActor());
799 $old_map = array_fuse($xaction->getOldValue());
800 $new_map = array_fuse($xaction->getNewValue());
802 $subeditor->unsubscribe(
804 array_diff_key($old_map, $new_map)));
806 $subeditor->subscribeExplicit(
808 array_diff_key($new_map, $old_map)));
812 // for the rest of these edits, subscribers should include those just
813 // added as well as those just removed.
814 $subscribers = array_unique(array_merge(
816 $xaction->getOldValue(),
817 $xaction->getNewValue()));
818 $this->subscribers
= $subscribers;
819 return $this->applyBuiltinExternalTransaction($object, $xaction);
821 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
822 $field = $this->getCustomFieldForTransaction($object, $xaction);
823 return $field->applyApplicationTransactionExternalEffects($xaction);
824 case PhabricatorTransactions
::TYPE_CREATE
:
825 case PhabricatorTransactions
::TYPE_HISTORY
:
826 case PhabricatorTransactions
::TYPE_SUBTYPE
:
827 case PhabricatorTransactions
::TYPE_MFA
:
828 case PhabricatorTransactions
::TYPE_EDGE
:
829 case PhabricatorTransactions
::TYPE_TOKEN
:
830 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
831 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
832 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
833 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
834 case PhabricatorTransactions
::TYPE_INLINESTATE
:
835 case PhabricatorTransactions
::TYPE_SPACE
:
836 case PhabricatorTransactions
::TYPE_COMMENT
:
837 case PhabricatorTransactions
::TYPE_FILE
:
838 return $this->applyBuiltinExternalTransaction($object, $xaction);
841 return $this->applyCustomExternalTransaction($object, $xaction);
844 protected function applyCustomInternalTransaction(
845 PhabricatorLiskDAO
$object,
846 PhabricatorApplicationTransaction
$xaction) {
847 $type = $xaction->getTransactionType();
850 "Transaction type '%s' is missing an internal apply implementation!",
854 protected function applyCustomExternalTransaction(
855 PhabricatorLiskDAO
$object,
856 PhabricatorApplicationTransaction
$xaction) {
857 $type = $xaction->getTransactionType();
860 "Transaction type '%s' is missing an external apply implementation!",
865 * @{class:PhabricatorTransactions} provides many built-in transactions
866 * which should not require much - if any - code in specific applications.
868 * This method is a hook for the exceedingly-rare cases where you may need
869 * to do **additional** work for built-in transactions. Developers should
870 * extend this method, making sure to return the parent implementation
871 * regardless of handling any transactions.
873 * See also @{method:applyBuiltinExternalTransaction}.
875 protected function applyBuiltinInternalTransaction(
876 PhabricatorLiskDAO
$object,
877 PhabricatorApplicationTransaction
$xaction) {
879 switch ($xaction->getTransactionType()) {
880 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
881 $object->setViewPolicy($xaction->getNewValue());
883 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
884 $object->setEditPolicy($xaction->getNewValue());
886 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
887 $object->setJoinPolicy($xaction->getNewValue());
889 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
890 $object->setInteractPolicy($xaction->getNewValue());
892 case PhabricatorTransactions
::TYPE_SPACE
:
893 $object->setSpacePHID($xaction->getNewValue());
895 case PhabricatorTransactions
::TYPE_SUBTYPE
:
896 $object->setEditEngineSubtype($xaction->getNewValue());
902 * See @{method::applyBuiltinInternalTransaction}.
904 protected function applyBuiltinExternalTransaction(
905 PhabricatorLiskDAO
$object,
906 PhabricatorApplicationTransaction
$xaction) {
908 switch ($xaction->getTransactionType()) {
909 case PhabricatorTransactions
::TYPE_EDGE
:
910 if ($this->getIsInverseEdgeEditor()) {
911 // If we're writing an inverse edge transaction, don't actually
912 // do anything. The initiating editor on the other side of the
913 // transaction will take care of the edge writes.
917 $old = $xaction->getOldValue();
918 $new = $xaction->getNewValue();
919 $src = $object->getPHID();
920 $const = $xaction->getMetadataValue('edge:type');
922 foreach ($new as $dst_phid => $edge) {
923 $new[$dst_phid]['src'] = $src;
926 $editor = new PhabricatorEdgeEditor();
928 foreach ($old as $dst_phid => $edge) {
929 if (!empty($new[$dst_phid])) {
930 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
934 $editor->removeEdge($src, $const, $dst_phid);
937 foreach ($new as $dst_phid => $edge) {
938 if (!empty($old[$dst_phid])) {
939 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
945 'data' => $edge['data'],
948 $editor->addEdge($src, $const, $dst_phid, $data);
953 $this->updateWorkboardColumns($object, $const, $old, $new);
955 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
956 case PhabricatorTransactions
::TYPE_SPACE
:
957 $this->scrambleFileSecrets($object);
959 case PhabricatorTransactions
::TYPE_HISTORY
:
960 $this->sendHistory
= true;
962 case PhabricatorTransactions
::TYPE_FILE
:
963 $this->applyFileTransaction($object, $xaction);
968 private function applyFileTransaction(
969 PhabricatorLiskDAO
$object,
970 PhabricatorApplicationTransaction
$xaction) {
972 $old_map = $xaction->getOldValue();
973 $new_map = $xaction->getNewValue();
975 $add_phids = array();
976 $rem_phids = array();
978 foreach ($new_map as $phid => $mode) {
979 $add_phids[$phid] = $mode;
982 foreach ($old_map as $phid => $mode) {
983 if (!isset($new_map[$phid])) {
984 $rem_phids[] = $phid;
988 $now = PhabricatorTime
::getNow();
989 $object_phid = $object->getPHID();
990 $attacher_phid = $this->getActingAsPHID();
992 $attachment_table = new PhabricatorFileAttachment();
993 $attachment_conn = $attachment_table->establishConnection('w');
996 foreach ($add_phids as $add_phid => $add_mode) {
997 $add_sql[] = qsprintf(
999 '(%s, %s, %s, %ns, %d, %d)',
1009 foreach ($rem_phids as $rem_phid) {
1010 $rem_sql[] = qsprintf(
1016 foreach (PhabricatorLiskDAO
::chunkSQL($add_sql) as $chunk) {
1019 'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
1020 attacherPHID, dateCreated, dateModified)
1022 ON DUPLICATE KEY UPDATE
1023 attachmentMode = VALUES(attachmentMode),
1024 attacherPHID = VALUES(attacherPHID),
1025 dateModified = VALUES(dateModified)',
1030 foreach (PhabricatorLiskDAO
::chunkSQL($rem_sql) as $chunk) {
1033 'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
1041 * Fill in a transaction's common values, like author and content source.
1043 protected function populateTransaction(
1044 PhabricatorLiskDAO
$object,
1045 PhabricatorApplicationTransaction
$xaction) {
1047 $actor = $this->getActor();
1049 // TODO: This needs to be more sophisticated once we have meta-policies.
1050 $xaction->setViewPolicy(PhabricatorPolicies
::POLICY_PUBLIC
);
1052 if ($actor->isOmnipotent()) {
1053 $xaction->setEditPolicy(PhabricatorPolicies
::POLICY_NOONE
);
1055 $xaction->setEditPolicy($this->getActingAsPHID());
1058 // If the transaction already has an explicit author PHID, allow it to
1059 // stand. This is used by applications like Owners that hook into the
1060 // post-apply change pipeline.
1061 if (!$xaction->getAuthorPHID()) {
1062 $xaction->setAuthorPHID($this->getActingAsPHID());
1065 $xaction->setContentSource($this->getContentSource());
1066 $xaction->attachViewer($actor);
1067 $xaction->attachObject($object);
1069 if ($object->getPHID()) {
1070 $xaction->setObjectPHID($object->getPHID());
1073 if ($this->getIsSilent()) {
1074 $xaction->setIsSilentTransaction(true);
1080 protected function didApplyInternalEffects(
1081 PhabricatorLiskDAO
$object,
1086 protected function applyFinalEffects(
1087 PhabricatorLiskDAO
$object,
1092 final protected function didCommitTransactions(
1093 PhabricatorLiskDAO
$object,
1096 foreach ($xactions as $xaction) {
1097 $type = $xaction->getTransactionType();
1099 // See T13082. When we're writing edges that imply corresponding inverse
1100 // transactions, apply those inverse transactions now. We have to wait
1101 // until the object we're editing (with this editor) has committed its
1102 // transactions to do this. If we don't, the inverse editor may race,
1103 // build a mail before we actually commit this object, and render "alice
1104 // added an edge: Unknown Object".
1106 if ($type === PhabricatorTransactions
::TYPE_EDGE
) {
1107 // Don't do anything if we're already an inverse edge editor.
1108 if ($this->getIsInverseEdgeEditor()) {
1112 $edge_const = $xaction->getMetadataValue('edge:type');
1113 $edge_type = PhabricatorEdgeType
::getByConstant($edge_const);
1114 if ($edge_type->shouldWriteInverseTransactions()) {
1115 $this->applyInverseEdgeTransactions(
1118 $edge_type->getInverseEdgeConstant());
1123 $xtype = $this->getModularTransactionType($object, $type);
1128 $xtype = clone $xtype;
1129 $xtype->setStorage($xaction);
1130 $xtype->didCommitTransaction($object, $xaction->getNewValue());
1134 public function setContentSource(PhabricatorContentSource
$content_source) {
1135 $this->contentSource
= $content_source;
1139 public function setContentSourceFromRequest(AphrontRequest
$request) {
1140 $this->setRequest($request);
1141 return $this->setContentSource(
1142 PhabricatorContentSource
::newFromRequest($request));
1145 public function getContentSource() {
1146 return $this->contentSource
;
1149 public function setRequest(AphrontRequest
$request) {
1150 $this->request
= $request;
1154 public function getRequest() {
1155 return $this->request
;
1158 public function setCancelURI($cancel_uri) {
1159 $this->cancelURI
= $cancel_uri;
1163 public function getCancelURI() {
1164 return $this->cancelURI
;
1167 protected function getTransactionGroupID() {
1168 if ($this->transactionGroupID
=== null) {
1169 $this->transactionGroupID
= Filesystem
::readRandomCharacters(32);
1172 return $this->transactionGroupID
;
1175 final public function applyTransactions(
1176 PhabricatorLiskDAO
$object,
1179 $is_new = ($object->getID() === null);
1180 $this->isNewObject
= $is_new;
1182 $is_preview = $this->getIsPreview();
1183 $read_locking = false;
1184 $transaction_open = false;
1186 // If we're attempting to apply transactions, lock and reload the object
1187 // before we go anywhere. If we don't do this at the very beginning, we
1188 // may be looking at an older version of the object when we populate and
1189 // filter the transactions. See PHI1165 for an example.
1193 $this->buildOldRecipientLists($object, $xactions);
1195 $object->openTransaction();
1196 $transaction_open = true;
1198 $object->beginReadLocking();
1199 $read_locking = true;
1206 $this->object = $object;
1207 $this->xactions
= $xactions;
1209 $this->validateEditParameters($object, $xactions);
1210 $xactions = $this->newMFATransactions($object, $xactions);
1212 $actor = $this->requireActor();
1214 // NOTE: Some transaction expansion requires that the edited object be
1216 foreach ($xactions as $xaction) {
1217 $xaction->attachObject($object);
1218 $xaction->attachViewer($actor);
1221 $xactions = $this->expandTransactions($object, $xactions);
1222 $xactions = $this->expandSupportTransactions($object, $xactions);
1223 $xactions = $this->combineTransactions($xactions);
1225 foreach ($xactions as $xaction) {
1226 $xaction = $this->populateTransaction($object, $xaction);
1231 $type_map = mgroup($xactions, 'getTransactionType');
1232 foreach ($this->getTransactionTypes() as $type) {
1233 $type_xactions = idx($type_map, $type, array());
1234 $errors[] = $this->validateTransaction(
1240 $errors[] = $this->validateAllTransactions($object, $xactions);
1241 $errors[] = $this->validateTransactionsWithExtensions(
1244 $errors = array_mergev($errors);
1246 $continue_on_missing = $this->getContinueOnMissingFields();
1247 foreach ($errors as $key => $error) {
1248 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1249 unset($errors[$key]);
1254 throw new PhabricatorApplicationTransactionValidationException(
1258 if ($this->raiseWarnings
) {
1259 $warnings = array();
1260 foreach ($xactions as $xaction) {
1261 if ($this->hasWarnings($object, $xaction)) {
1262 $warnings[] = $xaction;
1266 throw new PhabricatorApplicationTransactionWarningException(
1272 foreach ($xactions as $xaction) {
1273 $this->adjustTransactionValues($object, $xaction);
1276 // Now that we've merged and combined transactions, check for required
1277 // capabilities. Note that we're doing this before filtering
1278 // transactions: if you try to apply an edit which you do not have
1279 // permission to apply, we want to give you a permissions error even
1280 // if the edit would have no effect.
1281 $this->applyCapabilityChecks($object, $xactions);
1283 $xactions = $this->filterTransactions($object, $xactions);
1286 $this->hasRequiredMFA
= true;
1287 if ($this->getShouldRequireMFA()) {
1288 $this->requireMFA($object, $xactions);
1291 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1292 if (!$transaction_open) {
1293 $object->openTransaction();
1294 $transaction_open = true;
1299 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1300 $this->applyInitialEffects($object, $xactions);
1303 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1304 // figure this out instead.
1305 $mark_as_create = false;
1306 $create_type = PhabricatorTransactions
::TYPE_CREATE
;
1307 foreach ($xactions as $xaction) {
1308 if ($xaction->getTransactionType() == $create_type) {
1309 $mark_as_create = true;
1313 if ($mark_as_create) {
1314 foreach ($xactions as $xaction) {
1315 $xaction->setIsCreateTransaction(true);
1319 $xactions = $this->sortTransactions($xactions);
1322 $this->loadHandles($xactions);
1326 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1328 ->setActingAsPHID($this->getActingAsPHID())
1329 ->setContentSource($this->getContentSource())
1330 ->setIsNewComment(true);
1332 if (!$transaction_open) {
1333 $object->openTransaction();
1334 $transaction_open = true;
1337 // We can technically test any object for CAN_INTERACT, but we can
1338 // run into some issues in doing so (for example, in project unit tests).
1339 // For now, only test for CAN_INTERACT if the object is explicitly a
1342 $was_locked = false;
1343 if ($object instanceof PhabricatorEditEngineLockableInterface
) {
1344 $was_locked = !PhabricatorPolicyFilter
::canInteract($actor, $object);
1347 foreach ($xactions as $xaction) {
1348 $this->applyInternalEffects($object, $xaction);
1351 $xactions = $this->didApplyInternalEffects($object, $xactions);
1355 } catch (AphrontDuplicateKeyQueryException
$ex) {
1356 // This callback has an opportunity to throw a better exception,
1357 // so execution may end here.
1358 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1363 $group_id = $this->getTransactionGroupID();
1365 foreach ($xactions as $xaction) {
1367 $is_override = $this->isLockOverrideTransaction($xaction);
1369 $xaction->setIsLockOverrideTransaction(true);
1373 $xaction->setObjectPHID($object->getPHID());
1374 $xaction->setTransactionGroupID($group_id);
1376 if ($xaction->getComment()) {
1377 $xaction->setPHID($xaction->generatePHID());
1378 $comment_editor->applyEdit($xaction, $xaction->getComment());
1381 // TODO: This is a transitional hack to let us migrate edge
1382 // transactions to a more efficient storage format. For now, we're
1383 // going to write a new slim format to the database but keep the old
1384 // bulky format on the objects so we don't have to upgrade all the
1385 // edit logic to the new format yet. See T13051.
1387 $edge_type = PhabricatorTransactions
::TYPE_EDGE
;
1388 if ($xaction->getTransactionType() == $edge_type) {
1389 $bulky_old = $xaction->getOldValue();
1390 $bulky_new = $xaction->getNewValue();
1392 $record = PhabricatorEdgeChangeRecord
::newFromTransaction($xaction);
1393 $slim_old = $record->getModernOldEdgeTransactionData();
1394 $slim_new = $record->getModernNewEdgeTransactionData();
1396 $xaction->setOldValue($slim_old);
1397 $xaction->setNewValue($slim_new);
1400 $xaction->setOldValue($bulky_old);
1401 $xaction->setNewValue($bulky_new);
1408 foreach ($xactions as $xaction) {
1409 $this->applyExternalEffects($object, $xaction);
1412 $xactions = $this->applyFinalEffects($object, $xactions);
1414 if ($read_locking) {
1415 $object->endReadLocking();
1416 $read_locking = false;
1419 if ($transaction_open) {
1420 $object->saveTransaction();
1421 $transaction_open = false;
1424 $this->didCommitTransactions($object, $xactions);
1426 } catch (Exception
$ex) {
1427 if ($read_locking) {
1428 $object->endReadLocking();
1429 $read_locking = false;
1432 if ($transaction_open) {
1433 $object->killTransaction();
1434 $transaction_open = false;
1440 // If we need to perform cache engine updates, execute them now.
1441 id(new PhabricatorCacheEngine())
1442 ->updateObject($object);
1444 // Now that we've completely applied the core transaction set, try to apply
1445 // Herald rules. Herald rules are allowed to either take direct actions on
1446 // the database (like writing flags), or take indirect actions (like saving
1447 // some targets for CC when we generate mail a little later), or return
1448 // transactions which we'll apply normally using another Editor.
1450 // First, check if *this* is a sub-editor which is itself applying Herald
1451 // rules: if it is, stop working and return so we don't descend into
1454 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1455 // using a Herald editor to apply resulting transactions) and then send out
1456 // mail, notifications, and feed updates about everything.
1458 if ($this->getIsHeraldEditor()) {
1459 // We are the Herald editor, so stop work here and return the updated
1462 } else if ($this->getIsInverseEdgeEditor()) {
1463 // Do not run Herald if we're just recording that this object was
1464 // mentioned elsewhere. This tends to create Herald side effects which
1465 // feel arbitrary, and can really slow down edits which mention a large
1466 // number of other objects. See T13114.
1467 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1468 // We are not the Herald editor, so try to apply Herald rules.
1469 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1471 if ($herald_xactions) {
1472 $xscript_id = $this->getHeraldTranscript()->getID();
1473 foreach ($herald_xactions as $herald_xaction) {
1474 // Don't set a transcript ID if this is a transaction from another
1475 // application or source, like Owners.
1476 if ($herald_xaction->getAuthorPHID()) {
1480 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1483 // NOTE: We're acting as the omnipotent user because rules deal with
1484 // their own policy issues. We use a synthetic author PHID (the
1485 // Herald application) as the author of record, so that transactions
1486 // will render in a reasonable way ("Herald assigned this task ...").
1487 $herald_actor = PhabricatorUser
::getOmnipotentUser();
1488 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1490 // TODO: It would be nice to give transactions a more specific source
1491 // which points at the rule which generated them. You can figure this
1492 // out from transcripts, but it would be cleaner if you didn't have to.
1494 $herald_source = PhabricatorContentSource
::newForSource(
1495 PhabricatorHeraldContentSource
::SOURCECONST
);
1497 $herald_editor = $this->newEditorCopy()
1498 ->setContinueOnNoEffect(true)
1499 ->setContinueOnMissingFields(true)
1500 ->setIsHeraldEditor(true)
1501 ->setActor($herald_actor)
1502 ->setActingAsPHID($herald_phid)
1503 ->setContentSource($herald_source);
1505 $herald_xactions = $herald_editor->applyTransactions(
1509 // Merge the new transactions into the transaction list: we want to
1510 // send email and publish feed stories about them, too.
1511 $xactions = array_merge($xactions, $herald_xactions);
1514 // If Herald did not generate transactions, we may still need to handle
1515 // "Send an Email" rules.
1516 $adapter = $this->getHeraldAdapter();
1517 $this->heraldEmailPHIDs
= $adapter->getEmailPHIDs();
1518 $this->heraldForcedEmailPHIDs
= $adapter->getForcedEmailPHIDs();
1519 $this->webhookMap
= $adapter->getWebhookMap();
1522 $xactions = $this->didApplyTransactions($object, $xactions);
1524 if ($object instanceof PhabricatorCustomFieldInterface
) {
1525 // Maybe this makes more sense to move into the search index itself? For
1526 // now I'm putting it here since I think we might end up with things that
1527 // need it to be up to date once the next page loads, but if we don't go
1528 // there we could move it into search once search moves to the daemons.
1530 // It now happens in the search indexer as well, but the search indexer is
1531 // always daemonized, so the logic above still potentially holds. We could
1532 // possibly get rid of this. The major motivation for putting it in the
1533 // indexer was to enable reindexing to work.
1535 $fields = PhabricatorCustomField
::getObjectFields(
1537 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1538 $fields->readFieldsFromStorage($object);
1539 $fields->rebuildIndexes($object);
1542 $herald_xscript = $this->getHeraldTranscript();
1543 if ($herald_xscript) {
1544 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1545 $herald_header = HeraldTranscript
::saveXHeraldRulesHeader(
1549 $herald_header = HeraldTranscript
::loadXHeraldRulesHeader(
1550 $object->getPHID());
1552 $this->heraldHeader
= $herald_header;
1554 // See PHI1134. If we're a subeditor, we don't publish information about
1555 // the edit yet. Our parent editor still needs to finish applying
1556 // transactions and execute Herald, which may change the information we
1559 // For example, Herald actions may change the parent object's title or
1560 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1563 // Once the parent finishes work, it will queue its own publish step and
1564 // then queue publish steps for its children.
1566 $this->publishableObject
= $object;
1567 $this->publishableTransactions
= $xactions;
1568 if (!$this->parentEditor
) {
1569 $this->queuePublishing();
1575 private function queuePublishing() {
1576 $object = $this->publishableObject
;
1577 $xactions = $this->publishableTransactions
;
1580 throw new Exception(
1582 'Editor method "queuePublishing()" was called, but no publishable '.
1583 'object is present. This Editor is not ready to publish.'));
1586 // We're going to compute some of the data we'll use to publish these
1587 // transactions here, before queueing a worker.
1589 // Primarily, this is more correct: we want to publish the object as it
1590 // exists right now. The worker may not execute for some time, and we want
1591 // to use the current To/CC list, not respect any changes which may occur
1592 // between now and when the worker executes.
1594 // As a secondary benefit, this tends to reduce the amount of state that
1595 // Editors need to pass into workers.
1596 $object = $this->willPublish($object, $xactions);
1598 if (!$this->getIsSilent()) {
1599 if ($this->shouldSendMail($object, $xactions)) {
1600 $this->mailShouldSend
= true;
1601 $this->mailToPHIDs
= $this->getMailTo($object);
1602 $this->mailCCPHIDs
= $this->getMailCC($object);
1603 $this->mailUnexpandablePHIDs
= $this->newMailUnexpandablePHIDs($object);
1605 // Add any recipients who were previously on the notification list
1606 // but were removed by this change.
1607 $this->applyOldRecipientLists();
1609 if ($object instanceof PhabricatorSubscribableInterface
) {
1610 $this->mailMutedPHIDs
= PhabricatorEdgeQuery
::loadDestinationPHIDs(
1612 PhabricatorMutedByEdgeType
::EDGECONST
);
1614 $this->mailMutedPHIDs
= array();
1617 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1618 $stamps = $this->newMailStamps($object, $xactions);
1619 foreach ($stamps as $stamp) {
1620 $this->mailStamps
[] = $stamp->toDictionary();
1624 if ($this->shouldPublishFeedStory($object, $xactions)) {
1625 $this->feedShouldPublish
= true;
1626 $this->feedRelatedPHIDs
= $this->getFeedRelatedPHIDs(
1629 $this->feedNotifyPHIDs
= $this->getFeedNotifyPHIDs(
1635 PhabricatorWorker
::scheduleTask(
1636 'PhabricatorApplicationTransactionPublishWorker',
1638 'objectPHID' => $object->getPHID(),
1639 'actorPHID' => $this->getActingAsPHID(),
1640 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1641 'state' => $this->getWorkerState(),
1644 'objectPHID' => $object->getPHID(),
1645 'priority' => PhabricatorWorker
::PRIORITY_ALERTS
,
1648 foreach ($this->subEditors
as $sub_editor) {
1649 $sub_editor->queuePublishing();
1652 $this->flushTransactionQueue($object);
1655 protected function didCatchDuplicateKeyException(
1656 PhabricatorLiskDAO
$object,
1662 public function publishTransactions(
1663 PhabricatorLiskDAO
$object,
1666 $this->object = $object;
1667 $this->xactions
= $xactions;
1669 // Hook for edges or other properties that may need (re-)loading
1670 $object = $this->willPublish($object, $xactions);
1672 // The object might have changed, so reassign it.
1673 $this->object = $object;
1675 $messages = array();
1676 if ($this->mailShouldSend
) {
1677 $messages = $this->buildMail($object, $xactions);
1680 if ($this->supportsSearch()) {
1681 PhabricatorSearchWorker
::queueDocumentForIndexing(
1684 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1688 if ($this->feedShouldPublish
) {
1690 foreach ($messages as $mail) {
1691 foreach ($mail->buildRecipientList() as $phid) {
1692 $mailed[$phid] = $phid;
1696 $this->publishFeedStory($object, $xactions, $mailed);
1699 if ($this->sendHistory
) {
1700 $history_mail = $this->buildHistoryMail($object);
1701 if ($history_mail) {
1702 $messages[] = $history_mail;
1706 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1707 $messages[] = $message;
1710 // NOTE: This actually sends the mail. We do this last to reduce the chance
1711 // that we send some mail, hit an exception, then send the mail again when
1713 foreach ($messages as $mail) {
1717 $this->queueWebhooks($object, $xactions);
1722 protected function didApplyTransactions($object, array $xactions) {
1723 // Hook for subclasses.
1727 private function loadHandles(array $xactions) {
1729 foreach ($xactions as $key => $xaction) {
1730 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1733 $merged = array_mergev($phids);
1735 $handles = id(new PhabricatorHandleQuery())
1736 ->setViewer($this->requireActor())
1737 ->withPHIDs($merged)
1740 foreach ($xactions as $key => $xaction) {
1741 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1745 private function loadSubscribers(PhabricatorLiskDAO
$object) {
1746 if ($object->getPHID() &&
1747 ($object instanceof PhabricatorSubscribableInterface
)) {
1748 $subs = PhabricatorSubscribersQuery
::loadSubscribersForPHID(
1749 $object->getPHID());
1750 $this->subscribers
= array_fuse($subs);
1752 $this->subscribers
= array();
1756 private function validateEditParameters(
1757 PhabricatorLiskDAO
$object,
1760 if (!$this->getContentSource()) {
1761 throw new PhutilInvalidStateException('setContentSource');
1764 // Do a bunch of sanity checks that the incoming transactions are fresh.
1765 // They should be unsaved and have only "transactionType" and "newValue"
1768 $types = array_fill_keys($this->getTransactionTypes(), true);
1770 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1771 foreach ($xactions as $xaction) {
1772 if ($xaction->getPHID() ||
$xaction->getID()) {
1773 throw new PhabricatorApplicationTransactionStructureException(
1775 pht('You can not apply transactions which already have IDs/PHIDs!'));
1778 if ($xaction->getObjectPHID()) {
1779 throw new PhabricatorApplicationTransactionStructureException(
1782 'You can not apply transactions which already have %s!',
1786 if ($xaction->getCommentPHID()) {
1787 throw new PhabricatorApplicationTransactionStructureException(
1790 'You can not apply transactions which already have %s!',
1794 if ($xaction->getCommentVersion() !== 0) {
1795 throw new PhabricatorApplicationTransactionStructureException(
1798 'You can not apply transactions which already have '.
1799 'commentVersions!'));
1802 $expect_value = !$xaction->shouldGenerateOldValue();
1803 $has_value = $xaction->hasOldValue();
1805 // See T13082. In the narrow case of applying inverse edge edits, we
1806 // expect the old value to be populated.
1807 if ($this->getIsInverseEdgeEditor()) {
1808 $expect_value = true;
1811 if ($expect_value && !$has_value) {
1812 throw new PhabricatorApplicationTransactionStructureException(
1815 'This transaction is supposed to have an %s set, but it does not!',
1819 if ($has_value && !$expect_value) {
1820 throw new PhabricatorApplicationTransactionStructureException(
1823 'This transaction should generate its %s automatically, '.
1824 'but has already had one set!',
1828 $type = $xaction->getTransactionType();
1829 if (empty($types[$type])) {
1830 throw new PhabricatorApplicationTransactionStructureException(
1833 'Transaction has type "%s", but that transaction type is not '.
1834 'supported by this editor (%s).',
1841 private function applyCapabilityChecks(
1842 PhabricatorLiskDAO
$object,
1844 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1846 $can_edit = PhabricatorPolicyCapability
::CAN_EDIT
;
1848 if ($this->getIsNewObject()) {
1849 // If we're creating a new object, we don't need any special capabilities
1850 // on the object. The actor has already made it through creation checks,
1851 // and objects which haven't been created yet often can not be
1852 // meaningfully tested for capabilities anyway.
1853 $required_capabilities = array();
1855 if (!$xactions && !$this->xactions
) {
1856 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1857 $required_capabilities = array($can_edit);
1859 $required_capabilities = array();
1861 foreach ($xactions as $xaction) {
1862 $type = $xaction->getTransactionType();
1864 $xtype = $this->getModularTransactionType($object, $type);
1866 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1868 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1871 // For convenience, we allow flexibility in the return types because
1872 // it's very unusual that a transaction actually requires multiple
1873 // capability checks.
1874 if ($capabilities === null) {
1875 $capabilities = array();
1877 $capabilities = (array)$capabilities;
1880 foreach ($capabilities as $capability) {
1881 $required_capabilities[$capability] = $capability;
1887 $required_capabilities = array_fuse($required_capabilities);
1888 $actor = $this->getActor();
1890 if ($required_capabilities) {
1891 id(new PhabricatorPolicyFilter())
1893 ->requireCapabilities($required_capabilities)
1894 ->raisePolicyExceptions(true)
1895 ->apply(array($object));
1899 private function getLegacyRequiredCapabilities(
1900 PhabricatorApplicationTransaction
$xaction) {
1902 $type = $xaction->getTransactionType();
1904 case PhabricatorTransactions
::TYPE_COMMENT
:
1905 // TODO: Comments technically require CAN_INTERACT, but this is
1906 // currently somewhat special and handled through EditEngine. For now,
1907 // don't enforce it here.
1909 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1910 // Anyone can subscribe to or unsubscribe from anything they can view,
1911 // with no other permissions.
1913 $old = array_fuse($xaction->getOldValue());
1914 $new = array_fuse($xaction->getNewValue());
1916 // To remove users other than yourself, you must be able to edit the
1918 $rem = array_diff_key($old, $new);
1919 foreach ($rem as $phid) {
1920 if ($phid !== $this->getActingAsPHID()) {
1921 return PhabricatorPolicyCapability
::CAN_EDIT
;
1925 // To add users other than yourself, you must be able to interact.
1926 // This allows "@mentioning" users to work as long as you can comment
1929 // If you can edit, we return that policy instead so that you can
1930 // override a soft lock and still make edits.
1932 // TODO: This is a little bit hacky. We really want to be able to say
1933 // "this requires either interact or edit", but there's currently no
1934 // way to specify this kind of requirement.
1936 $can_edit = PhabricatorPolicyFilter
::hasCapability(
1939 PhabricatorPolicyCapability
::CAN_EDIT
);
1941 $add = array_diff_key($new, $old);
1942 foreach ($add as $phid) {
1943 if ($phid !== $this->getActingAsPHID()) {
1945 return PhabricatorPolicyCapability
::CAN_EDIT
;
1947 return PhabricatorPolicyCapability
::CAN_INTERACT
;
1953 case PhabricatorTransactions
::TYPE_TOKEN
:
1954 // TODO: This technically requires CAN_INTERACT, like comments.
1956 case PhabricatorTransactions
::TYPE_HISTORY
:
1957 // This is a special magic transaction which sends you history via
1958 // email and is only partially supported in the upstream. You don't
1959 // need any capabilities to apply it.
1961 case PhabricatorTransactions
::TYPE_MFA
:
1962 // Signing a transaction group with MFA does not require permissions
1965 case PhabricatorTransactions
::TYPE_FILE
:
1967 case PhabricatorTransactions
::TYPE_EDGE
:
1968 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1970 // For other older (non-modular) transactions, always require exactly
1971 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1972 // capabilities must move to ModularTransactions.
1973 return PhabricatorPolicyCapability
::CAN_EDIT
;
1977 private function getLegacyRequiredEdgeCapabilities(
1978 PhabricatorApplicationTransaction
$xaction) {
1980 // You don't need to have edit permission on an object to mention it or
1981 // otherwise add a relationship pointing toward it.
1982 if ($this->getIsInverseEdgeEditor()) {
1986 $edge_type = $xaction->getMetadataValue('edge:type');
1987 switch ($edge_type) {
1988 case PhabricatorMutedByEdgeType
::EDGECONST
:
1989 // At time of writing, you can only write this edge for yourself, so
1990 // you don't need permissions. If you can eventually mute an object
1991 // for other users, this would need to be revisited.
1993 case PhabricatorProjectSilencedEdgeType
::EDGECONST
:
1994 // At time of writing, you can only write this edge for yourself, so
1995 // you don't need permissions. If you can eventually silence project
1996 // for other users, this would need to be revisited.
1998 case PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
:
2000 case PhabricatorProjectProjectHasMemberEdgeType
::EDGECONST
:
2001 $old = $xaction->getOldValue();
2002 $new = $xaction->getNewValue();
2004 $add = array_keys(array_diff_key($new, $old));
2005 $rem = array_keys(array_diff_key($old, $new));
2007 $actor_phid = $this->requireActor()->getPHID();
2009 $is_join = (($add === array($actor_phid)) && !$rem);
2010 $is_leave = (($rem === array($actor_phid)) && !$add);
2013 // You need CAN_JOIN to join a project.
2014 return PhabricatorPolicyCapability
::CAN_JOIN
;
2018 $object = $this->object;
2019 // You usually don't need any capabilities to leave a project...
2020 if ($object->getIsMembershipLocked()) {
2021 // ...you must be able to edit to leave locked projects, though.
2022 return PhabricatorPolicyCapability
::CAN_EDIT
;
2028 // You need CAN_EDIT to change members other than yourself.
2029 return PhabricatorPolicyCapability
::CAN_EDIT
;
2030 case PhabricatorObjectHasWatcherEdgeType
::EDGECONST
:
2031 // See PHI1024. Watching a project does not require CAN_EDIT.
2034 return PhabricatorPolicyCapability
::CAN_EDIT
;
2039 private function buildSubscribeTransaction(
2040 PhabricatorLiskDAO
$object,
2044 if (!($object instanceof PhabricatorSubscribableInterface
)) {
2048 if ($this->shouldEnableMentions($object, $xactions)) {
2049 // Identify newly mentioned users. We ignore users who were previously
2050 // mentioned so that we don't re-subscribe users after an edit of text
2051 // which mentions them.
2052 $old_texts = mpull($changes, 'getOldValue');
2053 $new_texts = mpull($changes, 'getNewValue');
2055 $old_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
2059 $new_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
2063 $phids = array_diff($new_phids, $old_phids);
2068 $this->mentionedPHIDs
= $phids;
2070 if ($object->getPHID()) {
2071 // Don't try to subscribe already-subscribed mentions: we want to generate
2072 // a dialog about an action having no effect if the user explicitly adds
2073 // existing CCs, but not if they merely mention existing subscribers.
2074 $phids = array_diff($phids, $this->subscribers
);
2078 $users = id(new PhabricatorPeopleQuery())
2079 ->setViewer($this->getActor())
2082 $users = mpull($users, null, 'getPHID');
2084 foreach ($phids as $key => $phid) {
2085 $user = idx($users, $phid);
2087 // Don't subscribe invalid users.
2089 unset($phids[$key]);
2093 // Don't subscribe bots that get mentioned. If users truly intend
2094 // to subscribe them, they can add them explicitly, but it's generally
2095 // not useful to subscribe bots to objects.
2096 if ($user->getIsSystemAgent()) {
2097 unset($phids[$key]);
2101 // Do not subscribe mentioned users who do not have permission to see
2103 if ($object instanceof PhabricatorPolicyInterface
) {
2104 $can_view = PhabricatorPolicyFilter
::hasCapability(
2107 PhabricatorPolicyCapability
::CAN_VIEW
);
2109 unset($phids[$key]);
2114 // Don't subscribe users who are already automatically subscribed.
2115 if ($object->isAutomaticallySubscribed($phid)) {
2116 unset($phids[$key]);
2121 $phids = array_values($phids);
2128 $xaction = $object->getApplicationTransactionTemplate()
2129 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
2130 ->setNewValue(array('+' => $phids));
2135 protected function mergeTransactions(
2136 PhabricatorApplicationTransaction
$u,
2137 PhabricatorApplicationTransaction
$v) {
2139 $object = $this->object;
2140 $type = $u->getTransactionType();
2142 $xtype = $this->getModularTransactionType($object, $type);
2144 return $xtype->mergeTransactions($object, $u, $v);
2148 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
2149 return $this->mergePHIDOrEdgeTransactions($u, $v);
2150 case PhabricatorTransactions
::TYPE_EDGE
:
2151 $u_type = $u->getMetadataValue('edge:type');
2152 $v_type = $v->getMetadataValue('edge:type');
2153 if ($u_type == $v_type) {
2154 return $this->mergePHIDOrEdgeTransactions($u, $v);
2159 // By default, do not merge the transactions.
2164 * Optionally expand transactions which imply other effects. For example,
2165 * resigning from a revision in Differential implies removing yourself as
2168 protected function expandTransactions(
2169 PhabricatorLiskDAO
$object,
2173 foreach ($xactions as $xaction) {
2174 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2175 $results[] = $expanded;
2182 protected function expandTransaction(
2183 PhabricatorLiskDAO
$object,
2184 PhabricatorApplicationTransaction
$xaction) {
2185 return array($xaction);
2189 public function getExpandedSupportTransactions(
2190 PhabricatorLiskDAO
$object,
2191 PhabricatorApplicationTransaction
$xaction) {
2193 $xactions = array($xaction);
2194 $xactions = $this->expandSupportTransactions(
2198 if (count($xactions) == 1) {
2202 foreach ($xactions as $index => $cxaction) {
2203 if ($cxaction === $xaction) {
2204 unset($xactions[$index]);
2212 private function expandSupportTransactions(
2213 PhabricatorLiskDAO
$object,
2215 $this->loadSubscribers($object);
2217 $xactions = $this->applyImplicitCC($object, $xactions);
2219 $changes = $this->getRemarkupChanges($xactions);
2221 $subscribe_xaction = $this->buildSubscribeTransaction(
2225 if ($subscribe_xaction) {
2226 $xactions[] = $subscribe_xaction;
2229 // TODO: For now, this is just a placeholder.
2230 $engine = PhabricatorMarkupEngine
::getEngine('extract');
2231 $engine->setConfig('viewer', $this->requireActor());
2233 $block_xactions = $this->expandRemarkupBlockTransactions(
2239 foreach ($block_xactions as $xaction) {
2240 $xactions[] = $xaction;
2243 $file_xaction = $this->newFileTransaction(
2247 if ($file_xaction) {
2248 $xactions[] = $file_xaction;
2255 private function newFileTransaction(
2256 PhabricatorLiskDAO
$object,
2258 array $remarkup_changes) {
2260 assert_instances_of(
2262 'PhabricatorTransactionRemarkupChange');
2266 $viewer = $this->getActor();
2268 $old_blocks = mpull($remarkup_changes, 'getOldValue');
2269 foreach ($old_blocks as $key => $old_block) {
2270 $old_blocks[$key] = phutil_string_cast($old_block);
2273 $new_blocks = mpull($remarkup_changes, 'getNewValue');
2274 foreach ($new_blocks as $key => $new_block) {
2275 $new_blocks[$key] = phutil_string_cast($new_block);
2278 $old_refs = PhabricatorMarkupEngine
::extractFilePHIDsFromEmbeddedFiles(
2281 $old_refs = array_fuse($old_refs);
2283 $new_refs = PhabricatorMarkupEngine
::extractFilePHIDsFromEmbeddedFiles(
2286 $new_refs = array_fuse($new_refs);
2288 $add_refs = array_diff_key($new_refs, $old_refs);
2289 foreach ($add_refs as $file_phid) {
2290 $new_map[$file_phid] = PhabricatorFileAttachment
::MODE_REFERENCE
;
2293 foreach ($remarkup_changes as $remarkup_change) {
2294 $metadata = $remarkup_change->getMetadata();
2296 $attached_phids = idx($metadata, 'attachedFilePHIDs', array());
2297 foreach ($attached_phids as $file_phid) {
2299 // If the blocks don't include a new embedded reference to this file,
2300 // do not actually attach it. A common way for this to happen is for
2301 // a user to upload a file, then change their mind and remove the
2302 // reference. We do not want to attach the file if they decided against
2305 if (!isset($new_map[$file_phid])) {
2309 $new_map[$file_phid] = PhabricatorFileAttachment
::MODE_ATTACH
;
2313 $file_phids = $this->extractFilePHIDs($object, $xactions);
2314 foreach ($file_phids as $file_phid) {
2315 $new_map[$file_phid] = PhabricatorFileAttachment
::MODE_ATTACH
;
2322 $xaction = $object->getApplicationTransactionTemplate()
2323 ->setTransactionType(PhabricatorTransactions
::TYPE_FILE
)
2324 ->setMetadataValue('attach.implicit', true)
2325 ->setNewValue($new_map);
2331 private function getRemarkupChanges(array $xactions) {
2334 foreach ($xactions as $key => $xaction) {
2335 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2336 $changes[] = $change;
2343 private function getRemarkupChangesFromTransaction(
2344 PhabricatorApplicationTransaction
$transaction) {
2345 return $transaction->getRemarkupChanges();
2348 private function expandRemarkupBlockTransactions(
2349 PhabricatorLiskDAO
$object,
2352 PhutilMarkupEngine
$engine) {
2354 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2360 $mentioned_phids = array();
2361 if ($this->shouldEnableMentions($object, $xactions)) {
2362 foreach ($changes as $change) {
2363 // Here, we don't care about processing only new mentions after an edit
2364 // because there is no way for an object to ever "unmention" itself on
2365 // another object, so we can ignore the old value.
2366 if ($change->getNewValue() !== null) {
2367 $engine->markupText($change->getNewValue());
2370 $mentioned_phids +
= $engine->getTextMetadata(
2371 PhabricatorObjectRemarkupRule
::KEY_MENTIONED_OBJECTS
,
2376 if (!$mentioned_phids) {
2377 return $block_xactions;
2380 $mentioned_objects = id(new PhabricatorObjectQuery())
2381 ->setViewer($this->getActor())
2382 ->withPHIDs($mentioned_phids)
2385 $unmentionable_map = $this->getUnmentionablePHIDMap();
2387 $mentionable_phids = array();
2388 if ($this->shouldEnableMentions($object, $xactions)) {
2389 foreach ($mentioned_objects as $mentioned_object) {
2390 if ($mentioned_object instanceof PhabricatorMentionableInterface
) {
2391 $mentioned_phid = $mentioned_object->getPHID();
2392 if (isset($unmentionable_map[$mentioned_phid])) {
2395 // don't let objects mention themselves
2396 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2399 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2404 if ($mentionable_phids) {
2405 $edge_type = PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
;
2406 $block_xactions[] = newv(get_class(head($xactions)), array())
2407 ->setIgnoreOnNoEffect(true)
2408 ->setTransactionType(PhabricatorTransactions
::TYPE_EDGE
)
2409 ->setMetadataValue('edge:type', $edge_type)
2410 ->setNewValue(array('+' => $mentionable_phids));
2413 return $block_xactions;
2416 protected function expandCustomRemarkupBlockTransactions(
2417 PhabricatorLiskDAO
$object,
2420 PhutilMarkupEngine
$engine) {
2426 * Attempt to combine similar transactions into a smaller number of total
2427 * transactions. For example, two transactions which edit the title of an
2428 * object can be merged into a single edit.
2430 private function combineTransactions(array $xactions) {
2431 $stray_comments = array();
2435 foreach ($xactions as $key => $xaction) {
2436 $type = $xaction->getTransactionType();
2437 if (isset($types[$type])) {
2438 foreach ($types[$type] as $other_key) {
2439 $other_xaction = $result[$other_key];
2441 // Don't merge transactions with different authors. For example,
2442 // don't merge Herald transactions and owners transactions.
2443 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2447 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2449 $result[$other_key] = $merged;
2451 if ($xaction->getComment() &&
2452 ($xaction->getComment() !== $merged->getComment())) {
2453 $stray_comments[] = $xaction->getComment();
2456 if ($result[$other_key]->getComment() &&
2457 ($result[$other_key]->getComment() !== $merged->getComment())) {
2458 $stray_comments[] = $result[$other_key]->getComment();
2461 // Move on to the next transaction.
2466 $result[$key] = $xaction;
2467 $types[$type][] = $key;
2470 // If we merged any comments away, restore them.
2471 foreach ($stray_comments as $comment) {
2472 $xaction = newv(get_class(head($result)), array());
2473 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_COMMENT
);
2474 $xaction->setComment($comment);
2475 $result[] = $xaction;
2478 return array_values($result);
2481 public function mergePHIDOrEdgeTransactions(
2482 PhabricatorApplicationTransaction
$u,
2483 PhabricatorApplicationTransaction
$v) {
2485 $result = $u->getNewValue();
2486 foreach ($v->getNewValue() as $key => $value) {
2487 if ($u->getTransactionType() == PhabricatorTransactions
::TYPE_EDGE
) {
2488 if (empty($result[$key])) {
2489 $result[$key] = $value;
2491 // We're merging two lists of edge adds, sets, or removes. Merge
2492 // them by merging individual PHIDs within them.
2493 $merged = $result[$key];
2495 foreach ($value as $dst => $v_spec) {
2496 if (empty($merged[$dst])) {
2497 $merged[$dst] = $v_spec;
2499 // Two transactions are trying to perform the same operation on
2500 // the same edge. Normalize the edge data and then merge it. This
2501 // allows transactions to specify how data merges execute in a
2504 $u_spec = $merged[$dst];
2506 if (!is_array($u_spec)) {
2507 $u_spec = array('dst' => $u_spec);
2509 if (!is_array($v_spec)) {
2510 $v_spec = array('dst' => $v_spec);
2513 $ux_data = idx($u_spec, 'data', array());
2514 $vx_data = idx($v_spec, 'data', array());
2516 $merged_data = $this->mergeEdgeData(
2517 $u->getMetadataValue('edge:type'),
2521 $u_spec['data'] = $merged_data;
2522 $merged[$dst] = $u_spec;
2526 $result[$key] = $merged;
2529 $result[$key] = array_merge($value, idx($result, $key, array()));
2532 $u->setNewValue($result);
2534 // When combining an "ignore" transaction with a normal transaction, make
2535 // sure we don't propagate the "ignore" flag.
2536 if (!$v->getIgnoreOnNoEffect()) {
2537 $u->setIgnoreOnNoEffect(false);
2543 protected function mergeEdgeData($type, array $u, array $v) {
2547 protected function getPHIDTransactionNewValue(
2548 PhabricatorApplicationTransaction
$xaction,
2551 if ($old !== null) {
2552 $old = array_fuse($old);
2554 $old = array_fuse($xaction->getOldValue());
2557 return $this->getPHIDList($old, $xaction->getNewValue());
2560 public function getPHIDList(array $old, array $new) {
2561 $new_add = idx($new, '+', array());
2563 $new_rem = idx($new, '-', array());
2565 $new_set = idx($new, '=', null);
2566 if ($new_set !== null) {
2567 $new_set = array_fuse($new_set);
2572 throw new Exception(
2574 "Invalid '%s' value for PHID transaction. Value should contain only ".
2575 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2584 foreach ($old as $phid) {
2585 if ($new_set !== null && empty($new_set[$phid])) {
2588 $result[$phid] = $phid;
2591 if ($new_set !== null) {
2592 foreach ($new_set as $phid) {
2593 $result[$phid] = $phid;
2597 foreach ($new_add as $phid) {
2598 $result[$phid] = $phid;
2601 foreach ($new_rem as $phid) {
2602 unset($result[$phid]);
2605 return array_values($result);
2608 protected function getEdgeTransactionNewValue(
2609 PhabricatorApplicationTransaction
$xaction) {
2611 $new = $xaction->getNewValue();
2612 $new_add = idx($new, '+', array());
2614 $new_rem = idx($new, '-', array());
2616 $new_set = idx($new, '=', null);
2620 throw new Exception(
2622 "Invalid '%s' value for Edge transaction. Value should contain only ".
2623 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2630 $old = $xaction->getOldValue();
2632 $lists = array($new_set, $new_add, $new_rem);
2633 foreach ($lists as $list) {
2634 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2638 foreach ($old as $dst_phid => $edge) {
2639 if ($new_set !== null && empty($new_set[$dst_phid])) {
2642 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2648 if ($new_set !== null) {
2649 foreach ($new_set as $dst_phid => $edge) {
2650 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2657 foreach ($new_add as $dst_phid => $edge) {
2658 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2664 foreach ($new_rem as $dst_phid => $edge) {
2665 unset($result[$dst_phid]);
2671 private function checkEdgeList($list, $edge_type) {
2675 foreach ($list as $key => $item) {
2676 if (phid_get_type($key) === PhabricatorPHIDConstants
::PHID_TYPE_UNKNOWN
) {
2677 throw new Exception(
2679 'Edge transactions must have destination PHIDs as in edge '.
2680 'lists (found key "%s" on transaction of type "%s").',
2684 if (!is_array($item) && $item !== $key) {
2685 throw new Exception(
2687 'Edge transactions must have PHIDs or edge specs as values '.
2688 '(found value "%s" on transaction of type "%s").',
2695 private function normalizeEdgeTransactionValue(
2696 PhabricatorApplicationTransaction
$xaction,
2700 if (!is_array($edge)) {
2701 if ($edge != $dst_phid) {
2702 throw new Exception(
2704 'Transaction edge data must either be the edge PHID or an edge '.
2705 'specification dictionary.'));
2709 foreach ($edge as $key => $value) {
2716 case 'dateModified':
2721 throw new Exception(
2723 'Transaction edge specification contains unexpected key "%s".',
2729 $edge['dst'] = $dst_phid;
2731 $edge_type = $xaction->getMetadataValue('edge:type');
2732 if (empty($edge['type'])) {
2733 $edge['type'] = $edge_type;
2735 if ($edge['type'] != $edge_type) {
2736 $this_type = $edge['type'];
2737 throw new Exception(
2739 "Edge transaction includes edge of type '%s', but ".
2740 "transaction is of type '%s'. Each edge transaction ".
2741 "must alter edges of only one type.",
2747 if (!isset($edge['data'])) {
2748 $edge['data'] = array();
2754 protected function sortTransactions(array $xactions) {
2758 // Move bare comments to the end, so the actions precede them.
2759 foreach ($xactions as $xaction) {
2760 $type = $xaction->getTransactionType();
2761 if ($type == PhabricatorTransactions
::TYPE_COMMENT
) {
2768 return array_values(array_merge($head, $tail));
2772 protected function filterTransactions(
2773 PhabricatorLiskDAO
$object,
2776 $type_comment = PhabricatorTransactions
::TYPE_COMMENT
;
2777 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
2779 $no_effect = array();
2780 $has_comment = false;
2781 $any_effect = false;
2783 $meta_xactions = array();
2784 foreach ($xactions as $key => $xaction) {
2785 if ($xaction->getTransactionType() === $type_mfa) {
2786 $meta_xactions[$key] = $xaction;
2790 if ($this->transactionHasEffect($object, $xaction)) {
2791 if ($xaction->getTransactionType() != $type_comment) {
2794 } else if ($xaction->getIgnoreOnNoEffect()) {
2795 unset($xactions[$key]);
2797 $no_effect[$key] = $xaction;
2800 if ($xaction->hasComment()) {
2801 $has_comment = true;
2805 // If every transaction is a meta-transaction applying to the transaction
2806 // group, these transactions are junk.
2807 if (count($meta_xactions) == count($xactions)) {
2808 $no_effect = $xactions;
2809 $any_effect = false;
2816 // If none of the transactions have an effect, the meta-transactions also
2817 // have no effect. Add them to the "no effect" list so we get a full set
2818 // of errors for everything.
2819 if (!$any_effect && !$has_comment) {
2820 $no_effect +
= $meta_xactions;
2823 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2824 throw new PhabricatorApplicationTransactionNoEffectException(
2830 if (!$any_effect && !$has_comment) {
2831 // If we only have empty comment transactions, just drop them all.
2835 foreach ($no_effect as $key => $xaction) {
2836 if ($xaction->hasComment()) {
2837 $xaction->setTransactionType($type_comment);
2838 $xaction->setOldValue(null);
2839 $xaction->setNewValue(null);
2841 unset($xactions[$key]);
2850 * Hook for validating transactions. This callback will be invoked for each
2851 * available transaction type, even if an edit does not apply any transactions
2852 * of that type. This allows you to raise exceptions when required fields are
2853 * missing, by detecting that the object has no field value and there is no
2854 * transaction which sets one.
2856 * @param PhabricatorLiskDAO Object being edited.
2857 * @param string Transaction type to validate.
2858 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2859 * which may be empty if the edit does not apply any transactions of the
2861 * @return list<PhabricatorApplicationTransactionValidationError> List of
2862 * validation errors.
2864 protected function validateTransaction(
2865 PhabricatorLiskDAO
$object,
2871 $xtype = $this->getModularTransactionType($object, $type);
2873 $errors[] = $xtype->validateTransactions($object, $xactions);
2877 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
2878 $errors[] = $this->validatePolicyTransaction(
2882 PhabricatorPolicyCapability
::CAN_VIEW
);
2884 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
2885 $errors[] = $this->validatePolicyTransaction(
2889 PhabricatorPolicyCapability
::CAN_EDIT
);
2891 case PhabricatorTransactions
::TYPE_SPACE
:
2892 $errors[] = $this->validateSpaceTransactions(
2897 case PhabricatorTransactions
::TYPE_SUBTYPE
:
2898 $errors[] = $this->validateSubtypeTransactions(
2903 case PhabricatorTransactions
::TYPE_MFA
:
2904 $errors[] = $this->validateMFATransactions(
2909 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
2911 foreach ($xactions as $xaction) {
2912 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2915 $field_list = PhabricatorCustomField
::getObjectFields(
2917 PhabricatorCustomField
::ROLE_EDIT
);
2918 $field_list->setViewer($this->getActor());
2920 $role_xactions = PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
;
2921 foreach ($field_list->getFields() as $field) {
2922 if (!$field->shouldEnableForRole($role_xactions)) {
2925 $errors[] = $field->validateApplicationTransactions(
2928 idx($groups, $field->getFieldKey(), array()));
2931 case PhabricatorTransactions
::TYPE_FILE
:
2932 $errors[] = $this->validateFileTransactions(
2939 return array_mergev($errors);
2942 private function validateFileTransactions(
2943 PhabricatorLiskDAO
$object,
2945 $transaction_type) {
2949 $mode_map = PhabricatorFileAttachment
::getModeList();
2950 $mode_map = array_fuse($mode_map);
2952 $file_phids = array();
2953 foreach ($xactions as $xaction) {
2954 $new = $xaction->getNewValue();
2956 if (!is_array($new)) {
2957 $errors[] = new PhabricatorApplicationTransactionValidationError(
2961 'File attachment transaction must have a map of files to '.
2962 'attachment modes, found "%s".',
2963 phutil_describe_type($new)),
2968 foreach ($new as $file_phid => $attachment_mode) {
2969 $file_phids[$file_phid] = $file_phid;
2971 if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
2975 if (!is_string($attachment_mode)) {
2976 $errors[] = new PhabricatorApplicationTransactionValidationError(
2980 'File attachment mode (for file "%s") is invalid. Expected '.
2981 'a string, found "%s".',
2983 phutil_describe_type($attachment_mode)),
2986 $errors[] = new PhabricatorApplicationTransactionValidationError(
2990 'File attachment mode "%s" (for file "%s") is invalid. Valid '.
2994 pht_list($mode_map)),
3001 $file_map = id(new PhabricatorFileQuery())
3002 ->setViewer($this->getActor())
3003 ->withPHIDs($file_phids)
3005 $file_map = mpull($file_map, null, 'getPHID');
3007 $file_map = array();
3010 foreach ($xactions as $xaction) {
3011 $new = $xaction->getNewValue();
3013 if (!is_array($new)) {
3017 foreach ($new as $file_phid => $attachment_mode) {
3018 if (isset($file_map[$file_phid])) {
3022 $errors[] = new PhabricatorApplicationTransactionValidationError(
3026 'File "%s" is invalid: it could not be loaded, or you do not '.
3027 'have permission to view it. You must be able to see a file to '.
3028 'attach it to an object.',
3038 public function validatePolicyTransaction(
3039 PhabricatorLiskDAO
$object,
3044 $actor = $this->requireActor();
3046 // Note $this->xactions is necessary; $xactions is $this->xactions of
3047 // $transaction_type
3048 $policy_object = $this->adjustObjectForPolicyChecks(
3052 // Make sure the user isn't editing away their ability to $capability this
3054 foreach ($xactions as $xaction) {
3056 PhabricatorPolicyFilter
::requireCapabilityWithForcedPolicy(
3060 $xaction->getNewValue());
3061 } catch (PhabricatorPolicyException
$ex) {
3062 $errors[] = new PhabricatorApplicationTransactionValidationError(
3066 'You can not select this %s policy, because you would no longer '.
3067 'be able to %s the object.',
3074 if ($this->getIsNewObject()) {
3076 $has_capability = PhabricatorPolicyFilter
::hasCapability(
3080 if (!$has_capability) {
3081 $errors[] = new PhabricatorApplicationTransactionValidationError(
3085 'The selected %s policy excludes you. Choose a %s policy '.
3086 'which allows you to %s the object.',
3098 private function validateSpaceTransactions(
3099 PhabricatorLiskDAO
$object,
3101 $transaction_type) {
3104 $actor = $this->getActor();
3106 $has_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpacesExist($actor);
3107 $actor_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpaces($actor);
3108 $active_spaces = PhabricatorSpacesNamespaceQuery
::getViewerActiveSpaces(
3110 foreach ($xactions as $xaction) {
3111 $space_phid = $xaction->getNewValue();
3113 if ($space_phid === null) {
3115 // The install doesn't have any spaces, so this is fine.
3119 // The install has some spaces, so every object needs to be put
3120 // in a valid space.
3121 $errors[] = new PhabricatorApplicationTransactionValidationError(
3124 pht('You must choose a space for this object.'),
3129 // If the PHID isn't `null`, it needs to be a valid space that the
3131 if (empty($actor_spaces[$space_phid])) {
3132 $errors[] = new PhabricatorApplicationTransactionValidationError(
3136 'You can not shift this object in the selected space, because '.
3137 'the space does not exist or you do not have access to it.'),
3139 } else if (empty($active_spaces[$space_phid])) {
3141 // It's OK to edit objects in an archived space, so just move on if
3142 // we aren't adjusting the value.
3143 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
3144 if ($space_phid == $old_space_phid) {
3148 $errors[] = new PhabricatorApplicationTransactionValidationError(
3152 'You can not shift this object into the selected space, because '.
3153 'the space is archived. Objects can not be created inside (or '.
3154 'moved into) archived spaces.'),
3162 private function validateSubtypeTransactions(
3163 PhabricatorLiskDAO
$object,
3165 $transaction_type) {
3168 $map = $object->newEditEngineSubtypeMap();
3169 $old = $object->getEditEngineSubtype();
3170 foreach ($xactions as $xaction) {
3171 $new = $xaction->getNewValue();
3177 if (!$map->isValidSubtype($new)) {
3178 $errors[] = new PhabricatorApplicationTransactionValidationError(
3182 'The subtype "%s" is not a valid subtype.',
3192 private function validateMFATransactions(
3193 PhabricatorLiskDAO
$object,
3195 $transaction_type) {
3198 $factors = id(new PhabricatorAuthFactorConfigQuery())
3199 ->setViewer($this->getActor())
3200 ->withUserPHIDs(array($this->getActingAsPHID()))
3201 ->withFactorProviderStatuses(
3203 PhabricatorAuthFactorProviderStatus
::STATUS_ACTIVE
,
3204 PhabricatorAuthFactorProviderStatus
::STATUS_DEPRECATED
,
3208 foreach ($xactions as $xaction) {
3210 $errors[] = new PhabricatorApplicationTransactionValidationError(
3214 'You do not have any MFA factors attached to your account, so '.
3215 'you can not sign this transaction group with MFA. Add MFA to '.
3216 'your account in Settings.'),
3222 $this->setShouldRequireMFA(true);
3228 protected function adjustObjectForPolicyChecks(
3229 PhabricatorLiskDAO
$object,
3232 $copy = clone $object;
3234 foreach ($xactions as $xaction) {
3235 switch ($xaction->getTransactionType()) {
3236 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
3237 $clone_xaction = clone $xaction;
3238 $clone_xaction->setOldValue(array_values($this->subscribers
));
3239 $clone_xaction->setNewValue(
3240 $this->getPHIDTransactionNewValue(
3243 PhabricatorPolicyRule
::passTransactionHintToRule(
3245 new PhabricatorSubscriptionsSubscribersPolicyRule(),
3246 array_fuse($clone_xaction->getNewValue()));
3249 case PhabricatorTransactions
::TYPE_SPACE
:
3250 $space_phid = $this->getTransactionNewValue($object, $xaction);
3251 $copy->setSpacePHID($space_phid);
3259 protected function validateAllTransactions(
3260 PhabricatorLiskDAO
$object,
3266 * Check for a missing text field.
3268 * A text field is missing if the object has no value and there are no
3269 * transactions which set a value, or if the transactions remove the value.
3270 * This method is intended to make implementing @{method:validateTransaction}
3273 * $missing = $this->validateIsEmptyTextField(
3274 * $object->getName(),
3277 * This will return `true` if the net effect of the object and transactions
3278 * is an empty field.
3280 * @param wild Current field value.
3281 * @param list<PhabricatorApplicationTransaction> Transactions editing the
3283 * @return bool True if the field will be an empty text field after edits.
3285 protected function validateIsEmptyTextField($field_value, array $xactions) {
3286 if (($field_value !== null && strlen($field_value)) && empty($xactions)) {
3290 if ($xactions && strlen(last($xactions)->getNewValue())) {
3298 /* -( Implicit CCs )------------------------------------------------------- */
3302 * When a user interacts with an object, we might want to add them to CC.
3304 final public function applyImplicitCC(
3305 PhabricatorLiskDAO
$object,
3308 if (!($object instanceof PhabricatorSubscribableInterface
)) {
3309 // If the object isn't subscribable, we can't CC them.
3313 $actor_phid = $this->getActingAsPHID();
3315 $type_user = PhabricatorPeopleUserPHIDType
::TYPECONST
;
3316 if (phid_get_type($actor_phid) != $type_user) {
3317 // Transactions by application actors like Herald, Harbormaster and
3318 // Diffusion should not CC the applications.
3322 if ($object->isAutomaticallySubscribed($actor_phid)) {
3323 // If they're auto-subscribed, don't CC them.
3328 foreach ($xactions as $xaction) {
3329 if ($this->shouldImplyCC($object, $xaction)) {
3336 // Only some types of actions imply a CC (like adding a comment).
3340 if ($object->getPHID()) {
3341 if (isset($this->subscribers
[$actor_phid])) {
3342 // If the user is already subscribed, don't implicitly CC them.
3346 $unsub = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3348 PhabricatorObjectHasUnsubscriberEdgeType
::EDGECONST
);
3349 $unsub = array_fuse($unsub);
3350 if (isset($unsub[$actor_phid])) {
3351 // If the user has previously unsubscribed from this object explicitly,
3352 // don't implicitly CC them.
3357 $actor = $this->getActor();
3359 $user = id(new PhabricatorPeopleQuery())
3361 ->withPHIDs(array($actor_phid))
3367 // When a bot acts (usually via the API), don't automatically subscribe
3368 // them as a side effect. They can always subscribe explicitly if they
3369 // want, and bot subscriptions normally just clutter things up since bots
3370 // usually do not read email.
3371 if ($user->getIsSystemAgent()) {
3375 $xaction = newv(get_class(head($xactions)), array());
3376 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
);
3377 $xaction->setNewValue(array('+' => array($actor_phid)));
3379 array_unshift($xactions, $xaction);
3384 protected function shouldImplyCC(
3385 PhabricatorLiskDAO
$object,
3386 PhabricatorApplicationTransaction
$xaction) {
3388 return $xaction->isCommentTransaction();
3392 /* -( Sending Mail )------------------------------------------------------- */
3398 protected function shouldSendMail(
3399 PhabricatorLiskDAO
$object,
3408 private function buildMail(
3409 PhabricatorLiskDAO
$object,
3412 $email_to = $this->mailToPHIDs
;
3413 $email_cc = $this->mailCCPHIDs
;
3414 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs
);
3416 $unexpandable = $this->mailUnexpandablePHIDs
;
3417 if (!is_array($unexpandable)) {
3418 $unexpandable = array();
3421 $messages = $this->buildMailWithRecipients(
3428 $this->runHeraldMailRules($messages);
3433 private function buildMailWithRecipients(
3434 PhabricatorLiskDAO
$object,
3438 array $unexpandable) {
3440 $targets = $this->buildReplyHandler($object)
3441 ->setUnexpandablePHIDs($unexpandable)
3442 ->getMailTargets($email_to, $email_cc);
3444 // Set this explicitly before we start swapping out the effective actor.
3445 $this->setActingAsPHID($this->getActingAsPHID());
3447 $xaction_phids = mpull($xactions, 'getPHID');
3449 $messages = array();
3450 foreach ($targets as $target) {
3451 $original_actor = $this->getActor();
3453 $viewer = $target->getViewer();
3454 $this->setActor($viewer);
3455 $locale = PhabricatorEnv
::beginScopedLocale($viewer->getTranslation());
3460 // Reload the transactions for the current viewer.
3461 if ($xaction_phids) {
3462 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject(
3465 $mail_xactions = $query
3466 ->setViewer($viewer)
3467 ->withObjectPHIDs(array($object->getPHID()))
3468 ->withPHIDs($xaction_phids)
3471 // Sort the mail transactions in the input order.
3472 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3473 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3474 $mail_xactions = array_values($mail_xactions);
3476 $mail_xactions = array();
3479 // Reload handles for the current viewer. This covers older code which
3480 // emits a list of handle PHIDs upfront.
3481 $this->loadHandles($mail_xactions);
3483 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3486 if ($this->mustEncrypt
) {
3488 ->setMustEncrypt(true)
3489 ->setMustEncryptReasons($this->mustEncrypt
);
3492 } catch (Exception
$ex) {
3496 $this->setActor($original_actor);
3504 $messages[] = $mail;
3511 protected function getTransactionsForMail(
3512 PhabricatorLiskDAO
$object,
3517 private function buildMailForTarget(
3518 PhabricatorLiskDAO
$object,
3520 PhabricatorMailTarget
$target) {
3522 // Check if any of the transactions are visible for this viewer. If we
3523 // don't have any visible transactions, don't send the mail.
3525 $any_visible = false;
3526 foreach ($xactions as $xaction) {
3527 if (!$xaction->shouldHideForMail($xactions)) {
3528 $any_visible = true;
3533 if (!$any_visible) {
3537 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3539 $mail = $this->buildMailTemplate($object);
3540 $body = $this->buildMailBody($object, $mail_xactions);
3542 $mail_tags = $this->getMailTags($object, $mail_xactions);
3543 $action = $this->getMailAction($object, $mail_xactions);
3544 $stamps = $this->generateMailStamps($object, $this->mailStamps
);
3546 if (PhabricatorEnv
::getEnvConfig('metamta.email-preferences')) {
3547 $this->addEmailPreferenceSectionToMailBody(
3553 $muted_phids = $this->mailMutedPHIDs
;
3554 if (!is_array($muted_phids)) {
3555 $muted_phids = array();
3559 ->setSensitiveContent(false)
3560 ->setFrom($this->getActingAsPHID())
3561 ->setSubjectPrefix($this->getMailSubjectPrefix())
3562 ->setVarySubjectPrefix('['.$action.']')
3563 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3564 ->setRelatedPHID($object->getPHID())
3565 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3566 ->setMutedPHIDs($muted_phids)
3567 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs
)
3568 ->setMailTags($mail_tags)
3570 ->setBody($body->render())
3571 ->setHTMLBody($body->renderHTML());
3573 foreach ($body->getAttachments() as $attachment) {
3574 $mail->addAttachment($attachment);
3577 if ($this->heraldHeader
) {
3578 $mail->addHeader('X-Herald-Rules', $this->heraldHeader
);
3581 if ($object instanceof PhabricatorProjectInterface
) {
3582 $this->addMailProjectMetadata($object, $mail);
3585 if ($this->getParentMessageID()) {
3586 $mail->setParentMessageID($this->getParentMessageID());
3589 // If we have stamps, attach the raw dictionary version (not the actual
3590 // objects) to the mail so that debugging tools can see what we used to
3591 // render the final list.
3592 if ($this->mailStamps
) {
3593 $mail->setMailStampMetadata($this->mailStamps
);
3596 // If we have rendered stamps, attach them to the mail.
3598 $mail->setMailStamps($stamps);
3601 return $target->willSendMail($mail);
3604 private function addMailProjectMetadata(
3605 PhabricatorLiskDAO
$object,
3606 PhabricatorMetaMTAMail
$template) {
3608 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3610 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3612 if (!$project_phids) {
3616 // TODO: This viewer isn't quite right. It would be slightly better to use
3617 // the mail recipient, but that's not very easy given the way rendering
3620 $handles = id(new PhabricatorHandleQuery())
3621 ->setViewer($this->requireActor())
3622 ->withPHIDs($project_phids)
3625 $project_tags = array();
3626 foreach ($handles as $handle) {
3627 if (!$handle->isComplete()) {
3630 $project_tags[] = '<'.$handle->getObjectName().'>';
3633 if (!$project_tags) {
3637 $project_tags = implode(', ', $project_tags);
3638 $template->addHeader('X-Phabricator-Projects', $project_tags);
3642 protected function getMailThreadID(PhabricatorLiskDAO
$object) {
3643 return $object->getPHID();
3650 protected function getStrongestAction(
3651 PhabricatorLiskDAO
$object,
3653 return head(msortv($xactions, 'newActionStrengthSortVector'));
3660 protected function buildReplyHandler(PhabricatorLiskDAO
$object) {
3661 throw new Exception(pht('Capability not supported.'));
3667 protected function getMailSubjectPrefix() {
3668 throw new Exception(pht('Capability not supported.'));
3675 protected function getMailTags(
3676 PhabricatorLiskDAO
$object,
3680 foreach ($xactions as $xaction) {
3681 $tags[] = $xaction->getMailTags();
3684 return array_mergev($tags);
3690 public function getMailTagsMap() {
3691 // TODO: We should move shared mail tags, like "comment", here.
3699 protected function getMailAction(
3700 PhabricatorLiskDAO
$object,
3702 return $this->getStrongestAction($object, $xactions)->getActionName();
3709 protected function buildMailTemplate(PhabricatorLiskDAO
$object) {
3710 throw new Exception(pht('Capability not supported.'));
3717 protected function getMailTo(PhabricatorLiskDAO
$object) {
3718 throw new Exception(pht('Capability not supported.'));
3722 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO
$object) {
3730 protected function getMailCC(PhabricatorLiskDAO
$object) {
3732 $has_support = false;
3734 if ($object instanceof PhabricatorSubscribableInterface
) {
3735 $phid = $object->getPHID();
3736 $phids[] = PhabricatorSubscribersQuery
::loadSubscribersForPHID($phid);
3737 $has_support = true;
3740 if ($object instanceof PhabricatorProjectInterface
) {
3741 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3743 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3745 if ($project_phids) {
3746 $projects = id(new PhabricatorProjectQuery())
3747 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3748 ->withPHIDs($project_phids)
3749 ->needWatchers(true)
3752 $watcher_phids = array();
3753 foreach ($projects as $project) {
3754 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3755 $watcher_phids[$phid] = $phid;
3759 if ($watcher_phids) {
3760 // We need to do a visibility check for all the watchers, as
3761 // watching a project is not a guarantee that you can see objects
3762 // associated with it.
3763 $users = id(new PhabricatorPeopleQuery())
3764 ->setViewer($this->requireActor())
3765 ->withPHIDs($watcher_phids)
3768 $watchers = array();
3769 foreach ($users as $user) {
3770 $can_see = PhabricatorPolicyFilter
::hasCapability(
3773 PhabricatorPolicyCapability
::CAN_VIEW
);
3775 $watchers[] = $user->getPHID();
3778 $phids[] = $watchers;
3782 $has_support = true;
3785 if (!$has_support) {
3786 throw new Exception(
3787 pht('The object being edited does not implement any standard '.
3788 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3789 'CCs to be generated automatically. Override the "getMailCC()" '.
3790 'method and generate CCs explicitly.'));
3793 return array_mergev($phids);
3800 protected function buildMailBody(
3801 PhabricatorLiskDAO
$object,
3804 $body = id(new PhabricatorMetaMTAMailBody())
3805 ->setViewer($this->requireActor())
3806 ->setContextObject($object);
3808 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3809 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3811 $this->addHeadersAndCommentsToMailBody(
3817 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3822 protected function getObjectLinkButtonLabelForMail(
3823 PhabricatorLiskDAO
$object) {
3827 protected function getObjectLinkButtonURIForMail(
3828 PhabricatorLiskDAO
$object) {
3830 // Most objects define a "getURI()" method which does what we want, but
3831 // this isn't formally part of an interface at time of writing. Try to
3832 // call the method, expecting an exception if it does not exist.
3835 $uri = $object->getURI();
3836 return PhabricatorEnv
::getProductionURI($uri);
3837 } catch (Exception
$ex) {
3845 protected function addEmailPreferenceSectionToMailBody(
3846 PhabricatorMetaMTAMailBody
$body,
3847 PhabricatorLiskDAO
$object,
3850 $href = PhabricatorEnv
::getProductionURI(
3851 '/settings/panel/emailpreferences/');
3852 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3859 protected function addHeadersAndCommentsToMailBody(
3860 PhabricatorMetaMTAMailBody
$body,
3862 $object_label = null,
3863 $object_uri = null) {
3865 // First, remove transactions which shouldn't be rendered in mail.
3866 foreach ($xactions as $key => $xaction) {
3867 if ($xaction->shouldHideForMail($xactions)) {
3868 unset($xactions[$key]);
3873 $headers_html = array();
3874 $comments = array();
3877 $seen_comment = false;
3878 foreach ($xactions as $xaction) {
3880 // Most mail has zero or one comments. In these cases, we render the
3881 // "alice added a comment." transaction in the header, like a normal
3884 // Some mail, like Differential undraft mail or "!history" mail, may
3885 // have two or more comments. In these cases, we'll put the first
3886 // "alice added a comment." transaction in the header normally, but
3887 // move the other transactions down so they provide context above the
3890 $comment = $this->getBodyForTextMail($xaction);
3891 if ($comment !== null) {
3893 $comments[] = array(
3894 'xaction' => $xaction,
3895 'comment' => $comment,
3896 'initial' => !$seen_comment,
3899 $is_comment = false;
3902 if (!$is_comment ||
!$seen_comment) {
3903 $header = $this->getTitleForTextMail($xaction);
3904 if ($header !== null) {
3905 $headers[] = $header;
3908 $header_html = $this->getTitleForHTMLMail($xaction);
3909 if ($header_html !== null) {
3910 $headers_html[] = $header_html;
3914 if ($xaction->hasChangeDetailsForMail()) {
3915 $details[] = $xaction;
3919 $seen_comment = true;
3923 $headers_text = implode("\n", $headers);
3924 $body->addRawPlaintextSection($headers_text);
3926 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3928 $header_button = null;
3929 if ($object_label !== null && $object_uri !== null) {
3930 $button_style = array(
3931 'text-decoration: none;',
3932 'padding: 4px 8px;',
3933 'margin: 0 8px 8px;',
3936 'font-weight: bold;',
3937 'border-radius: 3px;',
3938 'background-color: #F7F7F9;',
3939 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3940 'display: inline-block;',
3941 'border: 1px solid rgba(71,87,120,.2);',
3944 $header_button = phutil_tag(
3947 'style' => implode(' ', $button_style),
3948 'href' => $object_uri,
3953 $xactions_style = array();
3955 $header_action = phutil_tag(
3960 $header_action = phutil_tag(
3963 'style' => implode(' ', $xactions_style),
3967 // Add an extra newline to prevent the "View Object" button from
3968 // running into the transaction text in Mail.app text snippet
3973 $headers_html = phutil_tag(
3976 phutil_tag('tr', array(), array($header_action, $header_button)));
3978 $body->addRawHTMLSection($headers_html);
3980 foreach ($comments as $spec) {
3981 $xaction = $spec['xaction'];
3982 $comment = $spec['comment'];
3983 $is_initial = $spec['initial'];
3985 // If this is not the first comment in the mail, add the header showing
3986 // who wrote the comment immediately above the comment.
3988 $header = $this->getTitleForTextMail($xaction);
3989 if ($header !== null) {
3990 $body->addRawPlaintextSection($header);
3993 $header_html = $this->getTitleForHTMLMail($xaction);
3994 if ($header_html !== null) {
3995 $body->addRawHTMLSection($header_html);
3999 $body->addRemarkupSection(null, $comment);
4002 foreach ($details as $xaction) {
4003 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
4004 if ($details !== null) {
4005 $label = $this->getMailDiffSectionHeader($xaction);
4006 $body->addHTMLSection($label, $details);
4012 private function getMailDiffSectionHeader($xaction) {
4013 $type = $xaction->getTransactionType();
4014 $object = $this->object;
4016 $xtype = $this->getModularTransactionType($object, $type);
4018 return $xtype->getMailDiffSectionHeader();
4021 return pht('EDIT DETAILS');
4027 protected function addCustomFieldsToMailBody(
4028 PhabricatorMetaMTAMailBody
$body,
4029 PhabricatorLiskDAO
$object,
4032 if ($object instanceof PhabricatorCustomFieldInterface
) {
4033 $field_list = PhabricatorCustomField
::getObjectFields(
4035 PhabricatorCustomField
::ROLE_TRANSACTIONMAIL
);
4036 $field_list->setViewer($this->getActor());
4037 $field_list->readFieldsFromStorage($object);
4039 foreach ($field_list->getFields() as $field) {
4040 $field->updateTransactionMailBody(
4052 private function runHeraldMailRules(array $messages) {
4053 foreach ($messages as $message) {
4054 $engine = new HeraldEngine();
4055 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
4056 ->setObject($message);
4058 $rules = $engine->loadRulesForAdapter($adapter);
4059 $effects = $engine->applyRules($rules, $adapter);
4060 $engine->applyEffects($effects, $adapter, $rules);
4065 /* -( Publishing Feed Stories )-------------------------------------------- */
4071 protected function shouldPublishFeedStory(
4072 PhabricatorLiskDAO
$object,
4081 protected function getFeedStoryType() {
4082 return 'PhabricatorApplicationTransactionFeedStory';
4089 protected function getFeedRelatedPHIDs(
4090 PhabricatorLiskDAO
$object,
4095 $this->getActingAsPHID(),
4098 if ($object instanceof PhabricatorProjectInterface
) {
4099 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
4101 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
4102 foreach ($project_phids as $project_phid) {
4103 $phids[] = $project_phid;
4114 protected function getFeedNotifyPHIDs(
4115 PhabricatorLiskDAO
$object,
4118 // If some transactions are forcing notification delivery, add the forced
4119 // recipients to the notify list.
4120 $force_list = array();
4121 foreach ($xactions as $xaction) {
4122 $force_phids = $xaction->getForceNotifyPHIDs();
4124 if (!$force_phids) {
4128 foreach ($force_phids as $force_phid) {
4129 $force_list[] = $force_phid;
4133 $to_list = $this->getMailTo($object);
4134 $cc_list = $this->getMailCC($object);
4136 $full_list = array_merge($force_list, $to_list, $cc_list);
4137 $full_list = array_fuse($full_list);
4139 return array_keys($full_list);
4146 protected function getFeedStoryData(
4147 PhabricatorLiskDAO
$object,
4150 $xactions = msortv($xactions, 'newActionStrengthSortVector');
4153 'objectPHID' => $object->getPHID(),
4154 'transactionPHIDs' => mpull($xactions, 'getPHID'),
4162 protected function publishFeedStory(
4163 PhabricatorLiskDAO
$object,
4165 array $mailed_phids) {
4167 // Remove transactions which don't publish feed stories or notifications.
4168 // These never show up anywhere, so we don't need to do anything with them.
4169 foreach ($xactions as $key => $xaction) {
4170 if (!$xaction->shouldHideForFeed()) {
4174 if (!$xaction->shouldHideForNotifications()) {
4178 unset($xactions[$key]);
4185 $related_phids = $this->feedRelatedPHIDs
;
4186 $subscribed_phids = $this->feedNotifyPHIDs
;
4188 // Remove muted users from the subscription list so they don't get
4189 // notifications, either.
4190 $muted_phids = $this->mailMutedPHIDs
;
4191 if (!is_array($muted_phids)) {
4192 $muted_phids = array();
4194 $subscribed_phids = array_fuse($subscribed_phids);
4195 foreach ($muted_phids as $muted_phid) {
4196 unset($subscribed_phids[$muted_phid]);
4198 $subscribed_phids = array_values($subscribed_phids);
4200 $story_type = $this->getFeedStoryType();
4201 $story_data = $this->getFeedStoryData($object, $xactions);
4203 $unexpandable_phids = $this->mailUnexpandablePHIDs
;
4204 if (!is_array($unexpandable_phids)) {
4205 $unexpandable_phids = array();
4208 id(new PhabricatorFeedStoryPublisher())
4209 ->setStoryType($story_type)
4210 ->setStoryData($story_data)
4211 ->setStoryTime(time())
4212 ->setStoryAuthorPHID($this->getActingAsPHID())
4213 ->setRelatedPHIDs($related_phids)
4214 ->setPrimaryObjectPHID($object->getPHID())
4215 ->setSubscribedPHIDs($subscribed_phids)
4216 ->setUnexpandablePHIDs($unexpandable_phids)
4217 ->setMailRecipientPHIDs($mailed_phids)
4218 ->setMailTags($this->getMailTags($object, $xactions))
4223 /* -( Search Index )------------------------------------------------------- */
4229 protected function supportsSearch() {
4234 /* -( Herald Integration )-------------------------------------------------- */
4237 protected function shouldApplyHeraldRules(
4238 PhabricatorLiskDAO
$object,
4243 protected function buildHeraldAdapter(
4244 PhabricatorLiskDAO
$object,
4246 throw new Exception(pht('No herald adapter specified.'));
4249 private function setHeraldAdapter(HeraldAdapter
$adapter) {
4250 $this->heraldAdapter
= $adapter;
4254 protected function getHeraldAdapter() {
4255 return $this->heraldAdapter
;
4258 private function setHeraldTranscript(HeraldTranscript
$transcript) {
4259 $this->heraldTranscript
= $transcript;
4263 protected function getHeraldTranscript() {
4264 return $this->heraldTranscript
;
4267 private function applyHeraldRules(
4268 PhabricatorLiskDAO
$object,
4271 $adapter = $this->buildHeraldAdapter($object, $xactions)
4272 ->setContentSource($this->getContentSource())
4273 ->setIsNewObject($this->getIsNewObject())
4274 ->setActingAsPHID($this->getActingAsPHID())
4275 ->setAppliedTransactions($xactions);
4277 if ($this->getApplicationEmail()) {
4278 $adapter->setApplicationEmail($this->getApplicationEmail());
4281 // If this editor is operating in silent mode, tell Herald that we aren't
4282 // going to send any mail. This allows it to skip "the first time this
4283 // rule matches, send me an email" rules which would otherwise match even
4284 // though we aren't going to send any mail.
4285 if ($this->getIsSilent()) {
4286 $adapter->setForbiddenAction(
4287 HeraldMailableState
::STATECONST
,
4288 HeraldCoreStateReasons
::REASON_SILENT
);
4291 $xscript = HeraldEngine
::loadAndApplyRules($adapter);
4293 $this->setHeraldAdapter($adapter);
4294 $this->setHeraldTranscript($xscript);
4296 if ($adapter instanceof HarbormasterBuildableAdapterInterface
) {
4297 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
4299 HarbormasterBuildable
::applyBuildPlans(
4301 $adapter->getHarbormasterContainerPHID(),
4302 $adapter->getQueuedHarbormasterBuildRequests());
4304 // Whether we queued any builds or not, any automatic buildable for this
4305 // object is now done preparing builds and can transition into a
4306 // completed status.
4307 $buildables = id(new HarbormasterBuildableQuery())
4308 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4309 ->withManualBuildables(false)
4310 ->withBuildablePHIDs(array($buildable_phid))
4312 foreach ($buildables as $buildable) {
4313 // If this buildable has already moved beyond preparation, we don't
4314 // need to nudge it again.
4315 if (!$buildable->isPreparing()) {
4318 $buildable->sendMessage(
4320 HarbormasterMessageType
::BUILDABLE_BUILD
,
4325 $this->mustEncrypt
= $adapter->getMustEncryptReasons();
4327 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
4328 foreach ($this->subEditors
as $sub_editor) {
4329 $sub_editor->mustEncrypt
= $this->mustEncrypt
;
4332 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
4333 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
4335 $queue_xactions = $adapter->getQueuedTransactions();
4338 array_values($apply_xactions),
4339 array_values($queue_xactions));
4342 protected function didApplyHeraldRules(
4343 PhabricatorLiskDAO
$object,
4344 HeraldAdapter
$adapter,
4345 HeraldTranscript
$transcript) {
4350 /* -( Custom Fields )------------------------------------------------------ */
4356 private function getCustomFieldForTransaction(
4357 PhabricatorLiskDAO
$object,
4358 PhabricatorApplicationTransaction
$xaction) {
4360 $field_key = $xaction->getMetadataValue('customfield:key');
4362 throw new Exception(
4364 "Custom field transaction has no '%s'!",
4365 'customfield:key'));
4368 $field = PhabricatorCustomField
::getObjectField(
4370 PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
,
4374 throw new Exception(
4376 "Custom field transaction has invalid '%s'; field '%s' ".
4377 "is disabled or does not exist.",
4382 if (!$field->shouldAppearInApplicationTransactions()) {
4383 throw new Exception(
4385 "Custom field transaction '%s' does not implement ".
4386 "integration for %s.",
4388 'ApplicationTransactions'));
4391 $field->setViewer($this->getActor());
4397 /* -( Files )-------------------------------------------------------------- */
4401 * Extract the PHIDs of any files which these transactions attach.
4405 private function extractFilePHIDs(
4406 PhabricatorLiskDAO
$object,
4411 foreach ($xactions as $xaction) {
4412 $type = $xaction->getTransactionType();
4414 $xtype = $this->getModularTransactionType($object, $type);
4416 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4418 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4424 $phids = array_unique(array_filter(array_mergev($phids)));
4432 protected function extractFilePHIDsFromCustomTransaction(
4433 PhabricatorLiskDAO
$object,
4434 PhabricatorApplicationTransaction
$xaction) {
4439 private function applyInverseEdgeTransactions(
4440 PhabricatorLiskDAO
$object,
4441 PhabricatorApplicationTransaction
$xaction,
4444 $old = $xaction->getOldValue();
4445 $new = $xaction->getNewValue();
4447 $add = array_keys(array_diff_key($new, $old));
4448 $rem = array_keys(array_diff_key($old, $new));
4450 $add = array_fuse($add);
4451 $rem = array_fuse($rem);
4454 $nodes = id(new PhabricatorObjectQuery())
4455 ->setViewer($this->requireActor())
4459 $object_phid = $object->getPHID();
4461 foreach ($nodes as $node) {
4462 if (!($node instanceof PhabricatorApplicationTransactionInterface
)) {
4466 if ($node instanceof PhabricatorUser
) {
4467 // TODO: At least for now, don't record inverse edge transactions
4468 // for users (for example, "alincoln joined project X"): Feed fills
4469 // this role instead.
4473 $node_phid = $node->getPHID();
4474 $editor = $node->getApplicationTransactionEditor();
4475 $template = $node->getApplicationTransactionTemplate();
4477 // See T13082. We have to build these transactions with synthetic values
4478 // because we've already applied the actual edit to the edge database
4479 // table. If we try to apply this transaction naturally, it will no-op
4480 // itself because it doesn't have any effect.
4482 $edge_query = id(new PhabricatorEdgeQuery())
4483 ->withSourcePHIDs(array($node_phid))
4484 ->withEdgeTypes(array($inverse_type));
4486 $edge_query->execute();
4488 $edge_phids = $edge_query->getDestinationPHIDs();
4489 $edge_phids = array_fuse($edge_phids);
4491 $new_phids = $edge_phids;
4492 $old_phids = $edge_phids;
4494 if (isset($add[$node_phid])) {
4495 unset($old_phids[$object_phid]);
4497 $old_phids[$object_phid] = $object_phid;
4501 ->setTransactionType($xaction->getTransactionType())
4502 ->setMetadataValue('edge:type', $inverse_type)
4503 ->setOldValue($old_phids)
4504 ->setNewValue($new_phids);
4506 $editor = $this->newSubEditor($editor)
4507 ->setContinueOnNoEffect(true)
4508 ->setContinueOnMissingFields(true)
4509 ->setIsInverseEdgeEditor(true);
4511 $editor->applyTransactions($node, array($template));
4516 /* -( Workers )------------------------------------------------------------ */
4520 * Load any object state which is required to publish transactions.
4522 * This hook is invoked in the main process before we compute data related
4523 * to publishing transactions (like email "To" and "CC" lists), and again in
4524 * the worker before publishing occurs.
4526 * @return object Publishable object.
4529 protected function willPublish(PhabricatorLiskDAO
$object, array $xactions) {
4535 * Convert the editor state to a serializable dictionary which can be passed
4538 * This data will be loaded with @{method:loadWorkerState} in the worker.
4540 * @return dict<string, wild> Serializable editor state.
4543 private function getWorkerState() {
4545 foreach ($this->getAutomaticStateProperties() as $property) {
4546 $state[$property] = $this->$property;
4549 $custom_state = $this->getCustomWorkerState();
4550 $custom_encoding = $this->getCustomWorkerStateEncoding();
4553 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4554 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4555 'custom.encoding' => $custom_encoding,
4563 * Hook; return custom properties which need to be passed to workers.
4565 * @return dict<string, wild> Custom properties.
4568 protected function getCustomWorkerState() {
4574 * Hook; return storage encoding for custom properties which need to be
4575 * passed to workers.
4577 * This primarily allows binary data to be passed to workers and survive
4580 * @return dict<string, string> Property encodings.
4583 protected function getCustomWorkerStateEncoding() {
4589 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4591 * This method is used to load state when running worker operations.
4593 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4597 final public function loadWorkerState(array $state) {
4598 foreach ($this->getAutomaticStateProperties() as $property) {
4599 $this->$property = idx($state, $property);
4602 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4603 $this->setExcludeMailRecipientPHIDs($exclude);
4605 $custom_state = idx($state, 'custom', array());
4606 $custom_encodings = idx($state, 'custom.encoding', array());
4607 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4609 $this->loadCustomWorkerState($custom);
4616 * Hook; set custom properties on the editor from data emitted by
4617 * @{method:getCustomWorkerState}.
4619 * @param dict<string, wild> Custom state,
4620 * from @{method:getCustomWorkerState}.
4624 protected function loadCustomWorkerState(array $state) {
4630 * Get a list of object properties which should be automatically sent to
4631 * workers in the state data.
4633 * These properties will be automatically stored and loaded by the editor in
4636 * @return list<string> List of properties.
4639 private function getAutomaticStateProperties() {
4644 'heraldForcedEmailPHIDs',
4650 'feedShouldPublish',
4654 'mailUnexpandablePHIDs',
4663 * Apply encodings prior to storage.
4665 * See @{method:getCustomWorkerStateEncoding}.
4667 * @param map<string, wild> Map of values to encode.
4668 * @param map<string, string> Map of encodings to apply.
4669 * @return map<string, wild> Map of encoded values.
4672 private function encodeStateForStorage(
4676 foreach ($state as $key => $value) {
4677 $encoding = idx($encodings, $key);
4678 switch ($encoding) {
4679 case self
::STORAGE_ENCODING_BINARY
:
4680 // The mechanics of this encoding (serialize + base64) are a little
4681 // awkward, but it allows us encode arrays and still be JSON-safe
4682 // with binary data.
4684 $value = @serialize
($value);
4685 if ($value === false) {
4686 throw new Exception(
4688 'Failed to serialize() value for key "%s".',
4692 $value = base64_encode($value);
4693 if ($value === false) {
4694 throw new Exception(
4696 'Failed to base64 encode value for key "%s".',
4701 $state[$key] = $value;
4709 * Undo storage encoding applied when storing state.
4711 * See @{method:getCustomWorkerStateEncoding}.
4713 * @param map<string, wild> Map of encoded values.
4714 * @param map<string, string> Map of encodings.
4715 * @return map<string, wild> Map of decoded values.
4718 private function decodeStateFromStorage(
4722 foreach ($state as $key => $value) {
4723 $encoding = idx($encodings, $key);
4724 switch ($encoding) {
4725 case self
::STORAGE_ENCODING_BINARY
:
4726 $value = base64_decode($value);
4727 if ($value === false) {
4728 throw new Exception(
4730 'Failed to base64_decode() value for key "%s".',
4734 $value = unserialize($value);
4737 $state[$key] = $value;
4745 * Remove conflicts from a list of projects.
4747 * Objects aren't allowed to be tagged with multiple milestones in the same
4748 * group, nor projects such that one tag is the ancestor of any other tag.
4749 * If the list of PHIDs include mutually exclusive projects, remove the
4750 * conflicting projects.
4752 * @param list<phid> List of project PHIDs.
4753 * @return list<phid> List with conflicts removed.
4755 private function applyProjectConflictRules(array $phids) {
4760 // Overall, the last project in the list wins in cases of conflict (so when
4761 // you add something, the thing you just added sticks and removes older
4764 // Beyond that, there are two basic cases:
4766 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4767 // If multiple projects are milestones of the same parent, we only keep the
4770 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4771 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4772 // remove "A > B" and keep "A".
4774 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4775 // if one project is an ancestor of another. It's OK to have something
4776 // tagged with multiple projects which share a common ancestor, so long as
4777 // they are not mutual ancestors.
4779 $viewer = PhabricatorUser
::getOmnipotentUser();
4781 $projects = id(new PhabricatorProjectQuery())
4782 ->setViewer($viewer)
4783 ->withPHIDs(array_keys($phids))
4785 $projects = mpull($projects, null, 'getPHID');
4787 // We're going to build a map from each project with milestones to the last
4788 // milestone in the list. This last milestone is the milestone we'll keep.
4789 $milestone_map = array();
4791 // We're going to build a set of the projects which have no descendants
4792 // later in the list. This allows us to apply both ancestor rules.
4793 $ancestor_map = array();
4795 foreach ($phids as $phid => $ignored) {
4796 $project = idx($projects, $phid);
4801 // This is the last milestone we've seen, so set it as the selection for
4802 // the project's parent. This might be setting a new value or overwriting
4803 // an earlier value.
4804 if ($project->isMilestone()) {
4805 $parent_phid = $project->getParentProjectPHID();
4806 $milestone_map[$parent_phid] = $phid;
4809 // Since this is the last item in the list we've examined so far, add it
4810 // to the set of projects with no later descendants.
4811 $ancestor_map[$phid] = $phid;
4813 // Remove any ancestors from the set, since this is a later descendant.
4814 foreach ($project->getAncestorProjects() as $ancestor) {
4815 $ancestor_phid = $ancestor->getPHID();
4816 unset($ancestor_map[$ancestor_phid]);
4820 // Now that we've built the maps, we can throw away all the projects which
4822 foreach ($phids as $phid => $ignored) {
4823 $project = idx($projects, $phid);
4826 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4827 // but leaving it untouched is less likely to cause collateral damage.
4831 // If this was a milestone, check if it was the last milestone from its
4832 // group in the list. If not, remove it from the list.
4833 if ($project->isMilestone()) {
4834 $parent_phid = $project->getParentProjectPHID();
4835 if ($milestone_map[$parent_phid] !== $phid) {
4836 unset($phids[$phid]);
4841 // If a later project in the list is a subproject of this one, it will
4842 // have removed ancestors from the map. If this project does not point
4843 // at itself in the ancestor map, it should be discarded in favor of a
4844 // subproject that comes later.
4845 if (idx($ancestor_map, $phid) !== $phid) {
4846 unset($phids[$phid]);
4850 // If a later project in the list is an ancestor of this one, it will
4851 // have added itself to the map. If any ancestor of this project points
4852 // at itself in the map, this project should be discarded in favor of
4853 // that later ancestor.
4854 foreach ($project->getAncestorProjects() as $ancestor) {
4855 $ancestor_phid = $ancestor->getPHID();
4856 if (isset($ancestor_map[$ancestor_phid])) {
4857 unset($phids[$phid]);
4867 * When the view policy for an object is changed, scramble the secret keys
4868 * for attached files to invalidate existing URIs.
4870 private function scrambleFileSecrets($object) {
4871 // If this is a newly created object, we don't need to scramble anything
4872 // since it couldn't have been previously published.
4873 if ($this->getIsNewObject()) {
4877 // If the object is a file itself, scramble it.
4878 if ($object instanceof PhabricatorFile
) {
4879 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4880 $object->scrambleSecret();
4885 $omnipotent_viewer = PhabricatorUser
::getOmnipotentUser();
4887 $files = id(new PhabricatorFileQuery())
4888 ->setViewer($omnipotent_viewer)
4889 ->withAttachedObjectPHIDs(array($object->getPHID()))
4891 foreach ($files as $file) {
4892 $view_policy = $file->getViewPolicy();
4893 if ($this->shouldScramblePolicy($view_policy)) {
4894 $file->scrambleSecret();
4902 * Check if a policy is strong enough to justify scrambling. Objects which
4903 * are set to very open policies don't need to scramble their files, and
4904 * files with very open policies don't need to be scrambled when associated
4907 private function shouldScramblePolicy($policy) {
4909 case PhabricatorPolicies
::POLICY_PUBLIC
:
4910 case PhabricatorPolicies
::POLICY_USER
:
4917 private function updateWorkboardColumns($object, $const, $old, $new) {
4918 // If an object is removed from a project, remove it from any proxy
4919 // columns for that project. This allows a task which is moved up from a
4920 // milestone to the parent to move back into the "Backlog" column on the
4921 // parent workboard.
4923 if ($const != PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
) {
4927 // TODO: This should likely be some future WorkboardInterface.
4928 $appears_on_workboards = ($object instanceof ManiphestTask
);
4929 if (!$appears_on_workboards) {
4933 $removed_phids = array_keys(array_diff_key($old, $new));
4934 if (!$removed_phids) {
4938 // Find any proxy columns for the removed projects.
4939 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4940 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4941 ->withProxyPHIDs($removed_phids)
4943 if (!$proxy_columns) {
4947 $proxy_phids = mpull($proxy_columns, 'getPHID');
4949 $position_table = new PhabricatorProjectColumnPosition();
4950 $conn_w = $position_table->establishConnection('w');
4954 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4955 $position_table->getTableName(),
4960 private function getModularTransactionTypes(
4961 PhabricatorLiskDAO
$object) {
4963 if ($this->modularTypes
=== null) {
4964 $template = $object->getApplicationTransactionTemplate();
4965 if ($template instanceof PhabricatorModularTransaction
) {
4966 $xtypes = $template->newModularTransactionTypes();
4967 foreach ($xtypes as $key => $xtype) {
4968 $xtype = clone $xtype;
4969 $xtype->setEditor($this);
4970 $xtypes[$key] = $xtype;
4976 $this->modularTypes
= $xtypes;
4979 return $this->modularTypes
;
4982 private function getModularTransactionType($object, $type) {
4983 $types = $this->getModularTransactionTypes($object);
4984 return idx($types, $type);
4987 public function getCreateObjectTitle($author, $object) {
4988 return pht('%s created this object.', $author);
4991 public function getCreateObjectTitleForFeed($author, $object) {
4992 return pht('%s created an object: %s.', $author, $object);
4995 /* -( Queue )-------------------------------------------------------------- */
4997 protected function queueTransaction(
4998 PhabricatorApplicationTransaction
$xaction) {
4999 $this->transactionQueue
[] = $xaction;
5003 private function flushTransactionQueue($object) {
5004 if (!$this->transactionQueue
) {
5008 $xactions = $this->transactionQueue
;
5009 $this->transactionQueue
= array();
5011 $editor = $this->newEditorCopy();
5013 return $editor->applyTransactions($object, $xactions);
5016 final protected function newSubEditor(
5017 PhabricatorApplicationTransactionEditor
$template = null) {
5018 $editor = $this->newEditorCopy($template);
5020 $editor->parentEditor
= $this;
5021 $this->subEditors
[] = $editor;
5026 private function newEditorCopy(
5027 PhabricatorApplicationTransactionEditor
$template = null) {
5028 if ($template === null) {
5029 $template = newv(get_class($this), array());
5032 $editor = id(clone $template)
5033 ->setActor($this->getActor())
5034 ->setContentSource($this->getContentSource())
5035 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
5036 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
5037 ->setParentMessageID($this->getParentMessageID())
5038 ->setIsSilent($this->getIsSilent());
5040 if ($this->actingAsPHID
!== null) {
5041 $editor->setActingAsPHID($this->actingAsPHID
);
5044 $editor->mustEncrypt
= $this->mustEncrypt
;
5045 $editor->transactionGroupID
= $this->getTransactionGroupID();
5051 /* -( Stamps )------------------------------------------------------------- */
5054 public function newMailStampTemplates($object) {
5055 $actor = $this->getActor();
5057 $templates = array();
5059 $extensions = $this->newMailExtensions($object);
5060 foreach ($extensions as $extension) {
5061 $stamps = $extension->newMailStampTemplates($object);
5062 foreach ($stamps as $stamp) {
5063 $key = $stamp->getKey();
5064 if (isset($templates[$key])) {
5065 throw new Exception(
5067 'Mail extension ("%s") defines a stamp template with the '.
5068 'same key ("%s") as another template. Each stamp template '.
5069 'must have a unique key.',
5070 get_class($extension),
5074 $stamp->setViewer($actor);
5076 $templates[$key] = $stamp;
5083 final public function getMailStamp($key) {
5084 if (!isset($this->stampTemplates
)) {
5085 throw new PhutilInvalidStateException('newMailStampTemplates');
5088 if (!isset($this->stampTemplates
[$key])) {
5089 throw new Exception(
5091 'Editor ("%s") has no mail stamp template with provided key ("%s").',
5096 return $this->stampTemplates
[$key];
5099 private function newMailStamps($object, array $xactions) {
5100 $actor = $this->getActor();
5102 $this->stampTemplates
= $this->newMailStampTemplates($object);
5104 $extensions = $this->newMailExtensions($object);
5106 foreach ($extensions as $extension) {
5107 $extension->newMailStamps($object, $xactions);
5110 return $this->stampTemplates
;
5113 private function newMailExtensions($object) {
5114 $actor = $this->getActor();
5116 $all_extensions = PhabricatorMailEngineExtension
::getAllExtensions();
5118 $extensions = array();
5119 foreach ($all_extensions as $key => $template) {
5120 $extension = id(clone $template)
5124 if ($extension->supportsObject($object)) {
5125 $extensions[$key] = $extension;
5132 protected function newAuxiliaryMail($object, array $xactions) {
5136 private function generateMailStamps($object, $data) {
5137 if (!$data ||
!is_array($data)) {
5141 $templates = $this->newMailStampTemplates($object);
5142 foreach ($data as $spec) {
5143 if (!is_array($spec)) {
5147 $key = idx($spec, 'key');
5148 if (!isset($templates[$key])) {
5152 $type = idx($spec, 'type');
5153 if ($templates[$key]->getStampType() !== $type) {
5157 $value = idx($spec, 'value');
5158 $templates[$key]->setValueFromDictionary($value);
5162 foreach ($templates as $template) {
5163 $value = $template->getValueForRendering();
5165 $rendered = $template->renderStamps($value);
5166 if ($rendered === null) {
5170 $rendered = (array)$rendered;
5171 foreach ($rendered as $stamp) {
5172 $results[] = $stamp;
5176 natcasesort($results);
5181 public function getRemovedRecipientPHIDs() {
5182 return $this->mailRemovedPHIDs
;
5185 private function buildOldRecipientLists($object, $xactions) {
5186 // See T4776. Before we start making any changes, build a list of the old
5187 // recipients. If a change removes a user from the recipient list for an
5188 // object we still want to notify the user about that change. This allows
5189 // them to respond if they didn't want to be removed.
5191 if (!$this->shouldSendMail($object, $xactions)) {
5195 $this->oldTo
= $this->getMailTo($object);
5196 $this->oldCC
= $this->getMailCC($object);
5201 private function applyOldRecipientLists() {
5202 $actor_phid = $this->getActingAsPHID();
5204 // If you took yourself off the recipient list (for example, by
5205 // unsubscribing or resigning) assume that you know what you did and
5206 // don't need to be notified.
5208 // If you just moved from "To" to "Cc" (or vice versa), you're still a
5209 // recipient so we don't need to add you back in.
5211 $map = array_fuse($this->mailToPHIDs
) +
array_fuse($this->mailCCPHIDs
);
5213 foreach ($this->oldTo
as $phid) {
5214 if ($phid === $actor_phid) {
5218 if (isset($map[$phid])) {
5222 $this->mailToPHIDs
[] = $phid;
5223 $this->mailRemovedPHIDs
[] = $phid;
5226 foreach ($this->oldCC
as $phid) {
5227 if ($phid === $actor_phid) {
5231 if (isset($map[$phid])) {
5235 $this->mailCCPHIDs
[] = $phid;
5236 $this->mailRemovedPHIDs
[] = $phid;
5242 private function queueWebhooks($object, array $xactions) {
5243 $hook_viewer = PhabricatorUser
::getOmnipotentUser();
5245 $webhook_map = $this->webhookMap
;
5246 if (!is_array($webhook_map)) {
5247 $webhook_map = array();
5250 // Add any "Firehose" hooks to the list of hooks we're going to call.
5251 $firehose_hooks = id(new HeraldWebhookQuery())
5252 ->setViewer($hook_viewer)
5255 HeraldWebhook
::HOOKSTATUS_FIREHOSE
,
5258 foreach ($firehose_hooks as $firehose_hook) {
5259 // This is "the hook itself is the reason this hook is being called",
5260 // since we're including it because it's configured as a firehose
5262 $hook_phid = $firehose_hook->getPHID();
5263 $webhook_map[$hook_phid][] = $hook_phid;
5266 if (!$webhook_map) {
5270 // NOTE: We're going to queue calls to disabled webhooks, they'll just
5271 // immediately fail in the worker queue. This makes the behavior more
5274 $call_hooks = id(new HeraldWebhookQuery())
5275 ->setViewer($hook_viewer)
5276 ->withPHIDs(array_keys($webhook_map))
5279 foreach ($call_hooks as $call_hook) {
5280 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
5282 $request = HeraldWebhookRequest
::initializeNewWebhookRequest($call_hook)
5283 ->setObjectPHID($object->getPHID())
5284 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
5285 ->setTriggerPHIDs($trigger_phids)
5286 ->setRetryMode(HeraldWebhookRequest
::RETRY_FOREVER
)
5287 ->setIsSilentAction((bool)$this->getIsSilent())
5288 ->setIsSecureAction((bool)$this->getMustEncrypt())
5291 $request->queueCall();
5295 private function hasWarnings($object, $xaction) {
5296 // TODO: For the moment, this is a very un-modular hack to support
5297 // a small number of warnings related to draft revisions. See PHI433.
5299 if (!($object instanceof DifferentialRevision
)) {
5303 $type = $xaction->getTransactionType();
5305 // TODO: This doesn't warn for inlines in Audit, even though they have
5306 // the same overall workflow.
5307 if ($type === DifferentialTransaction
::TYPE_INLINE
) {
5308 return (bool)$xaction->getComment()->getAttribute('editing', false);
5311 if (!$object->isDraft()) {
5315 if ($type != PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5319 // We're only going to raise a warning if the transaction adds subscribers
5320 // other than the acting user. (This implementation is clumsy because the
5321 // code runs before a lot of normalization occurs.)
5323 $old = $this->getTransactionOldValue($object, $xaction);
5324 $new = $this->getPHIDTransactionNewValue($xaction, $old);
5325 $old = array_fuse($old);
5326 $new = array_fuse($new);
5327 $add = array_diff_key($new, $old);
5329 unset($add[$this->getActingAsPHID()]);
5338 private function buildHistoryMail(PhabricatorLiskDAO
$object) {
5339 $viewer = $this->requireActor();
5340 $recipient_phid = $this->getActingAsPHID();
5342 // Load every transaction so we can build a mail message with a complete
5343 // history for the object.
5344 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject($object);
5346 ->setViewer($viewer)
5347 ->withObjectPHIDs(array($object->getPHID()))
5349 $xactions = array_reverse($xactions);
5351 $mail_messages = $this->buildMailWithRecipients(
5354 array($recipient_phid),
5357 $mail = head($mail_messages);
5359 // Since the user explicitly requested "!history", force delivery of this
5360 // message regardless of their other mail settings.
5361 $mail->setForceDelivery(true);
5366 public function newAutomaticInlineTransactions(
5367 PhabricatorLiskDAO
$object,
5369 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5371 $actor = $this->getActor();
5373 $inlines = id(clone $query_template)
5375 ->withObjectPHIDs(array($object->getPHID()))
5376 ->withPublishableComments(true)
5377 ->needAppliedDrafts(true)
5378 ->needReplyToComments(true)
5380 $inlines = msort($inlines, 'getID');
5382 $xactions = array();
5384 foreach ($inlines as $key => $inline) {
5385 $xactions[] = $object->getApplicationTransactionTemplate()
5386 ->setTransactionType($transaction_type)
5387 ->attachComment($inline);
5390 $state_xaction = $this->newInlineStateTransaction(
5394 if ($state_xaction) {
5395 $xactions[] = $state_xaction;
5401 protected function newInlineStateTransaction(
5402 PhabricatorLiskDAO
$object,
5403 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5405 $actor_phid = $this->getActingAsPHID();
5406 $author_phid = $object->getAuthorPHID();
5407 $actor_is_author = ($actor_phid == $author_phid);
5409 $state_map = PhabricatorTransactions
::getInlineStateMap();
5411 $inline_query = id(clone $query_template)
5412 ->setViewer($this->getActor())
5413 ->withObjectPHIDs(array($object->getPHID()))
5414 ->withFixedStates(array_keys($state_map))
5415 ->withPublishableComments(true);
5417 if ($actor_is_author) {
5418 $inline_query->withPublishedComments(true);
5421 $inlines = $inline_query->execute();
5427 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5428 $new_value = array();
5429 foreach ($old_value as $key => $state) {
5430 $new_value[$key] = $state_map[$state];
5433 // See PHI995. Copy some information about the inlines into the transaction
5434 // so we can tailor rendering behavior. In particular, we don't want to
5435 // render transactions about users marking their own inlines as "Done".
5437 $inline_details = array();
5438 foreach ($inlines as $inline) {
5439 $inline_details[$inline->getPHID()] = array(
5440 'authorPHID' => $inline->getAuthorPHID(),
5444 return $object->getApplicationTransactionTemplate()
5445 ->setTransactionType(PhabricatorTransactions
::TYPE_INLINESTATE
)
5446 ->setIgnoreOnNoEffect(true)
5447 ->setMetadataValue('inline.details', $inline_details)
5448 ->setOldValue($old_value)
5449 ->setNewValue($new_value);
5452 private function requireMFA(PhabricatorLiskDAO
$object, array $xactions) {
5453 $actor = $this->getActor();
5455 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5456 if ($actor->isOmnipotent()) {
5460 $editor_class = get_class($this);
5462 $object_phid = $object->getPHID();
5464 $workflow_key = sprintf(
5465 'editor(%s).phid(%s)',
5469 $workflow_key = sprintf(
5474 $request = $this->getRequest();
5475 if ($request === null) {
5476 $source_type = $this->getContentSource()->getSourceTypeConstant();
5477 $conduit_type = PhabricatorConduitContentSource
::SOURCECONST
;
5478 $is_conduit = ($source_type === $conduit_type);
5480 throw new Exception(
5482 'This transaction group requires MFA to apply, but you can not '.
5483 'provide an MFA response via Conduit. Edit this object via the '.
5486 throw new Exception(
5488 'This transaction group requires MFA to apply, but the Editor was '.
5489 'not configured with a Request. This workflow can not perform an '.
5494 $cancel_uri = $this->getCancelURI();
5495 if ($cancel_uri === null) {
5496 throw new Exception(
5498 'This transaction group requires MFA to apply, but the Editor was '.
5499 'not configured with a Cancel URI. This workflow can not perform '.
5503 $token = id(new PhabricatorAuthSessionEngine())
5504 ->setWorkflowKey($workflow_key)
5505 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5507 if (!$token->getIsUnchallengedToken()) {
5508 foreach ($xactions as $xaction) {
5509 $xaction->setIsMFATransaction(true);
5514 private function newMFATransactions(
5515 PhabricatorLiskDAO
$object,
5518 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface
);
5520 $engine = PhabricatorEditEngineMFAEngine
::newEngineForObject($object)
5521 ->setViewer($this->getActor());
5522 $require_mfa = $engine->shouldRequireMFA();
5523 $try_mfa = $engine->shouldTryMFA();
5525 $require_mfa = false;
5529 // If the user is mentioning an MFA object on another object or creating
5530 // a relationship like "parent" or "child" to this object, we always
5531 // allow the edit to move forward without requiring MFA.
5532 if ($this->getIsInverseEdgeEditor()) {
5536 if (!$require_mfa) {
5537 // If the object hasn't already opted into MFA, see if any of the
5538 // transactions want it.
5540 foreach ($xactions as $xaction) {
5541 $type = $xaction->getTransactionType();
5543 $xtype = $this->getModularTransactionType($object, $type);
5545 $xtype = clone $xtype;
5546 $xtype->setStorage($xaction);
5547 if ($xtype->shouldTryMFA($object, $xaction)) {
5556 $this->setShouldRequireMFA(true);
5562 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
5565 foreach ($xactions as $xaction) {
5566 if ($xaction->getTransactionType() === $type_mfa) {
5576 $template = $object->getApplicationTransactionTemplate();
5578 $mfa_xaction = id(clone $template)
5579 ->setTransactionType($type_mfa)
5580 ->setNewValue(true);
5582 array_unshift($xactions, $mfa_xaction);
5587 private function getTitleForTextMail(
5588 PhabricatorApplicationTransaction
$xaction) {
5589 $type = $xaction->getTransactionType();
5590 $object = $this->object;
5592 $xtype = $this->getModularTransactionType($object, $type);
5594 $xtype = clone $xtype;
5595 $xtype->setStorage($xaction);
5596 $comment = $xtype->getTitleForTextMail();
5597 if ($comment !== false) {
5602 return $xaction->getTitleForTextMail();
5605 private function getTitleForHTMLMail(
5606 PhabricatorApplicationTransaction
$xaction) {
5607 $type = $xaction->getTransactionType();
5608 $object = $this->object;
5610 $xtype = $this->getModularTransactionType($object, $type);
5612 $xtype = clone $xtype;
5613 $xtype->setStorage($xaction);
5614 $comment = $xtype->getTitleForHTMLMail();
5615 if ($comment !== false) {
5620 return $xaction->getTitleForHTMLMail();
5624 private function getBodyForTextMail(
5625 PhabricatorApplicationTransaction
$xaction) {
5626 $type = $xaction->getTransactionType();
5627 $object = $this->object;
5629 $xtype = $this->getModularTransactionType($object, $type);
5631 $xtype = clone $xtype;
5632 $xtype->setStorage($xaction);
5633 $comment = $xtype->getBodyForTextMail();
5634 if ($comment !== false) {
5639 return $xaction->getBodyForMail();
5642 private function isLockOverrideTransaction(
5643 PhabricatorApplicationTransaction
$xaction) {
5645 // See PHI1209. When an object is locked, certain types of transactions
5646 // can still be applied without requiring a policy check, like subscribing
5647 // or unsubscribing. We don't want these transactions to show the "Lock
5648 // Override" icon in the transaction timeline.
5650 // We could test if a transaction did no direct policy checks, but it may
5651 // have done additional policy checks during validation, so this is not a
5652 // reliable test (and could cause false negatives, where edits which did
5653 // override a lock are not marked properly).
5655 // For now, do this in a narrow way and just check against a hard-coded
5656 // list of non-override transaction situations. Some day, this should
5657 // likely be modularized.
5660 // Inverse edge edits don't interact with locks.
5661 if ($this->getIsInverseEdgeEditor()) {
5665 // For now, all edits other than subscribes always override locks.
5666 $type = $xaction->getTransactionType();
5667 if ($type !== PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5671 // Subscribes override locks if they affect any users other than the
5674 $acting_phid = $this->getActingAsPHID();
5676 $old = array_fuse($xaction->getOldValue());
5677 $new = array_fuse($xaction->getNewValue());
5678 $add = array_diff_key($new, $old);
5679 $rem = array_diff_key($old, $new);
5682 foreach ($all as $phid) {
5683 if ($phid !== $acting_phid) {
5692 /* -( Extensions )--------------------------------------------------------- */
5695 private function validateTransactionsWithExtensions(
5696 PhabricatorLiskDAO
$object,
5700 $extensions = $this->getEditorExtensions();
5701 foreach ($extensions as $extension) {
5702 $extension_errors = $extension
5703 ->setObject($object)
5704 ->validateTransactions($object, $xactions);
5706 assert_instances_of(
5708 'PhabricatorApplicationTransactionValidationError');
5710 $errors[] = $extension_errors;
5713 return array_mergev($errors);
5716 private function getEditorExtensions() {
5717 if ($this->extensions
=== null) {
5718 $this->extensions
= $this->newEditorExtensions();
5720 return $this->extensions
;
5723 private function newEditorExtensions() {
5724 $extensions = PhabricatorEditorExtension
::getAllExtensions();
5726 $actor = $this->getActor();
5727 $object = $this->object;
5728 foreach ($extensions as $key => $extension) {
5730 $extension = id(clone $extension)
5733 ->setObject($object);
5735 if (!$extension->supportsObject($this, $object)) {
5736 unset($extensions[$key]);
5740 $extensions[$key] = $extension;