5 * Publishing and Managing State
8 * After applying changes, the Editor queues a worker to publish mail, feed,
9 * and notifications, and to perform other background work like updating search
10 * indexes. This allows it to do this work without impacting performance for
13 * When work is moved to the daemons, the Editor state is serialized by
14 * @{method:getWorkerState}, then reloaded in a daemon process by
15 * @{method:loadWorkerState}. **This is fragile.**
17 * State is not persisted into the daemons by default, because we can not send
18 * arbitrary objects into the queue. This means the default behavior of any
19 * state properties is to reset to their defaults without warning prior to
22 * The easiest way to avoid this is to keep Editors stateless: the overwhelming
23 * majority of Editors can be written statelessly. If you need to maintain
24 * state, you can either:
26 * - not require state to exist during publishing; or
27 * - pass state to the daemons by implementing @{method:getCustomWorkerState}
28 * and @{method:loadCustomWorkerState}.
30 * This architecture isn't ideal, and we may eventually split this class into
31 * "Editor" and "Publisher" parts to make it more robust. See T6367 for some
32 * discussion and context.
34 * @task mail Sending Mail
35 * @task feed Publishing Feed Stories
36 * @task search Search Index
37 * @task files Integration with Files
38 * @task workers Managing Workers
40 abstract class PhabricatorApplicationTransactionEditor
41 extends PhabricatorEditor
{
43 private $contentSource;
48 private $mentionedPHIDs;
49 private $continueOnNoEffect;
50 private $continueOnMissingFields;
51 private $raiseWarnings;
52 private $parentMessageID;
53 private $heraldAdapter;
54 private $heraldTranscript;
56 private $unmentionablePHIDMap = array();
57 private $transactionGroupID;
58 private $applicationEmail;
61 private $isHeraldEditor;
62 private $isInverseEdgeEditor;
63 private $actingAsPHID;
65 private $heraldEmailPHIDs = array();
66 private $heraldForcedEmailPHIDs = array();
67 private $heraldHeader;
68 private $mailToPHIDs = array();
69 private $mailCCPHIDs = array();
70 private $feedNotifyPHIDs = array();
71 private $feedRelatedPHIDs = array();
72 private $feedShouldPublish = false;
73 private $mailShouldSend = false;
74 private $modularTypes;
76 private $mustEncrypt = array();
77 private $stampTemplates = array();
78 private $mailStamps = array();
79 private $oldTo = array();
80 private $oldCC = array();
81 private $mailRemovedPHIDs = array();
82 private $mailUnexpandablePHIDs = array();
83 private $mailMutedPHIDs = array();
84 private $webhookMap = array();
86 private $transactionQueue = array();
87 private $sendHistory = false;
88 private $shouldRequireMFA = false;
89 private $hasRequiredMFA = false;
94 private $parentEditor;
95 private $subEditors = array();
96 private $publishableObject;
97 private $publishableTransactions;
99 const STORAGE_ENCODING_BINARY
= 'binary';
102 * Get the class name for the application this editor is a part of.
104 * Uninstalling the application will disable the editor.
106 * @return string Editor's application class name.
108 abstract public function getEditorApplicationClass();
112 * Get a description of the objects this editor edits, like "Differential
115 * @return string Human readable description of edited objects.
117 abstract public function getEditorObjectsDescription();
120 public function setActingAsPHID($acting_as_phid) {
121 $this->actingAsPHID
= $acting_as_phid;
125 public function getActingAsPHID() {
126 if ($this->actingAsPHID
) {
127 return $this->actingAsPHID
;
129 return $this->getActor()->getPHID();
134 * When the editor tries to apply transactions that have no effect, should
135 * it raise an exception (default) or drop them and continue?
137 * Generally, you will set this flag for edits coming from "Edit" interfaces,
138 * and leave it cleared for edits coming from "Comment" interfaces, so the
139 * user will get a useful error if they try to submit a comment that does
140 * nothing (e.g., empty comment with a status change that has already been
141 * performed by another user).
143 * @param bool True to drop transactions without effect and continue.
146 public function setContinueOnNoEffect($continue) {
147 $this->continueOnNoEffect
= $continue;
151 public function getContinueOnNoEffect() {
152 return $this->continueOnNoEffect
;
157 * When the editor tries to apply transactions which don't populate all of
158 * an object's required fields, should it raise an exception (default) or
159 * drop them and continue?
161 * For example, if a user adds a new required custom field (like "Severity")
162 * to a task, all existing tasks won't have it populated. When users
163 * manually edit existing tasks, it's usually desirable to have them provide
164 * a severity. However, other operations (like batch editing just the
165 * owner of a task) will fail by default.
167 * By setting this flag for edit operations which apply to specific fields
168 * (like the priority, batch, and merge editors in Maniphest), these
169 * operations can continue to function even if an object is outdated.
171 * @param bool True to continue when transactions don't completely satisfy
172 * all required fields.
175 public function setContinueOnMissingFields($continue_on_missing_fields) {
176 $this->continueOnMissingFields
= $continue_on_missing_fields;
180 public function getContinueOnMissingFields() {
181 return $this->continueOnMissingFields
;
186 * Not strictly necessary, but reply handlers ideally set this value to
187 * make email threading work better.
189 public function setParentMessageID($parent_message_id) {
190 $this->parentMessageID
= $parent_message_id;
193 public function getParentMessageID() {
194 return $this->parentMessageID
;
197 public function getIsNewObject() {
198 return $this->isNewObject
;
201 public function getMentionedPHIDs() {
202 return $this->mentionedPHIDs
;
205 public function setIsPreview($is_preview) {
206 $this->isPreview
= $is_preview;
210 public function getIsPreview() {
211 return $this->isPreview
;
214 public function setIsSilent($silent) {
215 $this->silent
= $silent;
219 public function getIsSilent() {
220 return $this->silent
;
223 public function getMustEncrypt() {
224 return $this->mustEncrypt
;
227 public function getHeraldRuleMonograms() {
228 // Convert the stored "<123>, <456>" string into a list: "H123", "H456".
229 $list = $this->heraldHeader
;
230 $list = preg_split('/[, ]+/', $list);
232 foreach ($list as $key => $item) {
233 $item = trim($item, '<>');
235 if (!is_numeric($item)) {
240 $list[$key] = 'H'.$item;
246 public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
247 $this->isInverseEdgeEditor
= $is_inverse_edge_editor;
251 public function getIsInverseEdgeEditor() {
252 return $this->isInverseEdgeEditor
;
255 public function setIsHeraldEditor($is_herald_editor) {
256 $this->isHeraldEditor
= $is_herald_editor;
260 public function getIsHeraldEditor() {
261 return $this->isHeraldEditor
;
264 public function addUnmentionablePHIDs(array $phids) {
265 foreach ($phids as $phid) {
266 $this->unmentionablePHIDMap
[$phid] = true;
271 private function getUnmentionablePHIDMap() {
272 return $this->unmentionablePHIDMap
;
275 protected function shouldEnableMentions(
276 PhabricatorLiskDAO
$object,
281 public function setApplicationEmail(
282 PhabricatorMetaMTAApplicationEmail
$email) {
283 $this->applicationEmail
= $email;
287 public function getApplicationEmail() {
288 return $this->applicationEmail
;
291 public function setRaiseWarnings($raise_warnings) {
292 $this->raiseWarnings
= $raise_warnings;
296 public function getRaiseWarnings() {
297 return $this->raiseWarnings
;
300 public function setShouldRequireMFA($should_require_mfa) {
301 if ($this->hasRequiredMFA
) {
304 'Call to setShouldRequireMFA() is too late: this Editor has already '.
305 'checked for MFA requirements.'));
308 $this->shouldRequireMFA
= $should_require_mfa;
312 public function getShouldRequireMFA() {
313 return $this->shouldRequireMFA
;
316 public function getTransactionTypesForObject($object) {
317 $old = $this->object;
319 $this->object = $object;
320 $result = $this->getTransactionTypes();
321 $this->object = $old;
322 } catch (Exception
$ex) {
323 $this->object = $old;
329 public function getTransactionTypes() {
332 $types[] = PhabricatorTransactions
::TYPE_CREATE
;
333 $types[] = PhabricatorTransactions
::TYPE_HISTORY
;
335 if ($this->object instanceof PhabricatorEditEngineSubtypeInterface
) {
336 $types[] = PhabricatorTransactions
::TYPE_SUBTYPE
;
339 if ($this->object instanceof PhabricatorSubscribableInterface
) {
340 $types[] = PhabricatorTransactions
::TYPE_SUBSCRIBERS
;
343 if ($this->object instanceof PhabricatorCustomFieldInterface
) {
344 $types[] = PhabricatorTransactions
::TYPE_CUSTOMFIELD
;
347 if ($this->object instanceof PhabricatorTokenReceiverInterface
) {
348 $types[] = PhabricatorTransactions
::TYPE_TOKEN
;
351 if ($this->object instanceof PhabricatorProjectInterface ||
352 $this->object instanceof PhabricatorMentionableInterface
) {
353 $types[] = PhabricatorTransactions
::TYPE_EDGE
;
356 if ($this->object instanceof PhabricatorSpacesInterface
) {
357 $types[] = PhabricatorTransactions
::TYPE_SPACE
;
360 $types[] = PhabricatorTransactions
::TYPE_MFA
;
362 $template = $this->object->getApplicationTransactionTemplate();
363 if ($template instanceof PhabricatorModularTransaction
) {
364 $xtypes = $template->newModularTransactionTypes();
365 foreach ($xtypes as $xtype) {
366 $types[] = $xtype->getTransactionTypeConstant();
371 $comment = $template->getApplicationTransactionCommentObject();
373 $types[] = PhabricatorTransactions
::TYPE_COMMENT
;
380 private function adjustTransactionValues(
381 PhabricatorLiskDAO
$object,
382 PhabricatorApplicationTransaction
$xaction) {
384 if ($xaction->shouldGenerateOldValue()) {
385 $old = $this->getTransactionOldValue($object, $xaction);
386 $xaction->setOldValue($old);
389 $new = $this->getTransactionNewValue($object, $xaction);
390 $xaction->setNewValue($new);
393 private function getTransactionOldValue(
394 PhabricatorLiskDAO
$object,
395 PhabricatorApplicationTransaction
$xaction) {
397 $type = $xaction->getTransactionType();
399 $xtype = $this->getModularTransactionType($type);
401 $xtype = clone $xtype;
402 $xtype->setStorage($xaction);
403 return $xtype->generateOldValue($object);
407 case PhabricatorTransactions
::TYPE_CREATE
:
408 case PhabricatorTransactions
::TYPE_HISTORY
:
410 case PhabricatorTransactions
::TYPE_SUBTYPE
:
411 return $object->getEditEngineSubtype();
412 case PhabricatorTransactions
::TYPE_MFA
:
414 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
415 return array_values($this->subscribers
);
416 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
417 if ($this->getIsNewObject()) {
420 return $object->getViewPolicy();
421 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
422 if ($this->getIsNewObject()) {
425 return $object->getEditPolicy();
426 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
427 if ($this->getIsNewObject()) {
430 return $object->getJoinPolicy();
431 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
432 if ($this->getIsNewObject()) {
435 return $object->getInteractPolicy();
436 case PhabricatorTransactions
::TYPE_SPACE
:
437 if ($this->getIsNewObject()) {
441 $space_phid = $object->getSpacePHID();
442 if ($space_phid === null) {
443 $default_space = PhabricatorSpacesNamespaceQuery
::getDefaultSpace();
444 if ($default_space) {
445 $space_phid = $default_space->getPHID();
450 case PhabricatorTransactions
::TYPE_EDGE
:
451 $edge_type = $xaction->getMetadataValue('edge:type');
455 "Edge transaction has no '%s'!",
459 // See T13082. If this is an inverse edit, the parent editor has
460 // already populated the transaction values correctly.
461 if ($this->getIsInverseEdgeEditor()) {
462 return $xaction->getOldValue();
465 $old_edges = array();
466 if ($object->getPHID()) {
467 $edge_src = $object->getPHID();
469 $old_edges = id(new PhabricatorEdgeQuery())
470 ->withSourcePHIDs(array($edge_src))
471 ->withEdgeTypes(array($edge_type))
475 $old_edges = $old_edges[$edge_src][$edge_type];
478 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
479 // NOTE: Custom fields have their old value pre-populated when they are
480 // built by PhabricatorCustomFieldList.
481 return $xaction->getOldValue();
482 case PhabricatorTransactions
::TYPE_COMMENT
:
485 return $this->getCustomTransactionOldValue($object, $xaction);
489 private function getTransactionNewValue(
490 PhabricatorLiskDAO
$object,
491 PhabricatorApplicationTransaction
$xaction) {
493 $type = $xaction->getTransactionType();
495 $xtype = $this->getModularTransactionType($type);
497 $xtype = clone $xtype;
498 $xtype->setStorage($xaction);
499 return $xtype->generateNewValue($object, $xaction->getNewValue());
503 case PhabricatorTransactions
::TYPE_CREATE
:
505 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
506 return $this->getPHIDTransactionNewValue($xaction);
507 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
508 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
509 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
510 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
511 case PhabricatorTransactions
::TYPE_TOKEN
:
512 case PhabricatorTransactions
::TYPE_INLINESTATE
:
513 case PhabricatorTransactions
::TYPE_SUBTYPE
:
514 case PhabricatorTransactions
::TYPE_HISTORY
:
515 return $xaction->getNewValue();
516 case PhabricatorTransactions
::TYPE_MFA
:
518 case PhabricatorTransactions
::TYPE_SPACE
:
519 $space_phid = $xaction->getNewValue();
520 if (!strlen($space_phid)) {
521 // If an install has no Spaces or the Spaces controls are not visible
522 // to the viewer, we might end up with the empty string here instead
523 // of a strict `null`, because some controller just used `getStr()`
524 // to read the space PHID from the request.
525 // Just make this work like callers might reasonably expect so we
526 // don't need to handle this specially in every EditController.
527 return $this->getActor()->getDefaultSpacePHID();
531 case PhabricatorTransactions
::TYPE_EDGE
:
532 // See T13082. If this is an inverse edit, the parent editor has
533 // already populated appropriate transaction values.
534 if ($this->getIsInverseEdgeEditor()) {
535 return $xaction->getNewValue();
538 $new_value = $this->getEdgeTransactionNewValue($xaction);
540 $edge_type = $xaction->getMetadataValue('edge:type');
541 $type_project = PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
;
542 if ($edge_type == $type_project) {
543 $new_value = $this->applyProjectConflictRules($new_value);
547 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
548 $field = $this->getCustomFieldForTransaction($object, $xaction);
549 return $field->getNewValueFromApplicationTransactions($xaction);
550 case PhabricatorTransactions
::TYPE_COMMENT
:
553 return $this->getCustomTransactionNewValue($object, $xaction);
557 protected function getCustomTransactionOldValue(
558 PhabricatorLiskDAO
$object,
559 PhabricatorApplicationTransaction
$xaction) {
560 throw new Exception(pht('Capability not supported!'));
563 protected function getCustomTransactionNewValue(
564 PhabricatorLiskDAO
$object,
565 PhabricatorApplicationTransaction
$xaction) {
566 throw new Exception(pht('Capability not supported!'));
569 protected function transactionHasEffect(
570 PhabricatorLiskDAO
$object,
571 PhabricatorApplicationTransaction
$xaction) {
573 switch ($xaction->getTransactionType()) {
574 case PhabricatorTransactions
::TYPE_CREATE
:
575 case PhabricatorTransactions
::TYPE_HISTORY
:
577 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
578 $field = $this->getCustomFieldForTransaction($object, $xaction);
579 return $field->getApplicationTransactionHasEffect($xaction);
580 case PhabricatorTransactions
::TYPE_EDGE
:
581 // A straight value comparison here doesn't always get the right
582 // result, because newly added edges aren't fully populated. Instead,
583 // compare the changes in a more granular way.
584 $old = $xaction->getOldValue();
585 $new = $xaction->getNewValue();
587 $old_dst = array_keys($old);
588 $new_dst = array_keys($new);
590 // NOTE: For now, we don't consider edge reordering to be a change.
591 // We have very few order-dependent edges and effectively no order
592 // oriented UI. This might change in the future.
596 if ($old_dst !== $new_dst) {
597 // We've added or removed edges, so this transaction definitely
602 // We haven't added or removed edges, but we might have changed
604 foreach ($old as $key => $old_value) {
605 $new_value = $new[$key];
606 if ($old_value['data'] !== $new_value['data']) {
614 $type = $xaction->getTransactionType();
615 $xtype = $this->getModularTransactionType($type);
617 return $xtype->getTransactionHasEffect(
619 $xaction->getOldValue(),
620 $xaction->getNewValue());
623 if ($xaction->hasComment()) {
627 return ($xaction->getOldValue() !== $xaction->getNewValue());
630 protected function shouldApplyInitialEffects(
631 PhabricatorLiskDAO
$object,
636 protected function applyInitialEffects(
637 PhabricatorLiskDAO
$object,
639 throw new PhutilMethodNotImplementedException();
642 private function applyInternalEffects(
643 PhabricatorLiskDAO
$object,
644 PhabricatorApplicationTransaction
$xaction) {
646 $type = $xaction->getTransactionType();
648 $xtype = $this->getModularTransactionType($type);
650 $xtype = clone $xtype;
651 $xtype->setStorage($xaction);
652 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
656 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
657 $field = $this->getCustomFieldForTransaction($object, $xaction);
658 return $field->applyApplicationTransactionInternalEffects($xaction);
659 case PhabricatorTransactions
::TYPE_CREATE
:
660 case PhabricatorTransactions
::TYPE_HISTORY
:
661 case PhabricatorTransactions
::TYPE_SUBTYPE
:
662 case PhabricatorTransactions
::TYPE_MFA
:
663 case PhabricatorTransactions
::TYPE_TOKEN
:
664 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
665 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
666 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
667 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
668 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
669 case PhabricatorTransactions
::TYPE_INLINESTATE
:
670 case PhabricatorTransactions
::TYPE_EDGE
:
671 case PhabricatorTransactions
::TYPE_SPACE
:
672 case PhabricatorTransactions
::TYPE_COMMENT
:
673 return $this->applyBuiltinInternalTransaction($object, $xaction);
676 return $this->applyCustomInternalTransaction($object, $xaction);
679 private function applyExternalEffects(
680 PhabricatorLiskDAO
$object,
681 PhabricatorApplicationTransaction
$xaction) {
683 $type = $xaction->getTransactionType();
685 $xtype = $this->getModularTransactionType($type);
687 $xtype = clone $xtype;
688 $xtype->setStorage($xaction);
689 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
693 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
694 $subeditor = id(new PhabricatorSubscriptionsEditor())
696 ->setActor($this->requireActor());
698 $old_map = array_fuse($xaction->getOldValue());
699 $new_map = array_fuse($xaction->getNewValue());
701 $subeditor->unsubscribe(
703 array_diff_key($old_map, $new_map)));
705 $subeditor->subscribeExplicit(
707 array_diff_key($new_map, $old_map)));
711 // for the rest of these edits, subscribers should include those just
712 // added as well as those just removed.
713 $subscribers = array_unique(array_merge(
715 $xaction->getOldValue(),
716 $xaction->getNewValue()));
717 $this->subscribers
= $subscribers;
718 return $this->applyBuiltinExternalTransaction($object, $xaction);
720 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
721 $field = $this->getCustomFieldForTransaction($object, $xaction);
722 return $field->applyApplicationTransactionExternalEffects($xaction);
723 case PhabricatorTransactions
::TYPE_CREATE
:
724 case PhabricatorTransactions
::TYPE_HISTORY
:
725 case PhabricatorTransactions
::TYPE_SUBTYPE
:
726 case PhabricatorTransactions
::TYPE_MFA
:
727 case PhabricatorTransactions
::TYPE_EDGE
:
728 case PhabricatorTransactions
::TYPE_TOKEN
:
729 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
730 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
731 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
732 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
733 case PhabricatorTransactions
::TYPE_INLINESTATE
:
734 case PhabricatorTransactions
::TYPE_SPACE
:
735 case PhabricatorTransactions
::TYPE_COMMENT
:
736 return $this->applyBuiltinExternalTransaction($object, $xaction);
739 return $this->applyCustomExternalTransaction($object, $xaction);
742 protected function applyCustomInternalTransaction(
743 PhabricatorLiskDAO
$object,
744 PhabricatorApplicationTransaction
$xaction) {
745 $type = $xaction->getTransactionType();
748 "Transaction type '%s' is missing an internal apply implementation!",
752 protected function applyCustomExternalTransaction(
753 PhabricatorLiskDAO
$object,
754 PhabricatorApplicationTransaction
$xaction) {
755 $type = $xaction->getTransactionType();
758 "Transaction type '%s' is missing an external apply implementation!",
763 * @{class:PhabricatorTransactions} provides many built-in transactions
764 * which should not require much - if any - code in specific applications.
766 * This method is a hook for the exceedingly-rare cases where you may need
767 * to do **additional** work for built-in transactions. Developers should
768 * extend this method, making sure to return the parent implementation
769 * regardless of handling any transactions.
771 * See also @{method:applyBuiltinExternalTransaction}.
773 protected function applyBuiltinInternalTransaction(
774 PhabricatorLiskDAO
$object,
775 PhabricatorApplicationTransaction
$xaction) {
777 switch ($xaction->getTransactionType()) {
778 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
779 $object->setViewPolicy($xaction->getNewValue());
781 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
782 $object->setEditPolicy($xaction->getNewValue());
784 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
785 $object->setJoinPolicy($xaction->getNewValue());
787 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
788 $object->setInteractPolicy($xaction->getNewValue());
790 case PhabricatorTransactions
::TYPE_SPACE
:
791 $object->setSpacePHID($xaction->getNewValue());
793 case PhabricatorTransactions
::TYPE_SUBTYPE
:
794 $object->setEditEngineSubtype($xaction->getNewValue());
800 * See @{method::applyBuiltinInternalTransaction}.
802 protected function applyBuiltinExternalTransaction(
803 PhabricatorLiskDAO
$object,
804 PhabricatorApplicationTransaction
$xaction) {
806 switch ($xaction->getTransactionType()) {
807 case PhabricatorTransactions
::TYPE_EDGE
:
808 if ($this->getIsInverseEdgeEditor()) {
809 // If we're writing an inverse edge transaction, don't actually
810 // do anything. The initiating editor on the other side of the
811 // transaction will take care of the edge writes.
815 $old = $xaction->getOldValue();
816 $new = $xaction->getNewValue();
817 $src = $object->getPHID();
818 $const = $xaction->getMetadataValue('edge:type');
820 foreach ($new as $dst_phid => $edge) {
821 $new[$dst_phid]['src'] = $src;
824 $editor = new PhabricatorEdgeEditor();
826 foreach ($old as $dst_phid => $edge) {
827 if (!empty($new[$dst_phid])) {
828 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
832 $editor->removeEdge($src, $const, $dst_phid);
835 foreach ($new as $dst_phid => $edge) {
836 if (!empty($old[$dst_phid])) {
837 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
843 'data' => $edge['data'],
846 $editor->addEdge($src, $const, $dst_phid, $data);
851 $this->updateWorkboardColumns($object, $const, $old, $new);
853 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
854 case PhabricatorTransactions
::TYPE_SPACE
:
855 $this->scrambleFileSecrets($object);
857 case PhabricatorTransactions
::TYPE_HISTORY
:
858 $this->sendHistory
= true;
864 * Fill in a transaction's common values, like author and content source.
866 protected function populateTransaction(
867 PhabricatorLiskDAO
$object,
868 PhabricatorApplicationTransaction
$xaction) {
870 $actor = $this->getActor();
872 // TODO: This needs to be more sophisticated once we have meta-policies.
873 $xaction->setViewPolicy(PhabricatorPolicies
::POLICY_PUBLIC
);
875 if ($actor->isOmnipotent()) {
876 $xaction->setEditPolicy(PhabricatorPolicies
::POLICY_NOONE
);
878 $xaction->setEditPolicy($this->getActingAsPHID());
881 // If the transaction already has an explicit author PHID, allow it to
882 // stand. This is used by applications like Owners that hook into the
883 // post-apply change pipeline.
884 if (!$xaction->getAuthorPHID()) {
885 $xaction->setAuthorPHID($this->getActingAsPHID());
888 $xaction->setContentSource($this->getContentSource());
889 $xaction->attachViewer($actor);
890 $xaction->attachObject($object);
892 if ($object->getPHID()) {
893 $xaction->setObjectPHID($object->getPHID());
896 if ($this->getIsSilent()) {
897 $xaction->setIsSilentTransaction(true);
903 protected function didApplyInternalEffects(
904 PhabricatorLiskDAO
$object,
909 protected function applyFinalEffects(
910 PhabricatorLiskDAO
$object,
915 final protected function didCommitTransactions(
916 PhabricatorLiskDAO
$object,
919 foreach ($xactions as $xaction) {
920 $type = $xaction->getTransactionType();
922 // See T13082. When we're writing edges that imply corresponding inverse
923 // transactions, apply those inverse transactions now. We have to wait
924 // until the object we're editing (with this editor) has committed its
925 // transactions to do this. If we don't, the inverse editor may race,
926 // build a mail before we actually commit this object, and render "alice
927 // added an edge: Unknown Object".
929 if ($type === PhabricatorTransactions
::TYPE_EDGE
) {
930 // Don't do anything if we're already an inverse edge editor.
931 if ($this->getIsInverseEdgeEditor()) {
935 $edge_const = $xaction->getMetadataValue('edge:type');
936 $edge_type = PhabricatorEdgeType
::getByConstant($edge_const);
937 if ($edge_type->shouldWriteInverseTransactions()) {
938 $this->applyInverseEdgeTransactions(
941 $edge_type->getInverseEdgeConstant());
946 $xtype = $this->getModularTransactionType($type);
951 $xtype = clone $xtype;
952 $xtype->setStorage($xaction);
953 $xtype->didCommitTransaction($object, $xaction->getNewValue());
957 public function setContentSource(PhabricatorContentSource
$content_source) {
958 $this->contentSource
= $content_source;
962 public function setContentSourceFromRequest(AphrontRequest
$request) {
963 $this->setRequest($request);
964 return $this->setContentSource(
965 PhabricatorContentSource
::newFromRequest($request));
968 public function getContentSource() {
969 return $this->contentSource
;
972 public function setRequest(AphrontRequest
$request) {
973 $this->request
= $request;
977 public function getRequest() {
978 return $this->request
;
981 public function setCancelURI($cancel_uri) {
982 $this->cancelURI
= $cancel_uri;
986 public function getCancelURI() {
987 return $this->cancelURI
;
990 protected function getTransactionGroupID() {
991 if ($this->transactionGroupID
=== null) {
992 $this->transactionGroupID
= Filesystem
::readRandomCharacters(32);
995 return $this->transactionGroupID
;
998 final public function applyTransactions(
999 PhabricatorLiskDAO
$object,
1002 $is_new = ($object->getID() === null);
1003 $this->isNewObject
= $is_new;
1005 $is_preview = $this->getIsPreview();
1006 $read_locking = false;
1007 $transaction_open = false;
1009 // If we're attempting to apply transactions, lock and reload the object
1010 // before we go anywhere. If we don't do this at the very beginning, we
1011 // may be looking at an older version of the object when we populate and
1012 // filter the transactions. See PHI1165 for an example.
1016 $this->buildOldRecipientLists($object, $xactions);
1018 $object->openTransaction();
1019 $transaction_open = true;
1021 $object->beginReadLocking();
1022 $read_locking = true;
1029 $this->object = $object;
1030 $this->xactions
= $xactions;
1032 $this->validateEditParameters($object, $xactions);
1033 $xactions = $this->newMFATransactions($object, $xactions);
1035 $actor = $this->requireActor();
1037 // NOTE: Some transaction expansion requires that the edited object be
1039 foreach ($xactions as $xaction) {
1040 $xaction->attachObject($object);
1041 $xaction->attachViewer($actor);
1044 $xactions = $this->expandTransactions($object, $xactions);
1045 $xactions = $this->expandSupportTransactions($object, $xactions);
1046 $xactions = $this->combineTransactions($xactions);
1048 foreach ($xactions as $xaction) {
1049 $xaction = $this->populateTransaction($object, $xaction);
1054 $type_map = mgroup($xactions, 'getTransactionType');
1055 foreach ($this->getTransactionTypes() as $type) {
1056 $type_xactions = idx($type_map, $type, array());
1057 $errors[] = $this->validateTransaction(
1063 $errors[] = $this->validateAllTransactions($object, $xactions);
1064 $errors[] = $this->validateTransactionsWithExtensions(
1067 $errors = array_mergev($errors);
1069 $continue_on_missing = $this->getContinueOnMissingFields();
1070 foreach ($errors as $key => $error) {
1071 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1072 unset($errors[$key]);
1077 throw new PhabricatorApplicationTransactionValidationException(
1081 if ($this->raiseWarnings
) {
1082 $warnings = array();
1083 foreach ($xactions as $xaction) {
1084 if ($this->hasWarnings($object, $xaction)) {
1085 $warnings[] = $xaction;
1089 throw new PhabricatorApplicationTransactionWarningException(
1095 foreach ($xactions as $xaction) {
1096 $this->adjustTransactionValues($object, $xaction);
1099 // Now that we've merged and combined transactions, check for required
1100 // capabilities. Note that we're doing this before filtering
1101 // transactions: if you try to apply an edit which you do not have
1102 // permission to apply, we want to give you a permissions error even
1103 // if the edit would have no effect.
1104 $this->applyCapabilityChecks($object, $xactions);
1106 $xactions = $this->filterTransactions($object, $xactions);
1109 $this->hasRequiredMFA
= true;
1110 if ($this->getShouldRequireMFA()) {
1111 $this->requireMFA($object, $xactions);
1114 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1115 if (!$transaction_open) {
1116 $object->openTransaction();
1117 $transaction_open = true;
1122 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1123 $this->applyInitialEffects($object, $xactions);
1126 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1127 // figure this out instead.
1128 $mark_as_create = false;
1129 $create_type = PhabricatorTransactions
::TYPE_CREATE
;
1130 foreach ($xactions as $xaction) {
1131 if ($xaction->getTransactionType() == $create_type) {
1132 $mark_as_create = true;
1136 if ($mark_as_create) {
1137 foreach ($xactions as $xaction) {
1138 $xaction->setIsCreateTransaction(true);
1142 $xactions = $this->sortTransactions($xactions);
1143 $file_phids = $this->extractFilePHIDs($object, $xactions);
1146 $this->loadHandles($xactions);
1150 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1152 ->setActingAsPHID($this->getActingAsPHID())
1153 ->setContentSource($this->getContentSource())
1154 ->setIsNewComment(true);
1156 if (!$transaction_open) {
1157 $object->openTransaction();
1158 $transaction_open = true;
1161 // We can technically test any object for CAN_INTERACT, but we can
1162 // run into some issues in doing so (for example, in project unit tests).
1163 // For now, only test for CAN_INTERACT if the object is explicitly a
1166 $was_locked = false;
1167 if ($object instanceof PhabricatorEditEngineLockableInterface
) {
1168 $was_locked = !PhabricatorPolicyFilter
::canInteract($actor, $object);
1171 foreach ($xactions as $xaction) {
1172 $this->applyInternalEffects($object, $xaction);
1175 $xactions = $this->didApplyInternalEffects($object, $xactions);
1179 } catch (AphrontDuplicateKeyQueryException
$ex) {
1180 // This callback has an opportunity to throw a better exception,
1181 // so execution may end here.
1182 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1187 $group_id = $this->getTransactionGroupID();
1189 foreach ($xactions as $xaction) {
1191 $is_override = $this->isLockOverrideTransaction($xaction);
1193 $xaction->setIsLockOverrideTransaction(true);
1197 $xaction->setObjectPHID($object->getPHID());
1198 $xaction->setTransactionGroupID($group_id);
1200 if ($xaction->getComment()) {
1201 $xaction->setPHID($xaction->generatePHID());
1202 $comment_editor->applyEdit($xaction, $xaction->getComment());
1205 // TODO: This is a transitional hack to let us migrate edge
1206 // transactions to a more efficient storage format. For now, we're
1207 // going to write a new slim format to the database but keep the old
1208 // bulky format on the objects so we don't have to upgrade all the
1209 // edit logic to the new format yet. See T13051.
1211 $edge_type = PhabricatorTransactions
::TYPE_EDGE
;
1212 if ($xaction->getTransactionType() == $edge_type) {
1213 $bulky_old = $xaction->getOldValue();
1214 $bulky_new = $xaction->getNewValue();
1216 $record = PhabricatorEdgeChangeRecord
::newFromTransaction($xaction);
1217 $slim_old = $record->getModernOldEdgeTransactionData();
1218 $slim_new = $record->getModernNewEdgeTransactionData();
1220 $xaction->setOldValue($slim_old);
1221 $xaction->setNewValue($slim_new);
1224 $xaction->setOldValue($bulky_old);
1225 $xaction->setNewValue($bulky_new);
1233 $this->attachFiles($object, $file_phids);
1236 foreach ($xactions as $xaction) {
1237 $this->applyExternalEffects($object, $xaction);
1240 $xactions = $this->applyFinalEffects($object, $xactions);
1242 if ($read_locking) {
1243 $object->endReadLocking();
1244 $read_locking = false;
1247 if ($transaction_open) {
1248 $object->saveTransaction();
1249 $transaction_open = false;
1252 $this->didCommitTransactions($object, $xactions);
1254 } catch (Exception
$ex) {
1255 if ($read_locking) {
1256 $object->endReadLocking();
1257 $read_locking = false;
1260 if ($transaction_open) {
1261 $object->killTransaction();
1262 $transaction_open = false;
1268 // If we need to perform cache engine updates, execute them now.
1269 id(new PhabricatorCacheEngine())
1270 ->updateObject($object);
1272 // Now that we've completely applied the core transaction set, try to apply
1273 // Herald rules. Herald rules are allowed to either take direct actions on
1274 // the database (like writing flags), or take indirect actions (like saving
1275 // some targets for CC when we generate mail a little later), or return
1276 // transactions which we'll apply normally using another Editor.
1278 // First, check if *this* is a sub-editor which is itself applying Herald
1279 // rules: if it is, stop working and return so we don't descend into
1282 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1283 // using a Herald editor to apply resulting transactions) and then send out
1284 // mail, notifications, and feed updates about everything.
1286 if ($this->getIsHeraldEditor()) {
1287 // We are the Herald editor, so stop work here and return the updated
1290 } else if ($this->getIsInverseEdgeEditor()) {
1291 // Do not run Herald if we're just recording that this object was
1292 // mentioned elsewhere. This tends to create Herald side effects which
1293 // feel arbitrary, and can really slow down edits which mention a large
1294 // number of other objects. See T13114.
1295 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1296 // We are not the Herald editor, so try to apply Herald rules.
1297 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1299 if ($herald_xactions) {
1300 $xscript_id = $this->getHeraldTranscript()->getID();
1301 foreach ($herald_xactions as $herald_xaction) {
1302 // Don't set a transcript ID if this is a transaction from another
1303 // application or source, like Owners.
1304 if ($herald_xaction->getAuthorPHID()) {
1308 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1311 // NOTE: We're acting as the omnipotent user because rules deal with
1312 // their own policy issues. We use a synthetic author PHID (the
1313 // Herald application) as the author of record, so that transactions
1314 // will render in a reasonable way ("Herald assigned this task ...").
1315 $herald_actor = PhabricatorUser
::getOmnipotentUser();
1316 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1318 // TODO: It would be nice to give transactions a more specific source
1319 // which points at the rule which generated them. You can figure this
1320 // out from transcripts, but it would be cleaner if you didn't have to.
1322 $herald_source = PhabricatorContentSource
::newForSource(
1323 PhabricatorHeraldContentSource
::SOURCECONST
);
1325 $herald_editor = $this->newEditorCopy()
1326 ->setContinueOnNoEffect(true)
1327 ->setContinueOnMissingFields(true)
1328 ->setIsHeraldEditor(true)
1329 ->setActor($herald_actor)
1330 ->setActingAsPHID($herald_phid)
1331 ->setContentSource($herald_source);
1333 $herald_xactions = $herald_editor->applyTransactions(
1337 // Merge the new transactions into the transaction list: we want to
1338 // send email and publish feed stories about them, too.
1339 $xactions = array_merge($xactions, $herald_xactions);
1342 // If Herald did not generate transactions, we may still need to handle
1343 // "Send an Email" rules.
1344 $adapter = $this->getHeraldAdapter();
1345 $this->heraldEmailPHIDs
= $adapter->getEmailPHIDs();
1346 $this->heraldForcedEmailPHIDs
= $adapter->getForcedEmailPHIDs();
1347 $this->webhookMap
= $adapter->getWebhookMap();
1350 $xactions = $this->didApplyTransactions($object, $xactions);
1352 if ($object instanceof PhabricatorCustomFieldInterface
) {
1353 // Maybe this makes more sense to move into the search index itself? For
1354 // now I'm putting it here since I think we might end up with things that
1355 // need it to be up to date once the next page loads, but if we don't go
1356 // there we could move it into search once search moves to the daemons.
1358 // It now happens in the search indexer as well, but the search indexer is
1359 // always daemonized, so the logic above still potentially holds. We could
1360 // possibly get rid of this. The major motivation for putting it in the
1361 // indexer was to enable reindexing to work.
1363 $fields = PhabricatorCustomField
::getObjectFields(
1365 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1366 $fields->readFieldsFromStorage($object);
1367 $fields->rebuildIndexes($object);
1370 $herald_xscript = $this->getHeraldTranscript();
1371 if ($herald_xscript) {
1372 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1373 $herald_header = HeraldTranscript
::saveXHeraldRulesHeader(
1377 $herald_header = HeraldTranscript
::loadXHeraldRulesHeader(
1378 $object->getPHID());
1380 $this->heraldHeader
= $herald_header;
1382 // See PHI1134. If we're a subeditor, we don't publish information about
1383 // the edit yet. Our parent editor still needs to finish applying
1384 // transactions and execute Herald, which may change the information we
1387 // For example, Herald actions may change the parent object's title or
1388 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1391 // Once the parent finishes work, it will queue its own publish step and
1392 // then queue publish steps for its children.
1394 $this->publishableObject
= $object;
1395 $this->publishableTransactions
= $xactions;
1396 if (!$this->parentEditor
) {
1397 $this->queuePublishing();
1403 private function queuePublishing() {
1404 $object = $this->publishableObject
;
1405 $xactions = $this->publishableTransactions
;
1408 throw new Exception(
1410 'Editor method "queuePublishing()" was called, but no publishable '.
1411 'object is present. This Editor is not ready to publish.'));
1414 // We're going to compute some of the data we'll use to publish these
1415 // transactions here, before queueing a worker.
1417 // Primarily, this is more correct: we want to publish the object as it
1418 // exists right now. The worker may not execute for some time, and we want
1419 // to use the current To/CC list, not respect any changes which may occur
1420 // between now and when the worker executes.
1422 // As a secondary benefit, this tends to reduce the amount of state that
1423 // Editors need to pass into workers.
1424 $object = $this->willPublish($object, $xactions);
1426 if (!$this->getIsSilent()) {
1427 if ($this->shouldSendMail($object, $xactions)) {
1428 $this->mailShouldSend
= true;
1429 $this->mailToPHIDs
= $this->getMailTo($object);
1430 $this->mailCCPHIDs
= $this->getMailCC($object);
1431 $this->mailUnexpandablePHIDs
= $this->newMailUnexpandablePHIDs($object);
1433 // Add any recipients who were previously on the notification list
1434 // but were removed by this change.
1435 $this->applyOldRecipientLists();
1437 if ($object instanceof PhabricatorSubscribableInterface
) {
1438 $this->mailMutedPHIDs
= PhabricatorEdgeQuery
::loadDestinationPHIDs(
1440 PhabricatorMutedByEdgeType
::EDGECONST
);
1442 $this->mailMutedPHIDs
= array();
1445 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1446 $stamps = $this->newMailStamps($object, $xactions);
1447 foreach ($stamps as $stamp) {
1448 $this->mailStamps
[] = $stamp->toDictionary();
1452 if ($this->shouldPublishFeedStory($object, $xactions)) {
1453 $this->feedShouldPublish
= true;
1454 $this->feedRelatedPHIDs
= $this->getFeedRelatedPHIDs(
1457 $this->feedNotifyPHIDs
= $this->getFeedNotifyPHIDs(
1463 PhabricatorWorker
::scheduleTask(
1464 'PhabricatorApplicationTransactionPublishWorker',
1466 'objectPHID' => $object->getPHID(),
1467 'actorPHID' => $this->getActingAsPHID(),
1468 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1469 'state' => $this->getWorkerState(),
1472 'objectPHID' => $object->getPHID(),
1473 'priority' => PhabricatorWorker
::PRIORITY_ALERTS
,
1476 foreach ($this->subEditors
as $sub_editor) {
1477 $sub_editor->queuePublishing();
1480 $this->flushTransactionQueue($object);
1483 protected function didCatchDuplicateKeyException(
1484 PhabricatorLiskDAO
$object,
1490 public function publishTransactions(
1491 PhabricatorLiskDAO
$object,
1494 $this->object = $object;
1495 $this->xactions
= $xactions;
1497 // Hook for edges or other properties that may need (re-)loading
1498 $object = $this->willPublish($object, $xactions);
1500 // The object might have changed, so reassign it.
1501 $this->object = $object;
1503 $messages = array();
1504 if ($this->mailShouldSend
) {
1505 $messages = $this->buildMail($object, $xactions);
1508 if ($this->supportsSearch()) {
1509 PhabricatorSearchWorker
::queueDocumentForIndexing(
1512 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1516 if ($this->feedShouldPublish
) {
1518 foreach ($messages as $mail) {
1519 foreach ($mail->buildRecipientList() as $phid) {
1520 $mailed[$phid] = $phid;
1524 $this->publishFeedStory($object, $xactions, $mailed);
1527 if ($this->sendHistory
) {
1528 $history_mail = $this->buildHistoryMail($object);
1529 if ($history_mail) {
1530 $messages[] = $history_mail;
1534 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1535 $messages[] = $message;
1538 // NOTE: This actually sends the mail. We do this last to reduce the chance
1539 // that we send some mail, hit an exception, then send the mail again when
1541 foreach ($messages as $mail) {
1545 $this->queueWebhooks($object, $xactions);
1550 protected function didApplyTransactions($object, array $xactions) {
1551 // Hook for subclasses.
1555 private function loadHandles(array $xactions) {
1557 foreach ($xactions as $key => $xaction) {
1558 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1561 $merged = array_mergev($phids);
1563 $handles = id(new PhabricatorHandleQuery())
1564 ->setViewer($this->requireActor())
1565 ->withPHIDs($merged)
1568 foreach ($xactions as $key => $xaction) {
1569 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1573 private function loadSubscribers(PhabricatorLiskDAO
$object) {
1574 if ($object->getPHID() &&
1575 ($object instanceof PhabricatorSubscribableInterface
)) {
1576 $subs = PhabricatorSubscribersQuery
::loadSubscribersForPHID(
1577 $object->getPHID());
1578 $this->subscribers
= array_fuse($subs);
1580 $this->subscribers
= array();
1584 private function validateEditParameters(
1585 PhabricatorLiskDAO
$object,
1588 if (!$this->getContentSource()) {
1589 throw new PhutilInvalidStateException('setContentSource');
1592 // Do a bunch of sanity checks that the incoming transactions are fresh.
1593 // They should be unsaved and have only "transactionType" and "newValue"
1596 $types = array_fill_keys($this->getTransactionTypes(), true);
1598 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1599 foreach ($xactions as $xaction) {
1600 if ($xaction->getPHID() ||
$xaction->getID()) {
1601 throw new PhabricatorApplicationTransactionStructureException(
1603 pht('You can not apply transactions which already have IDs/PHIDs!'));
1606 if ($xaction->getObjectPHID()) {
1607 throw new PhabricatorApplicationTransactionStructureException(
1610 'You can not apply transactions which already have %s!',
1614 if ($xaction->getCommentPHID()) {
1615 throw new PhabricatorApplicationTransactionStructureException(
1618 'You can not apply transactions which already have %s!',
1622 if ($xaction->getCommentVersion() !== 0) {
1623 throw new PhabricatorApplicationTransactionStructureException(
1626 'You can not apply transactions which already have '.
1627 'commentVersions!'));
1630 $expect_value = !$xaction->shouldGenerateOldValue();
1631 $has_value = $xaction->hasOldValue();
1633 // See T13082. In the narrow case of applying inverse edge edits, we
1634 // expect the old value to be populated.
1635 if ($this->getIsInverseEdgeEditor()) {
1636 $expect_value = true;
1639 if ($expect_value && !$has_value) {
1640 throw new PhabricatorApplicationTransactionStructureException(
1643 'This transaction is supposed to have an %s set, but it does not!',
1647 if ($has_value && !$expect_value) {
1648 throw new PhabricatorApplicationTransactionStructureException(
1651 'This transaction should generate its %s automatically, '.
1652 'but has already had one set!',
1656 $type = $xaction->getTransactionType();
1657 if (empty($types[$type])) {
1658 throw new PhabricatorApplicationTransactionStructureException(
1661 'Transaction has type "%s", but that transaction type is not '.
1662 'supported by this editor (%s).',
1669 private function applyCapabilityChecks(
1670 PhabricatorLiskDAO
$object,
1672 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1674 $can_edit = PhabricatorPolicyCapability
::CAN_EDIT
;
1676 if ($this->getIsNewObject()) {
1677 // If we're creating a new object, we don't need any special capabilities
1678 // on the object. The actor has already made it through creation checks,
1679 // and objects which haven't been created yet often can not be
1680 // meaningfully tested for capabilities anyway.
1681 $required_capabilities = array();
1683 if (!$xactions && !$this->xactions
) {
1684 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1685 $required_capabilities = array($can_edit);
1687 $required_capabilities = array();
1689 foreach ($xactions as $xaction) {
1690 $type = $xaction->getTransactionType();
1692 $xtype = $this->getModularTransactionType($type);
1694 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1696 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1699 // For convenience, we allow flexibility in the return types because
1700 // it's very unusual that a transaction actually requires multiple
1701 // capability checks.
1702 if ($capabilities === null) {
1703 $capabilities = array();
1705 $capabilities = (array)$capabilities;
1708 foreach ($capabilities as $capability) {
1709 $required_capabilities[$capability] = $capability;
1715 $required_capabilities = array_fuse($required_capabilities);
1716 $actor = $this->getActor();
1718 if ($required_capabilities) {
1719 id(new PhabricatorPolicyFilter())
1721 ->requireCapabilities($required_capabilities)
1722 ->raisePolicyExceptions(true)
1723 ->apply(array($object));
1727 private function getLegacyRequiredCapabilities(
1728 PhabricatorApplicationTransaction
$xaction) {
1730 $type = $xaction->getTransactionType();
1732 case PhabricatorTransactions
::TYPE_COMMENT
:
1733 // TODO: Comments technically require CAN_INTERACT, but this is
1734 // currently somewhat special and handled through EditEngine. For now,
1735 // don't enforce it here.
1737 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1738 // Anyone can subscribe to or unsubscribe from anything they can view,
1739 // with no other permissions.
1741 $old = array_fuse($xaction->getOldValue());
1742 $new = array_fuse($xaction->getNewValue());
1744 // To remove users other than yourself, you must be able to edit the
1746 $rem = array_diff_key($old, $new);
1747 foreach ($rem as $phid) {
1748 if ($phid !== $this->getActingAsPHID()) {
1749 return PhabricatorPolicyCapability
::CAN_EDIT
;
1753 // To add users other than yourself, you must be able to interact.
1754 // This allows "@mentioning" users to work as long as you can comment
1757 // If you can edit, we return that policy instead so that you can
1758 // override a soft lock and still make edits.
1760 // TODO: This is a little bit hacky. We really want to be able to say
1761 // "this requires either interact or edit", but there's currently no
1762 // way to specify this kind of requirement.
1764 $can_edit = PhabricatorPolicyFilter
::hasCapability(
1767 PhabricatorPolicyCapability
::CAN_EDIT
);
1769 $add = array_diff_key($new, $old);
1770 foreach ($add as $phid) {
1771 if ($phid !== $this->getActingAsPHID()) {
1773 return PhabricatorPolicyCapability
::CAN_EDIT
;
1775 return PhabricatorPolicyCapability
::CAN_INTERACT
;
1781 case PhabricatorTransactions
::TYPE_TOKEN
:
1782 // TODO: This technically requires CAN_INTERACT, like comments.
1784 case PhabricatorTransactions
::TYPE_HISTORY
:
1785 // This is a special magic transaction which sends you history via
1786 // email and is only partially supported in the upstream. You don't
1787 // need any capabilities to apply it.
1789 case PhabricatorTransactions
::TYPE_MFA
:
1790 // Signing a transaction group with MFA does not require permissions
1793 case PhabricatorTransactions
::TYPE_EDGE
:
1794 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1796 // For other older (non-modular) transactions, always require exactly
1797 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1798 // capabilities must move to ModularTransactions.
1799 return PhabricatorPolicyCapability
::CAN_EDIT
;
1803 private function getLegacyRequiredEdgeCapabilities(
1804 PhabricatorApplicationTransaction
$xaction) {
1806 // You don't need to have edit permission on an object to mention it or
1807 // otherwise add a relationship pointing toward it.
1808 if ($this->getIsInverseEdgeEditor()) {
1812 $edge_type = $xaction->getMetadataValue('edge:type');
1813 switch ($edge_type) {
1814 case PhabricatorMutedByEdgeType
::EDGECONST
:
1815 // At time of writing, you can only write this edge for yourself, so
1816 // you don't need permissions. If you can eventually mute an object
1817 // for other users, this would need to be revisited.
1819 case PhabricatorProjectSilencedEdgeType
::EDGECONST
:
1820 // At time of writing, you can only write this edge for yourself, so
1821 // you don't need permissions. If you can eventually silence project
1822 // for other users, this would need to be revisited.
1824 case PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
:
1826 case PhabricatorProjectProjectHasMemberEdgeType
::EDGECONST
:
1827 $old = $xaction->getOldValue();
1828 $new = $xaction->getNewValue();
1830 $add = array_keys(array_diff_key($new, $old));
1831 $rem = array_keys(array_diff_key($old, $new));
1833 $actor_phid = $this->requireActor()->getPHID();
1835 $is_join = (($add === array($actor_phid)) && !$rem);
1836 $is_leave = (($rem === array($actor_phid)) && !$add);
1839 // You need CAN_JOIN to join a project.
1840 return PhabricatorPolicyCapability
::CAN_JOIN
;
1844 $object = $this->object;
1845 // You usually don't need any capabilities to leave a project...
1846 if ($object->getIsMembershipLocked()) {
1847 // ...you must be able to edit to leave locked projects, though.
1848 return PhabricatorPolicyCapability
::CAN_EDIT
;
1854 // You need CAN_EDIT to change members other than yourself.
1855 return PhabricatorPolicyCapability
::CAN_EDIT
;
1856 case PhabricatorObjectHasWatcherEdgeType
::EDGECONST
:
1857 // See PHI1024. Watching a project does not require CAN_EDIT.
1860 return PhabricatorPolicyCapability
::CAN_EDIT
;
1865 private function buildSubscribeTransaction(
1866 PhabricatorLiskDAO
$object,
1870 if (!($object instanceof PhabricatorSubscribableInterface
)) {
1874 if ($this->shouldEnableMentions($object, $xactions)) {
1875 // Identify newly mentioned users. We ignore users who were previously
1876 // mentioned so that we don't re-subscribe users after an edit of text
1877 // which mentions them.
1878 $old_texts = mpull($changes, 'getOldValue');
1879 $new_texts = mpull($changes, 'getNewValue');
1881 $old_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
1885 $new_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
1889 $phids = array_diff($new_phids, $old_phids);
1894 $this->mentionedPHIDs
= $phids;
1896 if ($object->getPHID()) {
1897 // Don't try to subscribe already-subscribed mentions: we want to generate
1898 // a dialog about an action having no effect if the user explicitly adds
1899 // existing CCs, but not if they merely mention existing subscribers.
1900 $phids = array_diff($phids, $this->subscribers
);
1904 $users = id(new PhabricatorPeopleQuery())
1905 ->setViewer($this->getActor())
1908 $users = mpull($users, null, 'getPHID');
1910 foreach ($phids as $key => $phid) {
1911 $user = idx($users, $phid);
1913 // Don't subscribe invalid users.
1915 unset($phids[$key]);
1919 // Don't subscribe bots that get mentioned. If users truly intend
1920 // to subscribe them, they can add them explicitly, but it's generally
1921 // not useful to subscribe bots to objects.
1922 if ($user->getIsSystemAgent()) {
1923 unset($phids[$key]);
1927 // Do not subscribe mentioned users who do not have permission to see
1929 if ($object instanceof PhabricatorPolicyInterface
) {
1930 $can_view = PhabricatorPolicyFilter
::hasCapability(
1933 PhabricatorPolicyCapability
::CAN_VIEW
);
1935 unset($phids[$key]);
1940 // Don't subscribe users who are already automatically subscribed.
1941 if ($object->isAutomaticallySubscribed($phid)) {
1942 unset($phids[$key]);
1947 $phids = array_values($phids);
1954 $xaction = $object->getApplicationTransactionTemplate()
1955 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
1956 ->setNewValue(array('+' => $phids));
1961 protected function mergeTransactions(
1962 PhabricatorApplicationTransaction
$u,
1963 PhabricatorApplicationTransaction
$v) {
1965 $type = $u->getTransactionType();
1967 $xtype = $this->getModularTransactionType($type);
1969 $object = $this->object;
1970 return $xtype->mergeTransactions($object, $u, $v);
1974 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1975 return $this->mergePHIDOrEdgeTransactions($u, $v);
1976 case PhabricatorTransactions
::TYPE_EDGE
:
1977 $u_type = $u->getMetadataValue('edge:type');
1978 $v_type = $v->getMetadataValue('edge:type');
1979 if ($u_type == $v_type) {
1980 return $this->mergePHIDOrEdgeTransactions($u, $v);
1985 // By default, do not merge the transactions.
1990 * Optionally expand transactions which imply other effects. For example,
1991 * resigning from a revision in Differential implies removing yourself as
1994 protected function expandTransactions(
1995 PhabricatorLiskDAO
$object,
1999 foreach ($xactions as $xaction) {
2000 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2001 $results[] = $expanded;
2008 protected function expandTransaction(
2009 PhabricatorLiskDAO
$object,
2010 PhabricatorApplicationTransaction
$xaction) {
2011 return array($xaction);
2015 public function getExpandedSupportTransactions(
2016 PhabricatorLiskDAO
$object,
2017 PhabricatorApplicationTransaction
$xaction) {
2019 $xactions = array($xaction);
2020 $xactions = $this->expandSupportTransactions(
2024 if (count($xactions) == 1) {
2028 foreach ($xactions as $index => $cxaction) {
2029 if ($cxaction === $xaction) {
2030 unset($xactions[$index]);
2038 private function expandSupportTransactions(
2039 PhabricatorLiskDAO
$object,
2041 $this->loadSubscribers($object);
2043 $xactions = $this->applyImplicitCC($object, $xactions);
2045 $changes = $this->getRemarkupChanges($xactions);
2047 $subscribe_xaction = $this->buildSubscribeTransaction(
2051 if ($subscribe_xaction) {
2052 $xactions[] = $subscribe_xaction;
2055 // TODO: For now, this is just a placeholder.
2056 $engine = PhabricatorMarkupEngine
::getEngine('extract');
2057 $engine->setConfig('viewer', $this->requireActor());
2059 $block_xactions = $this->expandRemarkupBlockTransactions(
2065 foreach ($block_xactions as $xaction) {
2066 $xactions[] = $xaction;
2072 private function getRemarkupChanges(array $xactions) {
2075 foreach ($xactions as $key => $xaction) {
2076 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2077 $changes[] = $change;
2084 private function getRemarkupChangesFromTransaction(
2085 PhabricatorApplicationTransaction
$transaction) {
2086 return $transaction->getRemarkupChanges();
2089 private function expandRemarkupBlockTransactions(
2090 PhabricatorLiskDAO
$object,
2093 PhutilMarkupEngine
$engine) {
2095 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2101 $mentioned_phids = array();
2102 if ($this->shouldEnableMentions($object, $xactions)) {
2103 foreach ($changes as $change) {
2104 // Here, we don't care about processing only new mentions after an edit
2105 // because there is no way for an object to ever "unmention" itself on
2106 // another object, so we can ignore the old value.
2107 $engine->markupText($change->getNewValue());
2109 $mentioned_phids +
= $engine->getTextMetadata(
2110 PhabricatorObjectRemarkupRule
::KEY_MENTIONED_OBJECTS
,
2115 if (!$mentioned_phids) {
2116 return $block_xactions;
2119 $mentioned_objects = id(new PhabricatorObjectQuery())
2120 ->setViewer($this->getActor())
2121 ->withPHIDs($mentioned_phids)
2124 $unmentionable_map = $this->getUnmentionablePHIDMap();
2126 $mentionable_phids = array();
2127 if ($this->shouldEnableMentions($object, $xactions)) {
2128 foreach ($mentioned_objects as $mentioned_object) {
2129 if ($mentioned_object instanceof PhabricatorMentionableInterface
) {
2130 $mentioned_phid = $mentioned_object->getPHID();
2131 if (isset($unmentionable_map[$mentioned_phid])) {
2134 // don't let objects mention themselves
2135 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2138 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2143 if ($mentionable_phids) {
2144 $edge_type = PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
;
2145 $block_xactions[] = newv(get_class(head($xactions)), array())
2146 ->setIgnoreOnNoEffect(true)
2147 ->setTransactionType(PhabricatorTransactions
::TYPE_EDGE
)
2148 ->setMetadataValue('edge:type', $edge_type)
2149 ->setNewValue(array('+' => $mentionable_phids));
2152 return $block_xactions;
2155 protected function expandCustomRemarkupBlockTransactions(
2156 PhabricatorLiskDAO
$object,
2159 PhutilMarkupEngine
$engine) {
2165 * Attempt to combine similar transactions into a smaller number of total
2166 * transactions. For example, two transactions which edit the title of an
2167 * object can be merged into a single edit.
2169 private function combineTransactions(array $xactions) {
2170 $stray_comments = array();
2174 foreach ($xactions as $key => $xaction) {
2175 $type = $xaction->getTransactionType();
2176 if (isset($types[$type])) {
2177 foreach ($types[$type] as $other_key) {
2178 $other_xaction = $result[$other_key];
2180 // Don't merge transactions with different authors. For example,
2181 // don't merge Herald transactions and owners transactions.
2182 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2186 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2188 $result[$other_key] = $merged;
2190 if ($xaction->getComment() &&
2191 ($xaction->getComment() !== $merged->getComment())) {
2192 $stray_comments[] = $xaction->getComment();
2195 if ($result[$other_key]->getComment() &&
2196 ($result[$other_key]->getComment() !== $merged->getComment())) {
2197 $stray_comments[] = $result[$other_key]->getComment();
2200 // Move on to the next transaction.
2205 $result[$key] = $xaction;
2206 $types[$type][] = $key;
2209 // If we merged any comments away, restore them.
2210 foreach ($stray_comments as $comment) {
2211 $xaction = newv(get_class(head($result)), array());
2212 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_COMMENT
);
2213 $xaction->setComment($comment);
2214 $result[] = $xaction;
2217 return array_values($result);
2220 public function mergePHIDOrEdgeTransactions(
2221 PhabricatorApplicationTransaction
$u,
2222 PhabricatorApplicationTransaction
$v) {
2224 $result = $u->getNewValue();
2225 foreach ($v->getNewValue() as $key => $value) {
2226 if ($u->getTransactionType() == PhabricatorTransactions
::TYPE_EDGE
) {
2227 if (empty($result[$key])) {
2228 $result[$key] = $value;
2230 // We're merging two lists of edge adds, sets, or removes. Merge
2231 // them by merging individual PHIDs within them.
2232 $merged = $result[$key];
2234 foreach ($value as $dst => $v_spec) {
2235 if (empty($merged[$dst])) {
2236 $merged[$dst] = $v_spec;
2238 // Two transactions are trying to perform the same operation on
2239 // the same edge. Normalize the edge data and then merge it. This
2240 // allows transactions to specify how data merges execute in a
2243 $u_spec = $merged[$dst];
2245 if (!is_array($u_spec)) {
2246 $u_spec = array('dst' => $u_spec);
2248 if (!is_array($v_spec)) {
2249 $v_spec = array('dst' => $v_spec);
2252 $ux_data = idx($u_spec, 'data', array());
2253 $vx_data = idx($v_spec, 'data', array());
2255 $merged_data = $this->mergeEdgeData(
2256 $u->getMetadataValue('edge:type'),
2260 $u_spec['data'] = $merged_data;
2261 $merged[$dst] = $u_spec;
2265 $result[$key] = $merged;
2268 $result[$key] = array_merge($value, idx($result, $key, array()));
2271 $u->setNewValue($result);
2273 // When combining an "ignore" transaction with a normal transaction, make
2274 // sure we don't propagate the "ignore" flag.
2275 if (!$v->getIgnoreOnNoEffect()) {
2276 $u->setIgnoreOnNoEffect(false);
2282 protected function mergeEdgeData($type, array $u, array $v) {
2286 protected function getPHIDTransactionNewValue(
2287 PhabricatorApplicationTransaction
$xaction,
2290 if ($old !== null) {
2291 $old = array_fuse($old);
2293 $old = array_fuse($xaction->getOldValue());
2296 return $this->getPHIDList($old, $xaction->getNewValue());
2299 public function getPHIDList(array $old, array $new) {
2300 $new_add = idx($new, '+', array());
2302 $new_rem = idx($new, '-', array());
2304 $new_set = idx($new, '=', null);
2305 if ($new_set !== null) {
2306 $new_set = array_fuse($new_set);
2311 throw new Exception(
2313 "Invalid '%s' value for PHID transaction. Value should contain only ".
2314 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2323 foreach ($old as $phid) {
2324 if ($new_set !== null && empty($new_set[$phid])) {
2327 $result[$phid] = $phid;
2330 if ($new_set !== null) {
2331 foreach ($new_set as $phid) {
2332 $result[$phid] = $phid;
2336 foreach ($new_add as $phid) {
2337 $result[$phid] = $phid;
2340 foreach ($new_rem as $phid) {
2341 unset($result[$phid]);
2344 return array_values($result);
2347 protected function getEdgeTransactionNewValue(
2348 PhabricatorApplicationTransaction
$xaction) {
2350 $new = $xaction->getNewValue();
2351 $new_add = idx($new, '+', array());
2353 $new_rem = idx($new, '-', array());
2355 $new_set = idx($new, '=', null);
2359 throw new Exception(
2361 "Invalid '%s' value for Edge transaction. Value should contain only ".
2362 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2369 $old = $xaction->getOldValue();
2371 $lists = array($new_set, $new_add, $new_rem);
2372 foreach ($lists as $list) {
2373 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2377 foreach ($old as $dst_phid => $edge) {
2378 if ($new_set !== null && empty($new_set[$dst_phid])) {
2381 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2387 if ($new_set !== null) {
2388 foreach ($new_set as $dst_phid => $edge) {
2389 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2396 foreach ($new_add as $dst_phid => $edge) {
2397 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2403 foreach ($new_rem as $dst_phid => $edge) {
2404 unset($result[$dst_phid]);
2410 private function checkEdgeList($list, $edge_type) {
2414 foreach ($list as $key => $item) {
2415 if (phid_get_type($key) === PhabricatorPHIDConstants
::PHID_TYPE_UNKNOWN
) {
2416 throw new Exception(
2418 'Edge transactions must have destination PHIDs as in edge '.
2419 'lists (found key "%s" on transaction of type "%s").',
2423 if (!is_array($item) && $item !== $key) {
2424 throw new Exception(
2426 'Edge transactions must have PHIDs or edge specs as values '.
2427 '(found value "%s" on transaction of type "%s").',
2434 private function normalizeEdgeTransactionValue(
2435 PhabricatorApplicationTransaction
$xaction,
2439 if (!is_array($edge)) {
2440 if ($edge != $dst_phid) {
2441 throw new Exception(
2443 'Transaction edge data must either be the edge PHID or an edge '.
2444 'specification dictionary.'));
2448 foreach ($edge as $key => $value) {
2455 case 'dateModified':
2460 throw new Exception(
2462 'Transaction edge specification contains unexpected key "%s".',
2468 $edge['dst'] = $dst_phid;
2470 $edge_type = $xaction->getMetadataValue('edge:type');
2471 if (empty($edge['type'])) {
2472 $edge['type'] = $edge_type;
2474 if ($edge['type'] != $edge_type) {
2475 $this_type = $edge['type'];
2476 throw new Exception(
2478 "Edge transaction includes edge of type '%s', but ".
2479 "transaction is of type '%s'. Each edge transaction ".
2480 "must alter edges of only one type.",
2486 if (!isset($edge['data'])) {
2487 $edge['data'] = array();
2493 protected function sortTransactions(array $xactions) {
2497 // Move bare comments to the end, so the actions precede them.
2498 foreach ($xactions as $xaction) {
2499 $type = $xaction->getTransactionType();
2500 if ($type == PhabricatorTransactions
::TYPE_COMMENT
) {
2507 return array_values(array_merge($head, $tail));
2511 protected function filterTransactions(
2512 PhabricatorLiskDAO
$object,
2515 $type_comment = PhabricatorTransactions
::TYPE_COMMENT
;
2516 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
2518 $no_effect = array();
2519 $has_comment = false;
2520 $any_effect = false;
2522 $meta_xactions = array();
2523 foreach ($xactions as $key => $xaction) {
2524 if ($xaction->getTransactionType() === $type_mfa) {
2525 $meta_xactions[$key] = $xaction;
2529 if ($this->transactionHasEffect($object, $xaction)) {
2530 if ($xaction->getTransactionType() != $type_comment) {
2533 } else if ($xaction->getIgnoreOnNoEffect()) {
2534 unset($xactions[$key]);
2536 $no_effect[$key] = $xaction;
2539 if ($xaction->hasComment()) {
2540 $has_comment = true;
2544 // If every transaction is a meta-transaction applying to the transaction
2545 // group, these transactions are junk.
2546 if (count($meta_xactions) == count($xactions)) {
2547 $no_effect = $xactions;
2548 $any_effect = false;
2555 // If none of the transactions have an effect, the meta-transactions also
2556 // have no effect. Add them to the "no effect" list so we get a full set
2557 // of errors for everything.
2558 if (!$any_effect && !$has_comment) {
2559 $no_effect +
= $meta_xactions;
2562 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2563 throw new PhabricatorApplicationTransactionNoEffectException(
2569 if (!$any_effect && !$has_comment) {
2570 // If we only have empty comment transactions, just drop them all.
2574 foreach ($no_effect as $key => $xaction) {
2575 if ($xaction->hasComment()) {
2576 $xaction->setTransactionType($type_comment);
2577 $xaction->setOldValue(null);
2578 $xaction->setNewValue(null);
2580 unset($xactions[$key]);
2589 * Hook for validating transactions. This callback will be invoked for each
2590 * available transaction type, even if an edit does not apply any transactions
2591 * of that type. This allows you to raise exceptions when required fields are
2592 * missing, by detecting that the object has no field value and there is no
2593 * transaction which sets one.
2595 * @param PhabricatorLiskDAO Object being edited.
2596 * @param string Transaction type to validate.
2597 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2598 * which may be empty if the edit does not apply any transactions of the
2600 * @return list<PhabricatorApplicationTransactionValidationError> List of
2601 * validation errors.
2603 protected function validateTransaction(
2604 PhabricatorLiskDAO
$object,
2610 $xtype = $this->getModularTransactionType($type);
2612 $errors[] = $xtype->validateTransactions($object, $xactions);
2616 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
2617 $errors[] = $this->validatePolicyTransaction(
2621 PhabricatorPolicyCapability
::CAN_VIEW
);
2623 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
2624 $errors[] = $this->validatePolicyTransaction(
2628 PhabricatorPolicyCapability
::CAN_EDIT
);
2630 case PhabricatorTransactions
::TYPE_SPACE
:
2631 $errors[] = $this->validateSpaceTransactions(
2636 case PhabricatorTransactions
::TYPE_SUBTYPE
:
2637 $errors[] = $this->validateSubtypeTransactions(
2642 case PhabricatorTransactions
::TYPE_MFA
:
2643 $errors[] = $this->validateMFATransactions(
2648 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
2650 foreach ($xactions as $xaction) {
2651 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2654 $field_list = PhabricatorCustomField
::getObjectFields(
2656 PhabricatorCustomField
::ROLE_EDIT
);
2657 $field_list->setViewer($this->getActor());
2659 $role_xactions = PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
;
2660 foreach ($field_list->getFields() as $field) {
2661 if (!$field->shouldEnableForRole($role_xactions)) {
2664 $errors[] = $field->validateApplicationTransactions(
2667 idx($groups, $field->getFieldKey(), array()));
2672 return array_mergev($errors);
2675 public function validatePolicyTransaction(
2676 PhabricatorLiskDAO
$object,
2681 $actor = $this->requireActor();
2683 // Note $this->xactions is necessary; $xactions is $this->xactions of
2684 // $transaction_type
2685 $policy_object = $this->adjustObjectForPolicyChecks(
2689 // Make sure the user isn't editing away their ability to $capability this
2691 foreach ($xactions as $xaction) {
2693 PhabricatorPolicyFilter
::requireCapabilityWithForcedPolicy(
2697 $xaction->getNewValue());
2698 } catch (PhabricatorPolicyException
$ex) {
2699 $errors[] = new PhabricatorApplicationTransactionValidationError(
2703 'You can not select this %s policy, because you would no longer '.
2704 'be able to %s the object.',
2711 if ($this->getIsNewObject()) {
2713 $has_capability = PhabricatorPolicyFilter
::hasCapability(
2717 if (!$has_capability) {
2718 $errors[] = new PhabricatorApplicationTransactionValidationError(
2722 'The selected %s policy excludes you. Choose a %s policy '.
2723 'which allows you to %s the object.',
2735 private function validateSpaceTransactions(
2736 PhabricatorLiskDAO
$object,
2738 $transaction_type) {
2741 $actor = $this->getActor();
2743 $has_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpacesExist($actor);
2744 $actor_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpaces($actor);
2745 $active_spaces = PhabricatorSpacesNamespaceQuery
::getViewerActiveSpaces(
2747 foreach ($xactions as $xaction) {
2748 $space_phid = $xaction->getNewValue();
2750 if ($space_phid === null) {
2752 // The install doesn't have any spaces, so this is fine.
2756 // The install has some spaces, so every object needs to be put
2757 // in a valid space.
2758 $errors[] = new PhabricatorApplicationTransactionValidationError(
2761 pht('You must choose a space for this object.'),
2766 // If the PHID isn't `null`, it needs to be a valid space that the
2768 if (empty($actor_spaces[$space_phid])) {
2769 $errors[] = new PhabricatorApplicationTransactionValidationError(
2773 'You can not shift this object in the selected space, because '.
2774 'the space does not exist or you do not have access to it.'),
2776 } else if (empty($active_spaces[$space_phid])) {
2778 // It's OK to edit objects in an archived space, so just move on if
2779 // we aren't adjusting the value.
2780 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
2781 if ($space_phid == $old_space_phid) {
2785 $errors[] = new PhabricatorApplicationTransactionValidationError(
2789 'You can not shift this object into the selected space, because '.
2790 'the space is archived. Objects can not be created inside (or '.
2791 'moved into) archived spaces.'),
2799 private function validateSubtypeTransactions(
2800 PhabricatorLiskDAO
$object,
2802 $transaction_type) {
2805 $map = $object->newEditEngineSubtypeMap();
2806 $old = $object->getEditEngineSubtype();
2807 foreach ($xactions as $xaction) {
2808 $new = $xaction->getNewValue();
2814 if (!$map->isValidSubtype($new)) {
2815 $errors[] = new PhabricatorApplicationTransactionValidationError(
2819 'The subtype "%s" is not a valid subtype.',
2829 private function validateMFATransactions(
2830 PhabricatorLiskDAO
$object,
2832 $transaction_type) {
2835 $factors = id(new PhabricatorAuthFactorConfigQuery())
2836 ->setViewer($this->getActor())
2837 ->withUserPHIDs(array($this->getActingAsPHID()))
2838 ->withFactorProviderStatuses(
2840 PhabricatorAuthFactorProviderStatus
::STATUS_ACTIVE
,
2841 PhabricatorAuthFactorProviderStatus
::STATUS_DEPRECATED
,
2845 foreach ($xactions as $xaction) {
2847 $errors[] = new PhabricatorApplicationTransactionValidationError(
2851 'You do not have any MFA factors attached to your account, so '.
2852 'you can not sign this transaction group with MFA. Add MFA to '.
2853 'your account in Settings.'),
2859 $this->setShouldRequireMFA(true);
2865 protected function adjustObjectForPolicyChecks(
2866 PhabricatorLiskDAO
$object,
2869 $copy = clone $object;
2871 foreach ($xactions as $xaction) {
2872 switch ($xaction->getTransactionType()) {
2873 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
2874 $clone_xaction = clone $xaction;
2875 $clone_xaction->setOldValue(array_values($this->subscribers
));
2876 $clone_xaction->setNewValue(
2877 $this->getPHIDTransactionNewValue(
2880 PhabricatorPolicyRule
::passTransactionHintToRule(
2882 new PhabricatorSubscriptionsSubscribersPolicyRule(),
2883 array_fuse($clone_xaction->getNewValue()));
2886 case PhabricatorTransactions
::TYPE_SPACE
:
2887 $space_phid = $this->getTransactionNewValue($object, $xaction);
2888 $copy->setSpacePHID($space_phid);
2896 protected function validateAllTransactions(
2897 PhabricatorLiskDAO
$object,
2903 * Check for a missing text field.
2905 * A text field is missing if the object has no value and there are no
2906 * transactions which set a value, or if the transactions remove the value.
2907 * This method is intended to make implementing @{method:validateTransaction}
2910 * $missing = $this->validateIsEmptyTextField(
2911 * $object->getName(),
2914 * This will return `true` if the net effect of the object and transactions
2915 * is an empty field.
2917 * @param wild Current field value.
2918 * @param list<PhabricatorApplicationTransaction> Transactions editing the
2920 * @return bool True if the field will be an empty text field after edits.
2922 protected function validateIsEmptyTextField($field_value, array $xactions) {
2923 if (strlen($field_value) && empty($xactions)) {
2927 if ($xactions && strlen(last($xactions)->getNewValue())) {
2935 /* -( Implicit CCs )------------------------------------------------------- */
2939 * When a user interacts with an object, we might want to add them to CC.
2941 final public function applyImplicitCC(
2942 PhabricatorLiskDAO
$object,
2945 if (!($object instanceof PhabricatorSubscribableInterface
)) {
2946 // If the object isn't subscribable, we can't CC them.
2950 $actor_phid = $this->getActingAsPHID();
2952 $type_user = PhabricatorPeopleUserPHIDType
::TYPECONST
;
2953 if (phid_get_type($actor_phid) != $type_user) {
2954 // Transactions by application actors like Herald, Harbormaster and
2955 // Diffusion should not CC the applications.
2959 if ($object->isAutomaticallySubscribed($actor_phid)) {
2960 // If they're auto-subscribed, don't CC them.
2965 foreach ($xactions as $xaction) {
2966 if ($this->shouldImplyCC($object, $xaction)) {
2973 // Only some types of actions imply a CC (like adding a comment).
2977 if ($object->getPHID()) {
2978 if (isset($this->subscribers
[$actor_phid])) {
2979 // If the user is already subscribed, don't implicitly CC them.
2983 $unsub = PhabricatorEdgeQuery
::loadDestinationPHIDs(
2985 PhabricatorObjectHasUnsubscriberEdgeType
::EDGECONST
);
2986 $unsub = array_fuse($unsub);
2987 if (isset($unsub[$actor_phid])) {
2988 // If the user has previously unsubscribed from this object explicitly,
2989 // don't implicitly CC them.
2994 $actor = $this->getActor();
2996 $user = id(new PhabricatorPeopleQuery())
2998 ->withPHIDs(array($actor_phid))
3004 // When a bot acts (usually via the API), don't automatically subscribe
3005 // them as a side effect. They can always subscribe explicitly if they
3006 // want, and bot subscriptions normally just clutter things up since bots
3007 // usually do not read email.
3008 if ($user->getIsSystemAgent()) {
3012 $xaction = newv(get_class(head($xactions)), array());
3013 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
);
3014 $xaction->setNewValue(array('+' => array($actor_phid)));
3016 array_unshift($xactions, $xaction);
3021 protected function shouldImplyCC(
3022 PhabricatorLiskDAO
$object,
3023 PhabricatorApplicationTransaction
$xaction) {
3025 return $xaction->isCommentTransaction();
3029 /* -( Sending Mail )------------------------------------------------------- */
3035 protected function shouldSendMail(
3036 PhabricatorLiskDAO
$object,
3045 private function buildMail(
3046 PhabricatorLiskDAO
$object,
3049 $email_to = $this->mailToPHIDs
;
3050 $email_cc = $this->mailCCPHIDs
;
3051 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs
);
3053 $unexpandable = $this->mailUnexpandablePHIDs
;
3054 if (!is_array($unexpandable)) {
3055 $unexpandable = array();
3058 $messages = $this->buildMailWithRecipients(
3065 $this->runHeraldMailRules($messages);
3070 private function buildMailWithRecipients(
3071 PhabricatorLiskDAO
$object,
3075 array $unexpandable) {
3077 $targets = $this->buildReplyHandler($object)
3078 ->setUnexpandablePHIDs($unexpandable)
3079 ->getMailTargets($email_to, $email_cc);
3081 // Set this explicitly before we start swapping out the effective actor.
3082 $this->setActingAsPHID($this->getActingAsPHID());
3084 $xaction_phids = mpull($xactions, 'getPHID');
3086 $messages = array();
3087 foreach ($targets as $target) {
3088 $original_actor = $this->getActor();
3090 $viewer = $target->getViewer();
3091 $this->setActor($viewer);
3092 $locale = PhabricatorEnv
::beginScopedLocale($viewer->getTranslation());
3097 // Reload the transactions for the current viewer.
3098 if ($xaction_phids) {
3099 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject(
3102 $mail_xactions = $query
3103 ->setViewer($viewer)
3104 ->withObjectPHIDs(array($object->getPHID()))
3105 ->withPHIDs($xaction_phids)
3108 // Sort the mail transactions in the input order.
3109 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3110 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3111 $mail_xactions = array_values($mail_xactions);
3113 $mail_xactions = array();
3116 // Reload handles for the current viewer. This covers older code which
3117 // emits a list of handle PHIDs upfront.
3118 $this->loadHandles($mail_xactions);
3120 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3123 if ($this->mustEncrypt
) {
3125 ->setMustEncrypt(true)
3126 ->setMustEncryptReasons($this->mustEncrypt
);
3129 } catch (Exception
$ex) {
3133 $this->setActor($original_actor);
3141 $messages[] = $mail;
3148 protected function getTransactionsForMail(
3149 PhabricatorLiskDAO
$object,
3154 private function buildMailForTarget(
3155 PhabricatorLiskDAO
$object,
3157 PhabricatorMailTarget
$target) {
3159 // Check if any of the transactions are visible for this viewer. If we
3160 // don't have any visible transactions, don't send the mail.
3162 $any_visible = false;
3163 foreach ($xactions as $xaction) {
3164 if (!$xaction->shouldHideForMail($xactions)) {
3165 $any_visible = true;
3170 if (!$any_visible) {
3174 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3176 $mail = $this->buildMailTemplate($object);
3177 $body = $this->buildMailBody($object, $mail_xactions);
3179 $mail_tags = $this->getMailTags($object, $mail_xactions);
3180 $action = $this->getMailAction($object, $mail_xactions);
3181 $stamps = $this->generateMailStamps($object, $this->mailStamps
);
3183 if (PhabricatorEnv
::getEnvConfig('metamta.email-preferences')) {
3184 $this->addEmailPreferenceSectionToMailBody(
3190 $muted_phids = $this->mailMutedPHIDs
;
3191 if (!is_array($muted_phids)) {
3192 $muted_phids = array();
3196 ->setSensitiveContent(false)
3197 ->setFrom($this->getActingAsPHID())
3198 ->setSubjectPrefix($this->getMailSubjectPrefix())
3199 ->setVarySubjectPrefix('['.$action.']')
3200 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3201 ->setRelatedPHID($object->getPHID())
3202 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3203 ->setMutedPHIDs($muted_phids)
3204 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs
)
3205 ->setMailTags($mail_tags)
3207 ->setBody($body->render())
3208 ->setHTMLBody($body->renderHTML());
3210 foreach ($body->getAttachments() as $attachment) {
3211 $mail->addAttachment($attachment);
3214 if ($this->heraldHeader
) {
3215 $mail->addHeader('X-Herald-Rules', $this->heraldHeader
);
3218 if ($object instanceof PhabricatorProjectInterface
) {
3219 $this->addMailProjectMetadata($object, $mail);
3222 if ($this->getParentMessageID()) {
3223 $mail->setParentMessageID($this->getParentMessageID());
3226 // If we have stamps, attach the raw dictionary version (not the actual
3227 // objects) to the mail so that debugging tools can see what we used to
3228 // render the final list.
3229 if ($this->mailStamps
) {
3230 $mail->setMailStampMetadata($this->mailStamps
);
3233 // If we have rendered stamps, attach them to the mail.
3235 $mail->setMailStamps($stamps);
3238 return $target->willSendMail($mail);
3241 private function addMailProjectMetadata(
3242 PhabricatorLiskDAO
$object,
3243 PhabricatorMetaMTAMail
$template) {
3245 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3247 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3249 if (!$project_phids) {
3253 // TODO: This viewer isn't quite right. It would be slightly better to use
3254 // the mail recipient, but that's not very easy given the way rendering
3257 $handles = id(new PhabricatorHandleQuery())
3258 ->setViewer($this->requireActor())
3259 ->withPHIDs($project_phids)
3262 $project_tags = array();
3263 foreach ($handles as $handle) {
3264 if (!$handle->isComplete()) {
3267 $project_tags[] = '<'.$handle->getObjectName().'>';
3270 if (!$project_tags) {
3274 $project_tags = implode(', ', $project_tags);
3275 $template->addHeader('X-Phabricator-Projects', $project_tags);
3279 protected function getMailThreadID(PhabricatorLiskDAO
$object) {
3280 return $object->getPHID();
3287 protected function getStrongestAction(
3288 PhabricatorLiskDAO
$object,
3290 return head(msortv($xactions, 'newActionStrengthSortVector'));
3297 protected function buildReplyHandler(PhabricatorLiskDAO
$object) {
3298 throw new Exception(pht('Capability not supported.'));
3304 protected function getMailSubjectPrefix() {
3305 throw new Exception(pht('Capability not supported.'));
3312 protected function getMailTags(
3313 PhabricatorLiskDAO
$object,
3317 foreach ($xactions as $xaction) {
3318 $tags[] = $xaction->getMailTags();
3321 return array_mergev($tags);
3327 public function getMailTagsMap() {
3328 // TODO: We should move shared mail tags, like "comment", here.
3336 protected function getMailAction(
3337 PhabricatorLiskDAO
$object,
3339 return $this->getStrongestAction($object, $xactions)->getActionName();
3346 protected function buildMailTemplate(PhabricatorLiskDAO
$object) {
3347 throw new Exception(pht('Capability not supported.'));
3354 protected function getMailTo(PhabricatorLiskDAO
$object) {
3355 throw new Exception(pht('Capability not supported.'));
3359 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO
$object) {
3367 protected function getMailCC(PhabricatorLiskDAO
$object) {
3369 $has_support = false;
3371 if ($object instanceof PhabricatorSubscribableInterface
) {
3372 $phid = $object->getPHID();
3373 $phids[] = PhabricatorSubscribersQuery
::loadSubscribersForPHID($phid);
3374 $has_support = true;
3377 if ($object instanceof PhabricatorProjectInterface
) {
3378 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3380 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3382 if ($project_phids) {
3383 $projects = id(new PhabricatorProjectQuery())
3384 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3385 ->withPHIDs($project_phids)
3386 ->needWatchers(true)
3389 $watcher_phids = array();
3390 foreach ($projects as $project) {
3391 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3392 $watcher_phids[$phid] = $phid;
3396 if ($watcher_phids) {
3397 // We need to do a visibility check for all the watchers, as
3398 // watching a project is not a guarantee that you can see objects
3399 // associated with it.
3400 $users = id(new PhabricatorPeopleQuery())
3401 ->setViewer($this->requireActor())
3402 ->withPHIDs($watcher_phids)
3405 $watchers = array();
3406 foreach ($users as $user) {
3407 $can_see = PhabricatorPolicyFilter
::hasCapability(
3410 PhabricatorPolicyCapability
::CAN_VIEW
);
3412 $watchers[] = $user->getPHID();
3415 $phids[] = $watchers;
3419 $has_support = true;
3422 if (!$has_support) {
3423 throw new Exception(
3424 pht('The object being edited does not implement any standard '.
3425 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3426 'CCs to be generated automatically. Override the "getMailCC()" '.
3427 'method and generate CCs explicitly.'));
3430 return array_mergev($phids);
3437 protected function buildMailBody(
3438 PhabricatorLiskDAO
$object,
3441 $body = id(new PhabricatorMetaMTAMailBody())
3442 ->setViewer($this->requireActor())
3443 ->setContextObject($object);
3445 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3446 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3448 $this->addHeadersAndCommentsToMailBody(
3454 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3459 protected function getObjectLinkButtonLabelForMail(
3460 PhabricatorLiskDAO
$object) {
3464 protected function getObjectLinkButtonURIForMail(
3465 PhabricatorLiskDAO
$object) {
3467 // Most objects define a "getURI()" method which does what we want, but
3468 // this isn't formally part of an interface at time of writing. Try to
3469 // call the method, expecting an exception if it does not exist.
3472 $uri = $object->getURI();
3473 return PhabricatorEnv
::getProductionURI($uri);
3474 } catch (Exception
$ex) {
3482 protected function addEmailPreferenceSectionToMailBody(
3483 PhabricatorMetaMTAMailBody
$body,
3484 PhabricatorLiskDAO
$object,
3487 $href = PhabricatorEnv
::getProductionURI(
3488 '/settings/panel/emailpreferences/');
3489 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3496 protected function addHeadersAndCommentsToMailBody(
3497 PhabricatorMetaMTAMailBody
$body,
3499 $object_label = null,
3500 $object_uri = null) {
3502 // First, remove transactions which shouldn't be rendered in mail.
3503 foreach ($xactions as $key => $xaction) {
3504 if ($xaction->shouldHideForMail($xactions)) {
3505 unset($xactions[$key]);
3510 $headers_html = array();
3511 $comments = array();
3514 $seen_comment = false;
3515 foreach ($xactions as $xaction) {
3517 // Most mail has zero or one comments. In these cases, we render the
3518 // "alice added a comment." transaction in the header, like a normal
3521 // Some mail, like Differential undraft mail or "!history" mail, may
3522 // have two or more comments. In these cases, we'll put the first
3523 // "alice added a comment." transaction in the header normally, but
3524 // move the other transactions down so they provide context above the
3527 $comment = $this->getBodyForTextMail($xaction);
3528 if ($comment !== null) {
3530 $comments[] = array(
3531 'xaction' => $xaction,
3532 'comment' => $comment,
3533 'initial' => !$seen_comment,
3536 $is_comment = false;
3539 if (!$is_comment ||
!$seen_comment) {
3540 $header = $this->getTitleForTextMail($xaction);
3541 if ($header !== null) {
3542 $headers[] = $header;
3545 $header_html = $this->getTitleForHTMLMail($xaction);
3546 if ($header_html !== null) {
3547 $headers_html[] = $header_html;
3551 if ($xaction->hasChangeDetailsForMail()) {
3552 $details[] = $xaction;
3556 $seen_comment = true;
3560 $headers_text = implode("\n", $headers);
3561 $body->addRawPlaintextSection($headers_text);
3563 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3565 $header_button = null;
3566 if ($object_label !== null && $object_uri !== null) {
3567 $button_style = array(
3568 'text-decoration: none;',
3569 'padding: 4px 8px;',
3570 'margin: 0 8px 8px;',
3573 'font-weight: bold;',
3574 'border-radius: 3px;',
3575 'background-color: #F7F7F9;',
3576 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3577 'display: inline-block;',
3578 'border: 1px solid rgba(71,87,120,.2);',
3581 $header_button = phutil_tag(
3584 'style' => implode(' ', $button_style),
3585 'href' => $object_uri,
3590 $xactions_style = array();
3592 $header_action = phutil_tag(
3597 $header_action = phutil_tag(
3600 'style' => implode(' ', $xactions_style),
3604 // Add an extra newline to prevent the "View Object" button from
3605 // running into the transaction text in Mail.app text snippet
3610 $headers_html = phutil_tag(
3613 phutil_tag('tr', array(), array($header_action, $header_button)));
3615 $body->addRawHTMLSection($headers_html);
3617 foreach ($comments as $spec) {
3618 $xaction = $spec['xaction'];
3619 $comment = $spec['comment'];
3620 $is_initial = $spec['initial'];
3622 // If this is not the first comment in the mail, add the header showing
3623 // who wrote the comment immediately above the comment.
3625 $header = $this->getTitleForTextMail($xaction);
3626 if ($header !== null) {
3627 $body->addRawPlaintextSection($header);
3630 $header_html = $this->getTitleForHTMLMail($xaction);
3631 if ($header_html !== null) {
3632 $body->addRawHTMLSection($header_html);
3636 $body->addRemarkupSection(null, $comment);
3639 foreach ($details as $xaction) {
3640 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
3641 if ($details !== null) {
3642 $label = $this->getMailDiffSectionHeader($xaction);
3643 $body->addHTMLSection($label, $details);
3649 private function getMailDiffSectionHeader($xaction) {
3650 $type = $xaction->getTransactionType();
3652 $xtype = $this->getModularTransactionType($type);
3654 return $xtype->getMailDiffSectionHeader();
3657 return pht('EDIT DETAILS');
3663 protected function addCustomFieldsToMailBody(
3664 PhabricatorMetaMTAMailBody
$body,
3665 PhabricatorLiskDAO
$object,
3668 if ($object instanceof PhabricatorCustomFieldInterface
) {
3669 $field_list = PhabricatorCustomField
::getObjectFields(
3671 PhabricatorCustomField
::ROLE_TRANSACTIONMAIL
);
3672 $field_list->setViewer($this->getActor());
3673 $field_list->readFieldsFromStorage($object);
3675 foreach ($field_list->getFields() as $field) {
3676 $field->updateTransactionMailBody(
3688 private function runHeraldMailRules(array $messages) {
3689 foreach ($messages as $message) {
3690 $engine = new HeraldEngine();
3691 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
3692 ->setObject($message);
3694 $rules = $engine->loadRulesForAdapter($adapter);
3695 $effects = $engine->applyRules($rules, $adapter);
3696 $engine->applyEffects($effects, $adapter, $rules);
3701 /* -( Publishing Feed Stories )-------------------------------------------- */
3707 protected function shouldPublishFeedStory(
3708 PhabricatorLiskDAO
$object,
3717 protected function getFeedStoryType() {
3718 return 'PhabricatorApplicationTransactionFeedStory';
3725 protected function getFeedRelatedPHIDs(
3726 PhabricatorLiskDAO
$object,
3731 $this->getActingAsPHID(),
3734 if ($object instanceof PhabricatorProjectInterface
) {
3735 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3737 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3738 foreach ($project_phids as $project_phid) {
3739 $phids[] = $project_phid;
3750 protected function getFeedNotifyPHIDs(
3751 PhabricatorLiskDAO
$object,
3754 // If some transactions are forcing notification delivery, add the forced
3755 // recipients to the notify list.
3756 $force_list = array();
3757 foreach ($xactions as $xaction) {
3758 $force_phids = $xaction->getForceNotifyPHIDs();
3760 if (!$force_phids) {
3764 foreach ($force_phids as $force_phid) {
3765 $force_list[] = $force_phid;
3769 $to_list = $this->getMailTo($object);
3770 $cc_list = $this->getMailCC($object);
3772 $full_list = array_merge($force_list, $to_list, $cc_list);
3773 $full_list = array_fuse($full_list);
3775 return array_keys($full_list);
3782 protected function getFeedStoryData(
3783 PhabricatorLiskDAO
$object,
3786 $xactions = msortv($xactions, 'newActionStrengthSortVector');
3789 'objectPHID' => $object->getPHID(),
3790 'transactionPHIDs' => mpull($xactions, 'getPHID'),
3798 protected function publishFeedStory(
3799 PhabricatorLiskDAO
$object,
3801 array $mailed_phids) {
3803 // Remove transactions which don't publish feed stories or notifications.
3804 // These never show up anywhere, so we don't need to do anything with them.
3805 foreach ($xactions as $key => $xaction) {
3806 if (!$xaction->shouldHideForFeed()) {
3810 if (!$xaction->shouldHideForNotifications()) {
3814 unset($xactions[$key]);
3821 $related_phids = $this->feedRelatedPHIDs
;
3822 $subscribed_phids = $this->feedNotifyPHIDs
;
3824 // Remove muted users from the subscription list so they don't get
3825 // notifications, either.
3826 $muted_phids = $this->mailMutedPHIDs
;
3827 if (!is_array($muted_phids)) {
3828 $muted_phids = array();
3830 $subscribed_phids = array_fuse($subscribed_phids);
3831 foreach ($muted_phids as $muted_phid) {
3832 unset($subscribed_phids[$muted_phid]);
3834 $subscribed_phids = array_values($subscribed_phids);
3836 $story_type = $this->getFeedStoryType();
3837 $story_data = $this->getFeedStoryData($object, $xactions);
3839 $unexpandable_phids = $this->mailUnexpandablePHIDs
;
3840 if (!is_array($unexpandable_phids)) {
3841 $unexpandable_phids = array();
3844 id(new PhabricatorFeedStoryPublisher())
3845 ->setStoryType($story_type)
3846 ->setStoryData($story_data)
3847 ->setStoryTime(time())
3848 ->setStoryAuthorPHID($this->getActingAsPHID())
3849 ->setRelatedPHIDs($related_phids)
3850 ->setPrimaryObjectPHID($object->getPHID())
3851 ->setSubscribedPHIDs($subscribed_phids)
3852 ->setUnexpandablePHIDs($unexpandable_phids)
3853 ->setMailRecipientPHIDs($mailed_phids)
3854 ->setMailTags($this->getMailTags($object, $xactions))
3859 /* -( Search Index )------------------------------------------------------- */
3865 protected function supportsSearch() {
3870 /* -( Herald Integration )-------------------------------------------------- */
3873 protected function shouldApplyHeraldRules(
3874 PhabricatorLiskDAO
$object,
3879 protected function buildHeraldAdapter(
3880 PhabricatorLiskDAO
$object,
3882 throw new Exception(pht('No herald adapter specified.'));
3885 private function setHeraldAdapter(HeraldAdapter
$adapter) {
3886 $this->heraldAdapter
= $adapter;
3890 protected function getHeraldAdapter() {
3891 return $this->heraldAdapter
;
3894 private function setHeraldTranscript(HeraldTranscript
$transcript) {
3895 $this->heraldTranscript
= $transcript;
3899 protected function getHeraldTranscript() {
3900 return $this->heraldTranscript
;
3903 private function applyHeraldRules(
3904 PhabricatorLiskDAO
$object,
3907 $adapter = $this->buildHeraldAdapter($object, $xactions)
3908 ->setContentSource($this->getContentSource())
3909 ->setIsNewObject($this->getIsNewObject())
3910 ->setActingAsPHID($this->getActingAsPHID())
3911 ->setAppliedTransactions($xactions);
3913 if ($this->getApplicationEmail()) {
3914 $adapter->setApplicationEmail($this->getApplicationEmail());
3917 // If this editor is operating in silent mode, tell Herald that we aren't
3918 // going to send any mail. This allows it to skip "the first time this
3919 // rule matches, send me an email" rules which would otherwise match even
3920 // though we aren't going to send any mail.
3921 if ($this->getIsSilent()) {
3922 $adapter->setForbiddenAction(
3923 HeraldMailableState
::STATECONST
,
3924 HeraldCoreStateReasons
::REASON_SILENT
);
3927 $xscript = HeraldEngine
::loadAndApplyRules($adapter);
3929 $this->setHeraldAdapter($adapter);
3930 $this->setHeraldTranscript($xscript);
3932 if ($adapter instanceof HarbormasterBuildableAdapterInterface
) {
3933 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
3935 HarbormasterBuildable
::applyBuildPlans(
3937 $adapter->getHarbormasterContainerPHID(),
3938 $adapter->getQueuedHarbormasterBuildRequests());
3940 // Whether we queued any builds or not, any automatic buildable for this
3941 // object is now done preparing builds and can transition into a
3942 // completed status.
3943 $buildables = id(new HarbormasterBuildableQuery())
3944 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3945 ->withManualBuildables(false)
3946 ->withBuildablePHIDs(array($buildable_phid))
3948 foreach ($buildables as $buildable) {
3949 // If this buildable has already moved beyond preparation, we don't
3950 // need to nudge it again.
3951 if (!$buildable->isPreparing()) {
3954 $buildable->sendMessage(
3956 HarbormasterMessageType
::BUILDABLE_BUILD
,
3961 $this->mustEncrypt
= $adapter->getMustEncryptReasons();
3963 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
3964 foreach ($this->subEditors
as $sub_editor) {
3965 $sub_editor->mustEncrypt
= $this->mustEncrypt
;
3968 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
3969 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
3971 $queue_xactions = $adapter->getQueuedTransactions();
3974 array_values($apply_xactions),
3975 array_values($queue_xactions));
3978 protected function didApplyHeraldRules(
3979 PhabricatorLiskDAO
$object,
3980 HeraldAdapter
$adapter,
3981 HeraldTranscript
$transcript) {
3986 /* -( Custom Fields )------------------------------------------------------ */
3992 private function getCustomFieldForTransaction(
3993 PhabricatorLiskDAO
$object,
3994 PhabricatorApplicationTransaction
$xaction) {
3996 $field_key = $xaction->getMetadataValue('customfield:key');
3998 throw new Exception(
4000 "Custom field transaction has no '%s'!",
4001 'customfield:key'));
4004 $field = PhabricatorCustomField
::getObjectField(
4006 PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
,
4010 throw new Exception(
4012 "Custom field transaction has invalid '%s'; field '%s' ".
4013 "is disabled or does not exist.",
4018 if (!$field->shouldAppearInApplicationTransactions()) {
4019 throw new Exception(
4021 "Custom field transaction '%s' does not implement ".
4022 "integration for %s.",
4024 'ApplicationTransactions'));
4027 $field->setViewer($this->getActor());
4033 /* -( Files )-------------------------------------------------------------- */
4037 * Extract the PHIDs of any files which these transactions attach.
4041 private function extractFilePHIDs(
4042 PhabricatorLiskDAO
$object,
4045 $changes = $this->getRemarkupChanges($xactions);
4046 $blocks = mpull($changes, 'getNewValue');
4050 $phids[] = PhabricatorMarkupEngine
::extractFilePHIDsFromEmbeddedFiles(
4055 foreach ($xactions as $xaction) {
4056 $type = $xaction->getTransactionType();
4058 $xtype = $this->getModularTransactionType($type);
4060 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4062 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4068 $phids = array_unique(array_filter(array_mergev($phids)));
4073 // Only let a user attach files they can actually see, since this would
4074 // otherwise let you access any file by attaching it to an object you have
4075 // view permission on.
4077 $files = id(new PhabricatorFileQuery())
4078 ->setViewer($this->getActor())
4082 return mpull($files, 'getPHID');
4088 protected function extractFilePHIDsFromCustomTransaction(
4089 PhabricatorLiskDAO
$object,
4090 PhabricatorApplicationTransaction
$xaction) {
4098 private function attachFiles(
4099 PhabricatorLiskDAO
$object,
4100 array $file_phids) {
4106 $editor = new PhabricatorEdgeEditor();
4108 $src = $object->getPHID();
4109 $type = PhabricatorObjectHasFileEdgeType
::EDGECONST
;
4110 foreach ($file_phids as $dst) {
4111 $editor->addEdge($src, $type, $dst);
4117 private function applyInverseEdgeTransactions(
4118 PhabricatorLiskDAO
$object,
4119 PhabricatorApplicationTransaction
$xaction,
4122 $old = $xaction->getOldValue();
4123 $new = $xaction->getNewValue();
4125 $add = array_keys(array_diff_key($new, $old));
4126 $rem = array_keys(array_diff_key($old, $new));
4128 $add = array_fuse($add);
4129 $rem = array_fuse($rem);
4132 $nodes = id(new PhabricatorObjectQuery())
4133 ->setViewer($this->requireActor())
4137 $object_phid = $object->getPHID();
4139 foreach ($nodes as $node) {
4140 if (!($node instanceof PhabricatorApplicationTransactionInterface
)) {
4144 if ($node instanceof PhabricatorUser
) {
4145 // TODO: At least for now, don't record inverse edge transactions
4146 // for users (for example, "alincoln joined project X"): Feed fills
4147 // this role instead.
4151 $node_phid = $node->getPHID();
4152 $editor = $node->getApplicationTransactionEditor();
4153 $template = $node->getApplicationTransactionTemplate();
4155 // See T13082. We have to build these transactions with synthetic values
4156 // because we've already applied the actual edit to the edge database
4157 // table. If we try to apply this transaction naturally, it will no-op
4158 // itself because it doesn't have any effect.
4160 $edge_query = id(new PhabricatorEdgeQuery())
4161 ->withSourcePHIDs(array($node_phid))
4162 ->withEdgeTypes(array($inverse_type));
4164 $edge_query->execute();
4166 $edge_phids = $edge_query->getDestinationPHIDs();
4167 $edge_phids = array_fuse($edge_phids);
4169 $new_phids = $edge_phids;
4170 $old_phids = $edge_phids;
4172 if (isset($add[$node_phid])) {
4173 unset($old_phids[$object_phid]);
4175 $old_phids[$object_phid] = $object_phid;
4179 ->setTransactionType($xaction->getTransactionType())
4180 ->setMetadataValue('edge:type', $inverse_type)
4181 ->setOldValue($old_phids)
4182 ->setNewValue($new_phids);
4184 $editor = $this->newSubEditor($editor)
4185 ->setContinueOnNoEffect(true)
4186 ->setContinueOnMissingFields(true)
4187 ->setIsInverseEdgeEditor(true);
4189 $editor->applyTransactions($node, array($template));
4194 /* -( Workers )------------------------------------------------------------ */
4198 * Load any object state which is required to publish transactions.
4200 * This hook is invoked in the main process before we compute data related
4201 * to publishing transactions (like email "To" and "CC" lists), and again in
4202 * the worker before publishing occurs.
4204 * @return object Publishable object.
4207 protected function willPublish(PhabricatorLiskDAO
$object, array $xactions) {
4213 * Convert the editor state to a serializable dictionary which can be passed
4216 * This data will be loaded with @{method:loadWorkerState} in the worker.
4218 * @return dict<string, wild> Serializable editor state.
4221 private function getWorkerState() {
4223 foreach ($this->getAutomaticStateProperties() as $property) {
4224 $state[$property] = $this->$property;
4227 $custom_state = $this->getCustomWorkerState();
4228 $custom_encoding = $this->getCustomWorkerStateEncoding();
4231 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4232 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4233 'custom.encoding' => $custom_encoding,
4241 * Hook; return custom properties which need to be passed to workers.
4243 * @return dict<string, wild> Custom properties.
4246 protected function getCustomWorkerState() {
4252 * Hook; return storage encoding for custom properties which need to be
4253 * passed to workers.
4255 * This primarily allows binary data to be passed to workers and survive
4258 * @return dict<string, string> Property encodings.
4261 protected function getCustomWorkerStateEncoding() {
4267 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4269 * This method is used to load state when running worker operations.
4271 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4275 final public function loadWorkerState(array $state) {
4276 foreach ($this->getAutomaticStateProperties() as $property) {
4277 $this->$property = idx($state, $property);
4280 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4281 $this->setExcludeMailRecipientPHIDs($exclude);
4283 $custom_state = idx($state, 'custom', array());
4284 $custom_encodings = idx($state, 'custom.encoding', array());
4285 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4287 $this->loadCustomWorkerState($custom);
4294 * Hook; set custom properties on the editor from data emitted by
4295 * @{method:getCustomWorkerState}.
4297 * @param dict<string, wild> Custom state,
4298 * from @{method:getCustomWorkerState}.
4302 protected function loadCustomWorkerState(array $state) {
4308 * Get a list of object properties which should be automatically sent to
4309 * workers in the state data.
4311 * These properties will be automatically stored and loaded by the editor in
4314 * @return list<string> List of properties.
4317 private function getAutomaticStateProperties() {
4322 'heraldForcedEmailPHIDs',
4328 'feedShouldPublish',
4332 'mailUnexpandablePHIDs',
4341 * Apply encodings prior to storage.
4343 * See @{method:getCustomWorkerStateEncoding}.
4345 * @param map<string, wild> Map of values to encode.
4346 * @param map<string, string> Map of encodings to apply.
4347 * @return map<string, wild> Map of encoded values.
4350 private function encodeStateForStorage(
4354 foreach ($state as $key => $value) {
4355 $encoding = idx($encodings, $key);
4356 switch ($encoding) {
4357 case self
::STORAGE_ENCODING_BINARY
:
4358 // The mechanics of this encoding (serialize + base64) are a little
4359 // awkward, but it allows us encode arrays and still be JSON-safe
4360 // with binary data.
4362 $value = @serialize
($value);
4363 if ($value === false) {
4364 throw new Exception(
4366 'Failed to serialize() value for key "%s".',
4370 $value = base64_encode($value);
4371 if ($value === false) {
4372 throw new Exception(
4374 'Failed to base64 encode value for key "%s".',
4379 $state[$key] = $value;
4387 * Undo storage encoding applied when storing state.
4389 * See @{method:getCustomWorkerStateEncoding}.
4391 * @param map<string, wild> Map of encoded values.
4392 * @param map<string, string> Map of encodings.
4393 * @return map<string, wild> Map of decoded values.
4396 private function decodeStateFromStorage(
4400 foreach ($state as $key => $value) {
4401 $encoding = idx($encodings, $key);
4402 switch ($encoding) {
4403 case self
::STORAGE_ENCODING_BINARY
:
4404 $value = base64_decode($value);
4405 if ($value === false) {
4406 throw new Exception(
4408 'Failed to base64_decode() value for key "%s".',
4412 $value = unserialize($value);
4415 $state[$key] = $value;
4423 * Remove conflicts from a list of projects.
4425 * Objects aren't allowed to be tagged with multiple milestones in the same
4426 * group, nor projects such that one tag is the ancestor of any other tag.
4427 * If the list of PHIDs include mutually exclusive projects, remove the
4428 * conflicting projects.
4430 * @param list<phid> List of project PHIDs.
4431 * @return list<phid> List with conflicts removed.
4433 private function applyProjectConflictRules(array $phids) {
4438 // Overall, the last project in the list wins in cases of conflict (so when
4439 // you add something, the thing you just added sticks and removes older
4442 // Beyond that, there are two basic cases:
4444 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4445 // If multiple projects are milestones of the same parent, we only keep the
4448 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4449 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4450 // remove "A > B" and keep "A".
4452 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4453 // if one project is an ancestor of another. It's OK to have something
4454 // tagged with multiple projects which share a common ancestor, so long as
4455 // they are not mutual ancestors.
4457 $viewer = PhabricatorUser
::getOmnipotentUser();
4459 $projects = id(new PhabricatorProjectQuery())
4460 ->setViewer($viewer)
4461 ->withPHIDs(array_keys($phids))
4463 $projects = mpull($projects, null, 'getPHID');
4465 // We're going to build a map from each project with milestones to the last
4466 // milestone in the list. This last milestone is the milestone we'll keep.
4467 $milestone_map = array();
4469 // We're going to build a set of the projects which have no descendants
4470 // later in the list. This allows us to apply both ancestor rules.
4471 $ancestor_map = array();
4473 foreach ($phids as $phid => $ignored) {
4474 $project = idx($projects, $phid);
4479 // This is the last milestone we've seen, so set it as the selection for
4480 // the project's parent. This might be setting a new value or overwriting
4481 // an earlier value.
4482 if ($project->isMilestone()) {
4483 $parent_phid = $project->getParentProjectPHID();
4484 $milestone_map[$parent_phid] = $phid;
4487 // Since this is the last item in the list we've examined so far, add it
4488 // to the set of projects with no later descendants.
4489 $ancestor_map[$phid] = $phid;
4491 // Remove any ancestors from the set, since this is a later descendant.
4492 foreach ($project->getAncestorProjects() as $ancestor) {
4493 $ancestor_phid = $ancestor->getPHID();
4494 unset($ancestor_map[$ancestor_phid]);
4498 // Now that we've built the maps, we can throw away all the projects which
4500 foreach ($phids as $phid => $ignored) {
4501 $project = idx($projects, $phid);
4504 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4505 // but leaving it untouched is less likely to cause collateral damage.
4509 // If this was a milestone, check if it was the last milestone from its
4510 // group in the list. If not, remove it from the list.
4511 if ($project->isMilestone()) {
4512 $parent_phid = $project->getParentProjectPHID();
4513 if ($milestone_map[$parent_phid] !== $phid) {
4514 unset($phids[$phid]);
4519 // If a later project in the list is a subproject of this one, it will
4520 // have removed ancestors from the map. If this project does not point
4521 // at itself in the ancestor map, it should be discarded in favor of a
4522 // subproject that comes later.
4523 if (idx($ancestor_map, $phid) !== $phid) {
4524 unset($phids[$phid]);
4528 // If a later project in the list is an ancestor of this one, it will
4529 // have added itself to the map. If any ancestor of this project points
4530 // at itself in the map, this project should be discarded in favor of
4531 // that later ancestor.
4532 foreach ($project->getAncestorProjects() as $ancestor) {
4533 $ancestor_phid = $ancestor->getPHID();
4534 if (isset($ancestor_map[$ancestor_phid])) {
4535 unset($phids[$phid]);
4545 * When the view policy for an object is changed, scramble the secret keys
4546 * for attached files to invalidate existing URIs.
4548 private function scrambleFileSecrets($object) {
4549 // If this is a newly created object, we don't need to scramble anything
4550 // since it couldn't have been previously published.
4551 if ($this->getIsNewObject()) {
4555 // If the object is a file itself, scramble it.
4556 if ($object instanceof PhabricatorFile
) {
4557 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4558 $object->scrambleSecret();
4563 $phid = $object->getPHID();
4565 $attached_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
4567 PhabricatorObjectHasFileEdgeType
::EDGECONST
);
4568 if (!$attached_phids) {
4572 $omnipotent_viewer = PhabricatorUser
::getOmnipotentUser();
4574 $files = id(new PhabricatorFileQuery())
4575 ->setViewer($omnipotent_viewer)
4576 ->withPHIDs($attached_phids)
4578 foreach ($files as $file) {
4579 $view_policy = $file->getViewPolicy();
4580 if ($this->shouldScramblePolicy($view_policy)) {
4581 $file->scrambleSecret();
4589 * Check if a policy is strong enough to justify scrambling. Objects which
4590 * are set to very open policies don't need to scramble their files, and
4591 * files with very open policies don't need to be scrambled when associated
4594 private function shouldScramblePolicy($policy) {
4596 case PhabricatorPolicies
::POLICY_PUBLIC
:
4597 case PhabricatorPolicies
::POLICY_USER
:
4604 private function updateWorkboardColumns($object, $const, $old, $new) {
4605 // If an object is removed from a project, remove it from any proxy
4606 // columns for that project. This allows a task which is moved up from a
4607 // milestone to the parent to move back into the "Backlog" column on the
4608 // parent workboard.
4610 if ($const != PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
) {
4614 // TODO: This should likely be some future WorkboardInterface.
4615 $appears_on_workboards = ($object instanceof ManiphestTask
);
4616 if (!$appears_on_workboards) {
4620 $removed_phids = array_keys(array_diff_key($old, $new));
4621 if (!$removed_phids) {
4625 // Find any proxy columns for the removed projects.
4626 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4627 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4628 ->withProxyPHIDs($removed_phids)
4630 if (!$proxy_columns) {
4634 $proxy_phids = mpull($proxy_columns, 'getPHID');
4636 $position_table = new PhabricatorProjectColumnPosition();
4637 $conn_w = $position_table->establishConnection('w');
4641 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4642 $position_table->getTableName(),
4647 private function getModularTransactionTypes() {
4648 if ($this->modularTypes
=== null) {
4649 $template = $this->object->getApplicationTransactionTemplate();
4650 if ($template instanceof PhabricatorModularTransaction
) {
4651 $xtypes = $template->newModularTransactionTypes();
4652 foreach ($xtypes as $key => $xtype) {
4653 $xtype = clone $xtype;
4654 $xtype->setEditor($this);
4655 $xtypes[$key] = $xtype;
4661 $this->modularTypes
= $xtypes;
4664 return $this->modularTypes
;
4667 private function getModularTransactionType($type) {
4668 $types = $this->getModularTransactionTypes();
4669 return idx($types, $type);
4672 public function getCreateObjectTitle($author, $object) {
4673 return pht('%s created this object.', $author);
4676 public function getCreateObjectTitleForFeed($author, $object) {
4677 return pht('%s created an object: %s.', $author, $object);
4680 /* -( Queue )-------------------------------------------------------------- */
4682 protected function queueTransaction(
4683 PhabricatorApplicationTransaction
$xaction) {
4684 $this->transactionQueue
[] = $xaction;
4688 private function flushTransactionQueue($object) {
4689 if (!$this->transactionQueue
) {
4693 $xactions = $this->transactionQueue
;
4694 $this->transactionQueue
= array();
4696 $editor = $this->newEditorCopy();
4698 return $editor->applyTransactions($object, $xactions);
4701 final protected function newSubEditor(
4702 PhabricatorApplicationTransactionEditor
$template = null) {
4703 $editor = $this->newEditorCopy($template);
4705 $editor->parentEditor
= $this;
4706 $this->subEditors
[] = $editor;
4711 private function newEditorCopy(
4712 PhabricatorApplicationTransactionEditor
$template = null) {
4713 if ($template === null) {
4714 $template = newv(get_class($this), array());
4717 $editor = id(clone $template)
4718 ->setActor($this->getActor())
4719 ->setContentSource($this->getContentSource())
4720 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
4721 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
4722 ->setParentMessageID($this->getParentMessageID())
4723 ->setIsSilent($this->getIsSilent());
4725 if ($this->actingAsPHID
!== null) {
4726 $editor->setActingAsPHID($this->actingAsPHID
);
4729 $editor->mustEncrypt
= $this->mustEncrypt
;
4730 $editor->transactionGroupID
= $this->getTransactionGroupID();
4736 /* -( Stamps )------------------------------------------------------------- */
4739 public function newMailStampTemplates($object) {
4740 $actor = $this->getActor();
4742 $templates = array();
4744 $extensions = $this->newMailExtensions($object);
4745 foreach ($extensions as $extension) {
4746 $stamps = $extension->newMailStampTemplates($object);
4747 foreach ($stamps as $stamp) {
4748 $key = $stamp->getKey();
4749 if (isset($templates[$key])) {
4750 throw new Exception(
4752 'Mail extension ("%s") defines a stamp template with the '.
4753 'same key ("%s") as another template. Each stamp template '.
4754 'must have a unique key.',
4755 get_class($extension),
4759 $stamp->setViewer($actor);
4761 $templates[$key] = $stamp;
4768 final public function getMailStamp($key) {
4769 if (!isset($this->stampTemplates
)) {
4770 throw new PhutilInvalidStateException('newMailStampTemplates');
4773 if (!isset($this->stampTemplates
[$key])) {
4774 throw new Exception(
4776 'Editor ("%s") has no mail stamp template with provided key ("%s").',
4781 return $this->stampTemplates
[$key];
4784 private function newMailStamps($object, array $xactions) {
4785 $actor = $this->getActor();
4787 $this->stampTemplates
= $this->newMailStampTemplates($object);
4789 $extensions = $this->newMailExtensions($object);
4791 foreach ($extensions as $extension) {
4792 $extension->newMailStamps($object, $xactions);
4795 return $this->stampTemplates
;
4798 private function newMailExtensions($object) {
4799 $actor = $this->getActor();
4801 $all_extensions = PhabricatorMailEngineExtension
::getAllExtensions();
4803 $extensions = array();
4804 foreach ($all_extensions as $key => $template) {
4805 $extension = id(clone $template)
4809 if ($extension->supportsObject($object)) {
4810 $extensions[$key] = $extension;
4817 protected function newAuxiliaryMail($object, array $xactions) {
4821 private function generateMailStamps($object, $data) {
4822 if (!$data ||
!is_array($data)) {
4826 $templates = $this->newMailStampTemplates($object);
4827 foreach ($data as $spec) {
4828 if (!is_array($spec)) {
4832 $key = idx($spec, 'key');
4833 if (!isset($templates[$key])) {
4837 $type = idx($spec, 'type');
4838 if ($templates[$key]->getStampType() !== $type) {
4842 $value = idx($spec, 'value');
4843 $templates[$key]->setValueFromDictionary($value);
4847 foreach ($templates as $template) {
4848 $value = $template->getValueForRendering();
4850 $rendered = $template->renderStamps($value);
4851 if ($rendered === null) {
4855 $rendered = (array)$rendered;
4856 foreach ($rendered as $stamp) {
4857 $results[] = $stamp;
4861 natcasesort($results);
4866 public function getRemovedRecipientPHIDs() {
4867 return $this->mailRemovedPHIDs
;
4870 private function buildOldRecipientLists($object, $xactions) {
4871 // See T4776. Before we start making any changes, build a list of the old
4872 // recipients. If a change removes a user from the recipient list for an
4873 // object we still want to notify the user about that change. This allows
4874 // them to respond if they didn't want to be removed.
4876 if (!$this->shouldSendMail($object, $xactions)) {
4880 $this->oldTo
= $this->getMailTo($object);
4881 $this->oldCC
= $this->getMailCC($object);
4886 private function applyOldRecipientLists() {
4887 $actor_phid = $this->getActingAsPHID();
4889 // If you took yourself off the recipient list (for example, by
4890 // unsubscribing or resigning) assume that you know what you did and
4891 // don't need to be notified.
4893 // If you just moved from "To" to "Cc" (or vice versa), you're still a
4894 // recipient so we don't need to add you back in.
4896 $map = array_fuse($this->mailToPHIDs
) +
array_fuse($this->mailCCPHIDs
);
4898 foreach ($this->oldTo
as $phid) {
4899 if ($phid === $actor_phid) {
4903 if (isset($map[$phid])) {
4907 $this->mailToPHIDs
[] = $phid;
4908 $this->mailRemovedPHIDs
[] = $phid;
4911 foreach ($this->oldCC
as $phid) {
4912 if ($phid === $actor_phid) {
4916 if (isset($map[$phid])) {
4920 $this->mailCCPHIDs
[] = $phid;
4921 $this->mailRemovedPHIDs
[] = $phid;
4927 private function queueWebhooks($object, array $xactions) {
4928 $hook_viewer = PhabricatorUser
::getOmnipotentUser();
4930 $webhook_map = $this->webhookMap
;
4931 if (!is_array($webhook_map)) {
4932 $webhook_map = array();
4935 // Add any "Firehose" hooks to the list of hooks we're going to call.
4936 $firehose_hooks = id(new HeraldWebhookQuery())
4937 ->setViewer($hook_viewer)
4940 HeraldWebhook
::HOOKSTATUS_FIREHOSE
,
4943 foreach ($firehose_hooks as $firehose_hook) {
4944 // This is "the hook itself is the reason this hook is being called",
4945 // since we're including it because it's configured as a firehose
4947 $hook_phid = $firehose_hook->getPHID();
4948 $webhook_map[$hook_phid][] = $hook_phid;
4951 if (!$webhook_map) {
4955 // NOTE: We're going to queue calls to disabled webhooks, they'll just
4956 // immediately fail in the worker queue. This makes the behavior more
4959 $call_hooks = id(new HeraldWebhookQuery())
4960 ->setViewer($hook_viewer)
4961 ->withPHIDs(array_keys($webhook_map))
4964 foreach ($call_hooks as $call_hook) {
4965 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
4967 $request = HeraldWebhookRequest
::initializeNewWebhookRequest($call_hook)
4968 ->setObjectPHID($object->getPHID())
4969 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
4970 ->setTriggerPHIDs($trigger_phids)
4971 ->setRetryMode(HeraldWebhookRequest
::RETRY_FOREVER
)
4972 ->setIsSilentAction((bool)$this->getIsSilent())
4973 ->setIsSecureAction((bool)$this->getMustEncrypt())
4976 $request->queueCall();
4980 private function hasWarnings($object, $xaction) {
4981 // TODO: For the moment, this is a very un-modular hack to support
4982 // a small number of warnings related to draft revisions. See PHI433.
4984 if (!($object instanceof DifferentialRevision
)) {
4988 $type = $xaction->getTransactionType();
4990 // TODO: This doesn't warn for inlines in Audit, even though they have
4991 // the same overall workflow.
4992 if ($type === DifferentialTransaction
::TYPE_INLINE
) {
4993 return (bool)$xaction->getComment()->getAttribute('editing', false);
4996 if (!$object->isDraft()) {
5000 if ($type != PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5004 // We're only going to raise a warning if the transaction adds subscribers
5005 // other than the acting user. (This implementation is clumsy because the
5006 // code runs before a lot of normalization occurs.)
5008 $old = $this->getTransactionOldValue($object, $xaction);
5009 $new = $this->getPHIDTransactionNewValue($xaction, $old);
5010 $old = array_fuse($old);
5011 $new = array_fuse($new);
5012 $add = array_diff_key($new, $old);
5014 unset($add[$this->getActingAsPHID()]);
5023 private function buildHistoryMail(PhabricatorLiskDAO
$object) {
5024 $viewer = $this->requireActor();
5025 $recipient_phid = $this->getActingAsPHID();
5027 // Load every transaction so we can build a mail message with a complete
5028 // history for the object.
5029 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject($object);
5031 ->setViewer($viewer)
5032 ->withObjectPHIDs(array($object->getPHID()))
5034 $xactions = array_reverse($xactions);
5036 $mail_messages = $this->buildMailWithRecipients(
5039 array($recipient_phid),
5042 $mail = head($mail_messages);
5044 // Since the user explicitly requested "!history", force delivery of this
5045 // message regardless of their other mail settings.
5046 $mail->setForceDelivery(true);
5051 public function newAutomaticInlineTransactions(
5052 PhabricatorLiskDAO
$object,
5054 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5056 $actor = $this->getActor();
5058 $inlines = id(clone $query_template)
5060 ->withObjectPHIDs(array($object->getPHID()))
5061 ->withPublishableComments(true)
5062 ->needAppliedDrafts(true)
5063 ->needReplyToComments(true)
5065 $inlines = msort($inlines, 'getID');
5067 $xactions = array();
5069 foreach ($inlines as $key => $inline) {
5070 $xactions[] = $object->getApplicationTransactionTemplate()
5071 ->setTransactionType($transaction_type)
5072 ->attachComment($inline);
5075 $state_xaction = $this->newInlineStateTransaction(
5079 if ($state_xaction) {
5080 $xactions[] = $state_xaction;
5086 protected function newInlineStateTransaction(
5087 PhabricatorLiskDAO
$object,
5088 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5090 $actor_phid = $this->getActingAsPHID();
5091 $author_phid = $object->getAuthorPHID();
5092 $actor_is_author = ($actor_phid == $author_phid);
5094 $state_map = PhabricatorTransactions
::getInlineStateMap();
5096 $inline_query = id(clone $query_template)
5097 ->setViewer($this->getActor())
5098 ->withObjectPHIDs(array($object->getPHID()))
5099 ->withFixedStates(array_keys($state_map))
5100 ->withPublishableComments(true);
5102 if ($actor_is_author) {
5103 $inline_query->withPublishedComments(true);
5106 $inlines = $inline_query->execute();
5112 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5113 $new_value = array();
5114 foreach ($old_value as $key => $state) {
5115 $new_value[$key] = $state_map[$state];
5118 // See PHI995. Copy some information about the inlines into the transaction
5119 // so we can tailor rendering behavior. In particular, we don't want to
5120 // render transactions about users marking their own inlines as "Done".
5122 $inline_details = array();
5123 foreach ($inlines as $inline) {
5124 $inline_details[$inline->getPHID()] = array(
5125 'authorPHID' => $inline->getAuthorPHID(),
5129 return $object->getApplicationTransactionTemplate()
5130 ->setTransactionType(PhabricatorTransactions
::TYPE_INLINESTATE
)
5131 ->setIgnoreOnNoEffect(true)
5132 ->setMetadataValue('inline.details', $inline_details)
5133 ->setOldValue($old_value)
5134 ->setNewValue($new_value);
5137 private function requireMFA(PhabricatorLiskDAO
$object, array $xactions) {
5138 $actor = $this->getActor();
5140 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5141 if ($actor->isOmnipotent()) {
5145 $editor_class = get_class($this);
5147 $object_phid = $object->getPHID();
5149 $workflow_key = sprintf(
5150 'editor(%s).phid(%s)',
5154 $workflow_key = sprintf(
5159 $request = $this->getRequest();
5160 if ($request === null) {
5161 $source_type = $this->getContentSource()->getSourceTypeConstant();
5162 $conduit_type = PhabricatorConduitContentSource
::SOURCECONST
;
5163 $is_conduit = ($source_type === $conduit_type);
5165 throw new Exception(
5167 'This transaction group requires MFA to apply, but you can not '.
5168 'provide an MFA response via Conduit. Edit this object via the '.
5171 throw new Exception(
5173 'This transaction group requires MFA to apply, but the Editor was '.
5174 'not configured with a Request. This workflow can not perform an '.
5179 $cancel_uri = $this->getCancelURI();
5180 if ($cancel_uri === null) {
5181 throw new Exception(
5183 'This transaction group requires MFA to apply, but the Editor was '.
5184 'not configured with a Cancel URI. This workflow can not perform '.
5188 $token = id(new PhabricatorAuthSessionEngine())
5189 ->setWorkflowKey($workflow_key)
5190 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5192 if (!$token->getIsUnchallengedToken()) {
5193 foreach ($xactions as $xaction) {
5194 $xaction->setIsMFATransaction(true);
5199 private function newMFATransactions(
5200 PhabricatorLiskDAO
$object,
5203 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface
);
5205 $engine = PhabricatorEditEngineMFAEngine
::newEngineForObject($object)
5206 ->setViewer($this->getActor());
5207 $require_mfa = $engine->shouldRequireMFA();
5208 $try_mfa = $engine->shouldTryMFA();
5210 $require_mfa = false;
5214 // If the user is mentioning an MFA object on another object or creating
5215 // a relationship like "parent" or "child" to this object, we always
5216 // allow the edit to move forward without requiring MFA.
5217 if ($this->getIsInverseEdgeEditor()) {
5221 if (!$require_mfa) {
5222 // If the object hasn't already opted into MFA, see if any of the
5223 // transactions want it.
5225 foreach ($xactions as $xaction) {
5226 $type = $xaction->getTransactionType();
5228 $xtype = $this->getModularTransactionType($type);
5230 $xtype = clone $xtype;
5231 $xtype->setStorage($xaction);
5232 if ($xtype->shouldTryMFA($object, $xaction)) {
5241 $this->setShouldRequireMFA(true);
5247 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
5250 foreach ($xactions as $xaction) {
5251 if ($xaction->getTransactionType() === $type_mfa) {
5261 $template = $object->getApplicationTransactionTemplate();
5263 $mfa_xaction = id(clone $template)
5264 ->setTransactionType($type_mfa)
5265 ->setNewValue(true);
5267 array_unshift($xactions, $mfa_xaction);
5272 private function getTitleForTextMail(
5273 PhabricatorApplicationTransaction
$xaction) {
5274 $type = $xaction->getTransactionType();
5276 $xtype = $this->getModularTransactionType($type);
5278 $xtype = clone $xtype;
5279 $xtype->setStorage($xaction);
5280 $comment = $xtype->getTitleForTextMail();
5281 if ($comment !== false) {
5286 return $xaction->getTitleForTextMail();
5289 private function getTitleForHTMLMail(
5290 PhabricatorApplicationTransaction
$xaction) {
5291 $type = $xaction->getTransactionType();
5293 $xtype = $this->getModularTransactionType($type);
5295 $xtype = clone $xtype;
5296 $xtype->setStorage($xaction);
5297 $comment = $xtype->getTitleForHTMLMail();
5298 if ($comment !== false) {
5303 return $xaction->getTitleForHTMLMail();
5307 private function getBodyForTextMail(
5308 PhabricatorApplicationTransaction
$xaction) {
5309 $type = $xaction->getTransactionType();
5311 $xtype = $this->getModularTransactionType($type);
5313 $xtype = clone $xtype;
5314 $xtype->setStorage($xaction);
5315 $comment = $xtype->getBodyForTextMail();
5316 if ($comment !== false) {
5321 return $xaction->getBodyForMail();
5324 private function isLockOverrideTransaction(
5325 PhabricatorApplicationTransaction
$xaction) {
5327 // See PHI1209. When an object is locked, certain types of transactions
5328 // can still be applied without requiring a policy check, like subscribing
5329 // or unsubscribing. We don't want these transactions to show the "Lock
5330 // Override" icon in the transaction timeline.
5332 // We could test if a transaction did no direct policy checks, but it may
5333 // have done additional policy checks during validation, so this is not a
5334 // reliable test (and could cause false negatives, where edits which did
5335 // override a lock are not marked properly).
5337 // For now, do this in a narrow way and just check against a hard-coded
5338 // list of non-override transaction situations. Some day, this should
5339 // likely be modularized.
5342 // Inverse edge edits don't interact with locks.
5343 if ($this->getIsInverseEdgeEditor()) {
5347 // For now, all edits other than subscribes always override locks.
5348 $type = $xaction->getTransactionType();
5349 if ($type !== PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5353 // Subscribes override locks if they affect any users other than the
5356 $acting_phid = $this->getActingAsPHID();
5358 $old = array_fuse($xaction->getOldValue());
5359 $new = array_fuse($xaction->getNewValue());
5360 $add = array_diff_key($new, $old);
5361 $rem = array_diff_key($old, $new);
5364 foreach ($all as $phid) {
5365 if ($phid !== $acting_phid) {
5374 /* -( Extensions )--------------------------------------------------------- */
5377 private function validateTransactionsWithExtensions(
5378 PhabricatorLiskDAO
$object,
5382 $extensions = $this->getEditorExtensions();
5383 foreach ($extensions as $extension) {
5384 $extension_errors = $extension
5385 ->setObject($object)
5386 ->validateTransactions($object, $xactions);
5388 assert_instances_of(
5390 'PhabricatorApplicationTransactionValidationError');
5392 $errors[] = $extension_errors;
5395 return array_mergev($errors);
5398 private function getEditorExtensions() {
5399 if ($this->extensions
=== null) {
5400 $this->extensions
= $this->newEditorExtensions();
5402 return $this->extensions
;
5405 private function newEditorExtensions() {
5406 $extensions = PhabricatorEditorExtension
::getAllExtensions();
5408 $actor = $this->getActor();
5409 $object = $this->object;
5410 foreach ($extensions as $key => $extension) {
5412 $extension = id(clone $extension)
5415 ->setObject($object);
5417 if (!$extension->supportsObject($this, $object)) {
5418 unset($extensions[$key]);
5422 $extensions[$key] = $extension;