Give Phame blogs mutable interact policies
[phabricator.git] / src / applications / transactions / editor / PhabricatorApplicationTransactionEditor.php
blob8d8dfeea5f017de905f5e9a652c81475d1d4375e
1 <?php
3 /**
5 * Publishing and Managing State
6 * ======
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
11 * users.
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
20 * publishing.
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;
44 private $object;
45 private $xactions;
47 private $isNewObject;
48 private $mentionedPHIDs;
49 private $continueOnNoEffect;
50 private $continueOnMissingFields;
51 private $raiseWarnings;
52 private $parentMessageID;
53 private $heraldAdapter;
54 private $heraldTranscript;
55 private $subscribers;
56 private $unmentionablePHIDMap = array();
57 private $transactionGroupID;
58 private $applicationEmail;
60 private $isPreview;
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;
75 private $silent;
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;
90 private $request;
91 private $cancelURI;
92 private $extensions;
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
113 * Revisions".
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;
122 return $this;
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.
144 * @return this
146 public function setContinueOnNoEffect($continue) {
147 $this->continueOnNoEffect = $continue;
148 return $this;
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.
173 * @return this
175 public function setContinueOnMissingFields($continue_on_missing_fields) {
176 $this->continueOnMissingFields = $continue_on_missing_fields;
177 return $this;
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;
191 return $this;
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;
207 return $this;
210 public function getIsPreview() {
211 return $this->isPreview;
214 public function setIsSilent($silent) {
215 $this->silent = $silent;
216 return $this;
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)) {
236 unset($list[$key]);
237 continue;
240 $list[$key] = 'H'.$item;
243 return $list;
246 public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
247 $this->isInverseEdgeEditor = $is_inverse_edge_editor;
248 return $this;
251 public function getIsInverseEdgeEditor() {
252 return $this->isInverseEdgeEditor;
255 public function setIsHeraldEditor($is_herald_editor) {
256 $this->isHeraldEditor = $is_herald_editor;
257 return $this;
260 public function getIsHeraldEditor() {
261 return $this->isHeraldEditor;
264 public function addUnmentionablePHIDs(array $phids) {
265 foreach ($phids as $phid) {
266 $this->unmentionablePHIDMap[$phid] = true;
268 return $this;
271 private function getUnmentionablePHIDMap() {
272 return $this->unmentionablePHIDMap;
275 protected function shouldEnableMentions(
276 PhabricatorLiskDAO $object,
277 array $xactions) {
278 return true;
281 public function setApplicationEmail(
282 PhabricatorMetaMTAApplicationEmail $email) {
283 $this->applicationEmail = $email;
284 return $this;
287 public function getApplicationEmail() {
288 return $this->applicationEmail;
291 public function setRaiseWarnings($raise_warnings) {
292 $this->raiseWarnings = $raise_warnings;
293 return $this;
296 public function getRaiseWarnings() {
297 return $this->raiseWarnings;
300 public function setShouldRequireMFA($should_require_mfa) {
301 if ($this->hasRequiredMFA) {
302 throw new Exception(
303 pht(
304 'Call to setShouldRequireMFA() is too late: this Editor has already '.
305 'checked for MFA requirements.'));
308 $this->shouldRequireMFA = $should_require_mfa;
309 return $this;
312 public function getShouldRequireMFA() {
313 return $this->shouldRequireMFA;
316 public function getTransactionTypesForObject($object) {
317 $old = $this->object;
318 try {
319 $this->object = $object;
320 $result = $this->getTransactionTypes();
321 $this->object = $old;
322 } catch (Exception $ex) {
323 $this->object = $old;
324 throw $ex;
326 return $result;
329 public function getTransactionTypes() {
330 $types = array();
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();
370 if ($template) {
371 $comment = $template->getApplicationTransactionCommentObject();
372 if ($comment) {
373 $types[] = PhabricatorTransactions::TYPE_COMMENT;
377 return $types;
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);
400 if ($xtype) {
401 $xtype = clone $xtype;
402 $xtype->setStorage($xaction);
403 return $xtype->generateOldValue($object);
406 switch ($type) {
407 case PhabricatorTransactions::TYPE_CREATE:
408 case PhabricatorTransactions::TYPE_HISTORY:
409 return null;
410 case PhabricatorTransactions::TYPE_SUBTYPE:
411 return $object->getEditEngineSubtype();
412 case PhabricatorTransactions::TYPE_MFA:
413 return null;
414 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
415 return array_values($this->subscribers);
416 case PhabricatorTransactions::TYPE_VIEW_POLICY:
417 if ($this->getIsNewObject()) {
418 return null;
420 return $object->getViewPolicy();
421 case PhabricatorTransactions::TYPE_EDIT_POLICY:
422 if ($this->getIsNewObject()) {
423 return null;
425 return $object->getEditPolicy();
426 case PhabricatorTransactions::TYPE_JOIN_POLICY:
427 if ($this->getIsNewObject()) {
428 return null;
430 return $object->getJoinPolicy();
431 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
432 if ($this->getIsNewObject()) {
433 return null;
435 return $object->getInteractPolicy();
436 case PhabricatorTransactions::TYPE_SPACE:
437 if ($this->getIsNewObject()) {
438 return null;
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();
449 return $space_phid;
450 case PhabricatorTransactions::TYPE_EDGE:
451 $edge_type = $xaction->getMetadataValue('edge:type');
452 if (!$edge_type) {
453 throw new Exception(
454 pht(
455 "Edge transaction has no '%s'!",
456 'edge:type'));
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))
472 ->needEdgeData(true)
473 ->execute();
475 $old_edges = $old_edges[$edge_src][$edge_type];
477 return $old_edges;
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:
483 return null;
484 default:
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);
496 if ($xtype) {
497 $xtype = clone $xtype;
498 $xtype->setStorage($xaction);
499 return $xtype->generateNewValue($object, $xaction->getNewValue());
502 switch ($type) {
503 case PhabricatorTransactions::TYPE_CREATE:
504 return null;
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:
517 return true;
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();
528 } else {
529 return $space_phid;
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);
546 return $new_value;
547 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
548 $field = $this->getCustomFieldForTransaction($object, $xaction);
549 return $field->getNewValueFromApplicationTransactions($xaction);
550 case PhabricatorTransactions::TYPE_COMMENT:
551 return null;
552 default:
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:
576 return true;
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.
593 sort($old_dst);
594 sort($new_dst);
596 if ($old_dst !== $new_dst) {
597 // We've added or removed edges, so this transaction definitely
598 // has an effect.
599 return true;
602 // We haven't added or removed edges, but we might have changed
603 // edge data.
604 foreach ($old as $key => $old_value) {
605 $new_value = $new[$key];
606 if ($old_value['data'] !== $new_value['data']) {
607 return true;
611 return false;
614 $type = $xaction->getTransactionType();
615 $xtype = $this->getModularTransactionType($type);
616 if ($xtype) {
617 return $xtype->getTransactionHasEffect(
618 $object,
619 $xaction->getOldValue(),
620 $xaction->getNewValue());
623 if ($xaction->hasComment()) {
624 return true;
627 return ($xaction->getOldValue() !== $xaction->getNewValue());
630 protected function shouldApplyInitialEffects(
631 PhabricatorLiskDAO $object,
632 array $xactions) {
633 return false;
636 protected function applyInitialEffects(
637 PhabricatorLiskDAO $object,
638 array $xactions) {
639 throw new PhutilMethodNotImplementedException();
642 private function applyInternalEffects(
643 PhabricatorLiskDAO $object,
644 PhabricatorApplicationTransaction $xaction) {
646 $type = $xaction->getTransactionType();
648 $xtype = $this->getModularTransactionType($type);
649 if ($xtype) {
650 $xtype = clone $xtype;
651 $xtype->setStorage($xaction);
652 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
655 switch ($type) {
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);
686 if ($xtype) {
687 $xtype = clone $xtype;
688 $xtype->setStorage($xaction);
689 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
692 switch ($type) {
693 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
694 $subeditor = id(new PhabricatorSubscriptionsEditor())
695 ->setObject($object)
696 ->setActor($this->requireActor());
698 $old_map = array_fuse($xaction->getOldValue());
699 $new_map = array_fuse($xaction->getNewValue());
701 $subeditor->unsubscribe(
702 array_keys(
703 array_diff_key($old_map, $new_map)));
705 $subeditor->subscribeExplicit(
706 array_keys(
707 array_diff_key($new_map, $old_map)));
709 $subeditor->save();
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(
714 $this->subscribers,
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();
746 throw new Exception(
747 pht(
748 "Transaction type '%s' is missing an internal apply implementation!",
749 $type));
752 protected function applyCustomExternalTransaction(
753 PhabricatorLiskDAO $object,
754 PhabricatorApplicationTransaction $xaction) {
755 $type = $xaction->getTransactionType();
756 throw new Exception(
757 pht(
758 "Transaction type '%s' is missing an external apply implementation!",
759 $type));
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());
780 break;
781 case PhabricatorTransactions::TYPE_EDIT_POLICY:
782 $object->setEditPolicy($xaction->getNewValue());
783 break;
784 case PhabricatorTransactions::TYPE_JOIN_POLICY:
785 $object->setJoinPolicy($xaction->getNewValue());
786 break;
787 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
788 $object->setInteractPolicy($xaction->getNewValue());
789 break;
790 case PhabricatorTransactions::TYPE_SPACE:
791 $object->setSpacePHID($xaction->getNewValue());
792 break;
793 case PhabricatorTransactions::TYPE_SUBTYPE:
794 $object->setEditEngineSubtype($xaction->getNewValue());
795 break;
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.
812 break;
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']) {
829 continue;
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']) {
838 continue;
842 $data = array(
843 'data' => $edge['data'],
846 $editor->addEdge($src, $const, $dst_phid, $data);
849 $editor->save();
851 $this->updateWorkboardColumns($object, $const, $old, $new);
852 break;
853 case PhabricatorTransactions::TYPE_VIEW_POLICY:
854 case PhabricatorTransactions::TYPE_SPACE:
855 $this->scrambleFileSecrets($object);
856 break;
857 case PhabricatorTransactions::TYPE_HISTORY:
858 $this->sendHistory = true;
859 break;
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);
877 } else {
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);
900 return $xaction;
903 protected function didApplyInternalEffects(
904 PhabricatorLiskDAO $object,
905 array $xactions) {
906 return $xactions;
909 protected function applyFinalEffects(
910 PhabricatorLiskDAO $object,
911 array $xactions) {
912 return $xactions;
915 final protected function didCommitTransactions(
916 PhabricatorLiskDAO $object,
917 array $xactions) {
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()) {
932 continue;
935 $edge_const = $xaction->getMetadataValue('edge:type');
936 $edge_type = PhabricatorEdgeType::getByConstant($edge_const);
937 if ($edge_type->shouldWriteInverseTransactions()) {
938 $this->applyInverseEdgeTransactions(
939 $object,
940 $xaction,
941 $edge_type->getInverseEdgeConstant());
943 continue;
946 $xtype = $this->getModularTransactionType($type);
947 if (!$xtype) {
948 continue;
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;
959 return $this;
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;
974 return $this;
977 public function getRequest() {
978 return $this->request;
981 public function setCancelURI($cancel_uri) {
982 $this->cancelURI = $cancel_uri;
983 return $this;
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,
1000 array $xactions) {
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.
1014 if (!$is_preview) {
1015 if (!$is_new) {
1016 $this->buildOldRecipientLists($object, $xactions);
1018 $object->openTransaction();
1019 $transaction_open = true;
1021 $object->beginReadLocking();
1022 $read_locking = true;
1024 $object->reload();
1028 try {
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
1038 // attached.
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);
1052 if (!$is_preview) {
1053 $errors = array();
1054 $type_map = mgroup($xactions, 'getTransactionType');
1055 foreach ($this->getTransactionTypes() as $type) {
1056 $type_xactions = idx($type_map, $type, array());
1057 $errors[] = $this->validateTransaction(
1058 $object,
1059 $type,
1060 $type_xactions);
1063 $errors[] = $this->validateAllTransactions($object, $xactions);
1064 $errors[] = $this->validateTransactionsWithExtensions(
1065 $object,
1066 $xactions);
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]);
1076 if ($errors) {
1077 throw new PhabricatorApplicationTransactionValidationException(
1078 $errors);
1081 if ($this->raiseWarnings) {
1082 $warnings = array();
1083 foreach ($xactions as $xaction) {
1084 if ($this->hasWarnings($object, $xaction)) {
1085 $warnings[] = $xaction;
1088 if ($warnings) {
1089 throw new PhabricatorApplicationTransactionWarningException(
1090 $warnings);
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);
1108 if (!$is_preview) {
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);
1145 if ($is_preview) {
1146 $this->loadHandles($xactions);
1147 return $xactions;
1150 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1151 ->setActor($actor)
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
1164 // lockable object.
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);
1177 try {
1178 $object->save();
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);
1184 throw $ex;
1187 $group_id = $this->getTransactionGroupID();
1189 foreach ($xactions as $xaction) {
1190 if ($was_locked) {
1191 $is_override = $this->isLockOverrideTransaction($xaction);
1192 if ($is_override) {
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());
1203 } else {
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);
1222 $xaction->save();
1224 $xaction->setOldValue($bulky_old);
1225 $xaction->setNewValue($bulky_new);
1226 } else {
1227 $xaction->save();
1232 if ($file_phids) {
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;
1265 throw $ex;
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
1280 // madness.
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
1288 // transactions.
1289 return $xactions;
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()) {
1305 continue;
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(
1334 $object,
1335 $herald_xactions);
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(
1364 $object,
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(
1374 $object->getPHID(),
1375 $herald_header);
1376 } else {
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
1385 // publish.
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
1389 // email.
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();
1400 return $xactions;
1403 private function queuePublishing() {
1404 $object = $this->publishableObject;
1405 $xactions = $this->publishableTransactions;
1407 if (!$object) {
1408 throw new Exception(
1409 pht(
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(
1439 $object->getPHID(),
1440 PhabricatorMutedByEdgeType::EDGECONST);
1441 } else {
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(
1455 $object,
1456 $xactions);
1457 $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs(
1458 $object,
1459 $xactions);
1463 PhabricatorWorker::scheduleTask(
1464 'PhabricatorApplicationTransactionPublishWorker',
1465 array(
1466 'objectPHID' => $object->getPHID(),
1467 'actorPHID' => $this->getActingAsPHID(),
1468 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1469 'state' => $this->getWorkerState(),
1471 array(
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,
1485 array $xactions,
1486 Exception $ex) {
1487 return;
1490 public function publishTransactions(
1491 PhabricatorLiskDAO $object,
1492 array $xactions) {
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(
1510 $object->getPHID(),
1511 array(
1512 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1516 if ($this->feedShouldPublish) {
1517 $mailed = array();
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
1540 // retrying.
1541 foreach ($messages as $mail) {
1542 $mail->save();
1545 $this->queueWebhooks($object, $xactions);
1547 return $xactions;
1550 protected function didApplyTransactions($object, array $xactions) {
1551 // Hook for subclasses.
1552 return $xactions;
1555 private function loadHandles(array $xactions) {
1556 $phids = array();
1557 foreach ($xactions as $key => $xaction) {
1558 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1560 $handles = array();
1561 $merged = array_mergev($phids);
1562 if ($merged) {
1563 $handles = id(new PhabricatorHandleQuery())
1564 ->setViewer($this->requireActor())
1565 ->withPHIDs($merged)
1566 ->execute();
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);
1579 } else {
1580 $this->subscribers = array();
1584 private function validateEditParameters(
1585 PhabricatorLiskDAO $object,
1586 array $xactions) {
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"
1594 // set.
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(
1602 $xaction,
1603 pht('You can not apply transactions which already have IDs/PHIDs!'));
1606 if ($xaction->getObjectPHID()) {
1607 throw new PhabricatorApplicationTransactionStructureException(
1608 $xaction,
1609 pht(
1610 'You can not apply transactions which already have %s!',
1611 'objectPHIDs'));
1614 if ($xaction->getCommentPHID()) {
1615 throw new PhabricatorApplicationTransactionStructureException(
1616 $xaction,
1617 pht(
1618 'You can not apply transactions which already have %s!',
1619 'commentPHIDs'));
1622 if ($xaction->getCommentVersion() !== 0) {
1623 throw new PhabricatorApplicationTransactionStructureException(
1624 $xaction,
1625 pht(
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(
1641 $xaction,
1642 pht(
1643 'This transaction is supposed to have an %s set, but it does not!',
1644 'oldValue'));
1647 if ($has_value && !$expect_value) {
1648 throw new PhabricatorApplicationTransactionStructureException(
1649 $xaction,
1650 pht(
1651 'This transaction should generate its %s automatically, '.
1652 'but has already had one set!',
1653 'oldValue'));
1656 $type = $xaction->getTransactionType();
1657 if (empty($types[$type])) {
1658 throw new PhabricatorApplicationTransactionStructureException(
1659 $xaction,
1660 pht(
1661 'Transaction has type "%s", but that transaction type is not '.
1662 'supported by this editor (%s).',
1663 $type,
1664 get_class($this)));
1669 private function applyCapabilityChecks(
1670 PhabricatorLiskDAO $object,
1671 array $xactions) {
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();
1682 } else {
1683 if (!$xactions && !$this->xactions) {
1684 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1685 $required_capabilities = array($can_edit);
1686 } else {
1687 $required_capabilities = array();
1689 foreach ($xactions as $xaction) {
1690 $type = $xaction->getTransactionType();
1692 $xtype = $this->getModularTransactionType($type);
1693 if (!$xtype) {
1694 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1695 } else {
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();
1704 } else {
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())
1720 ->setViewer($actor)
1721 ->requireCapabilities($required_capabilities)
1722 ->raisePolicyExceptions(true)
1723 ->apply(array($object));
1727 private function getLegacyRequiredCapabilities(
1728 PhabricatorApplicationTransaction $xaction) {
1730 $type = $xaction->getTransactionType();
1731 switch ($type) {
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.
1736 return null;
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
1745 // object.
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
1755 // on objects.
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(
1765 $this->getActor(),
1766 $this->object,
1767 PhabricatorPolicyCapability::CAN_EDIT);
1769 $add = array_diff_key($new, $old);
1770 foreach ($add as $phid) {
1771 if ($phid !== $this->getActingAsPHID()) {
1772 if ($can_edit) {
1773 return PhabricatorPolicyCapability::CAN_EDIT;
1774 } else {
1775 return PhabricatorPolicyCapability::CAN_INTERACT;
1780 return null;
1781 case PhabricatorTransactions::TYPE_TOKEN:
1782 // TODO: This technically requires CAN_INTERACT, like comments.
1783 return null;
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.
1788 return null;
1789 case PhabricatorTransactions::TYPE_MFA:
1790 // Signing a transaction group with MFA does not require permissions
1791 // on its own.
1792 return null;
1793 case PhabricatorTransactions::TYPE_EDGE:
1794 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1795 default:
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()) {
1809 return null;
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.
1818 return null;
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.
1823 return null;
1824 case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
1825 return null;
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);
1838 if ($is_join) {
1839 // You need CAN_JOIN to join a project.
1840 return PhabricatorPolicyCapability::CAN_JOIN;
1843 if ($is_leave) {
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;
1849 } else {
1850 return null;
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.
1858 return null;
1859 default:
1860 return PhabricatorPolicyCapability::CAN_EDIT;
1865 private function buildSubscribeTransaction(
1866 PhabricatorLiskDAO $object,
1867 array $xactions,
1868 array $changes) {
1870 if (!($object instanceof PhabricatorSubscribableInterface)) {
1871 return null;
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(
1882 $this->getActor(),
1883 $old_texts);
1885 $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
1886 $this->getActor(),
1887 $new_texts);
1889 $phids = array_diff($new_phids, $old_phids);
1890 } else {
1891 $phids = array();
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);
1903 if ($phids) {
1904 $users = id(new PhabricatorPeopleQuery())
1905 ->setViewer($this->getActor())
1906 ->withPHIDs($phids)
1907 ->execute();
1908 $users = mpull($users, null, 'getPHID');
1910 foreach ($phids as $key => $phid) {
1911 $user = idx($users, $phid);
1913 // Don't subscribe invalid users.
1914 if (!$user) {
1915 unset($phids[$key]);
1916 continue;
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]);
1924 continue;
1927 // Do not subscribe mentioned users who do not have permission to see
1928 // the object.
1929 if ($object instanceof PhabricatorPolicyInterface) {
1930 $can_view = PhabricatorPolicyFilter::hasCapability(
1931 $user,
1932 $object,
1933 PhabricatorPolicyCapability::CAN_VIEW);
1934 if (!$can_view) {
1935 unset($phids[$key]);
1936 continue;
1940 // Don't subscribe users who are already automatically subscribed.
1941 if ($object->isAutomaticallySubscribed($phid)) {
1942 unset($phids[$key]);
1943 continue;
1947 $phids = array_values($phids);
1950 if (!$phids) {
1951 return null;
1954 $xaction = $object->getApplicationTransactionTemplate()
1955 ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
1956 ->setNewValue(array('+' => $phids));
1958 return $xaction;
1961 protected function mergeTransactions(
1962 PhabricatorApplicationTransaction $u,
1963 PhabricatorApplicationTransaction $v) {
1965 $type = $u->getTransactionType();
1967 $xtype = $this->getModularTransactionType($type);
1968 if ($xtype) {
1969 $object = $this->object;
1970 return $xtype->mergeTransactions($object, $u, $v);
1973 switch ($type) {
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);
1982 return null;
1985 // By default, do not merge the transactions.
1986 return null;
1990 * Optionally expand transactions which imply other effects. For example,
1991 * resigning from a revision in Differential implies removing yourself as
1992 * a reviewer.
1994 protected function expandTransactions(
1995 PhabricatorLiskDAO $object,
1996 array $xactions) {
1998 $results = array();
1999 foreach ($xactions as $xaction) {
2000 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2001 $results[] = $expanded;
2005 return $results;
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(
2021 $object,
2022 $xactions);
2024 if (count($xactions) == 1) {
2025 return array();
2028 foreach ($xactions as $index => $cxaction) {
2029 if ($cxaction === $xaction) {
2030 unset($xactions[$index]);
2031 break;
2035 return $xactions;
2038 private function expandSupportTransactions(
2039 PhabricatorLiskDAO $object,
2040 array $xactions) {
2041 $this->loadSubscribers($object);
2043 $xactions = $this->applyImplicitCC($object, $xactions);
2045 $changes = $this->getRemarkupChanges($xactions);
2047 $subscribe_xaction = $this->buildSubscribeTransaction(
2048 $object,
2049 $xactions,
2050 $changes);
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(
2060 $object,
2061 $xactions,
2062 $changes,
2063 $engine);
2065 foreach ($block_xactions as $xaction) {
2066 $xactions[] = $xaction;
2069 return $xactions;
2072 private function getRemarkupChanges(array $xactions) {
2073 $changes = array();
2075 foreach ($xactions as $key => $xaction) {
2076 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2077 $changes[] = $change;
2081 return $changes;
2084 private function getRemarkupChangesFromTransaction(
2085 PhabricatorApplicationTransaction $transaction) {
2086 return $transaction->getRemarkupChanges();
2089 private function expandRemarkupBlockTransactions(
2090 PhabricatorLiskDAO $object,
2091 array $xactions,
2092 array $changes,
2093 PhutilMarkupEngine $engine) {
2095 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2096 $object,
2097 $xactions,
2098 $changes,
2099 $engine);
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,
2111 array());
2115 if (!$mentioned_phids) {
2116 return $block_xactions;
2119 $mentioned_objects = id(new PhabricatorObjectQuery())
2120 ->setViewer($this->getActor())
2121 ->withPHIDs($mentioned_phids)
2122 ->execute();
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])) {
2132 continue;
2134 // don't let objects mention themselves
2135 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2136 continue;
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,
2157 array $xactions,
2158 array $changes,
2159 PhutilMarkupEngine $engine) {
2160 return array();
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();
2172 $result = array();
2173 $types = 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()) {
2183 continue;
2186 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2187 if ($merged) {
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.
2201 continue 2;
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;
2229 } else {
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;
2237 } else {
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
2241 // precise way.
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'),
2257 $ux_data,
2258 $vx_data);
2260 $u_spec['data'] = $merged_data;
2261 $merged[$dst] = $u_spec;
2265 $result[$key] = $merged;
2267 } else {
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);
2279 return $u;
2282 protected function mergeEdgeData($type, array $u, array $v) {
2283 return $v + $u;
2286 protected function getPHIDTransactionNewValue(
2287 PhabricatorApplicationTransaction $xaction,
2288 $old = null) {
2290 if ($old !== null) {
2291 $old = array_fuse($old);
2292 } else {
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());
2301 unset($new['+']);
2302 $new_rem = idx($new, '-', array());
2303 unset($new['-']);
2304 $new_set = idx($new, '=', null);
2305 if ($new_set !== null) {
2306 $new_set = array_fuse($new_set);
2308 unset($new['=']);
2310 if ($new) {
2311 throw new Exception(
2312 pht(
2313 "Invalid '%s' value for PHID transaction. Value should contain only ".
2314 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2315 'new',
2316 '+',
2317 '-',
2318 '='));
2321 $result = array();
2323 foreach ($old as $phid) {
2324 if ($new_set !== null && empty($new_set[$phid])) {
2325 continue;
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());
2352 unset($new['+']);
2353 $new_rem = idx($new, '-', array());
2354 unset($new['-']);
2355 $new_set = idx($new, '=', null);
2356 unset($new['=']);
2358 if ($new) {
2359 throw new Exception(
2360 pht(
2361 "Invalid '%s' value for Edge transaction. Value should contain only ".
2362 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2363 'new',
2364 '+',
2365 '-',
2366 '='));
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'));
2376 $result = array();
2377 foreach ($old as $dst_phid => $edge) {
2378 if ($new_set !== null && empty($new_set[$dst_phid])) {
2379 continue;
2381 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2382 $xaction,
2383 $edge,
2384 $dst_phid);
2387 if ($new_set !== null) {
2388 foreach ($new_set as $dst_phid => $edge) {
2389 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2390 $xaction,
2391 $edge,
2392 $dst_phid);
2396 foreach ($new_add as $dst_phid => $edge) {
2397 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2398 $xaction,
2399 $edge,
2400 $dst_phid);
2403 foreach ($new_rem as $dst_phid => $edge) {
2404 unset($result[$dst_phid]);
2407 return $result;
2410 private function checkEdgeList($list, $edge_type) {
2411 if (!$list) {
2412 return;
2414 foreach ($list as $key => $item) {
2415 if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
2416 throw new Exception(
2417 pht(
2418 'Edge transactions must have destination PHIDs as in edge '.
2419 'lists (found key "%s" on transaction of type "%s").',
2420 $key,
2421 $edge_type));
2423 if (!is_array($item) && $item !== $key) {
2424 throw new Exception(
2425 pht(
2426 'Edge transactions must have PHIDs or edge specs as values '.
2427 '(found value "%s" on transaction of type "%s").',
2428 $item,
2429 $edge_type));
2434 private function normalizeEdgeTransactionValue(
2435 PhabricatorApplicationTransaction $xaction,
2436 $edge,
2437 $dst_phid) {
2439 if (!is_array($edge)) {
2440 if ($edge != $dst_phid) {
2441 throw new Exception(
2442 pht(
2443 'Transaction edge data must either be the edge PHID or an edge '.
2444 'specification dictionary.'));
2446 $edge = array();
2447 } else {
2448 foreach ($edge as $key => $value) {
2449 switch ($key) {
2450 case 'src':
2451 case 'dst':
2452 case 'type':
2453 case 'data':
2454 case 'dateCreated':
2455 case 'dateModified':
2456 case 'seq':
2457 case 'dataID':
2458 break;
2459 default:
2460 throw new Exception(
2461 pht(
2462 'Transaction edge specification contains unexpected key "%s".',
2463 $key));
2468 $edge['dst'] = $dst_phid;
2470 $edge_type = $xaction->getMetadataValue('edge:type');
2471 if (empty($edge['type'])) {
2472 $edge['type'] = $edge_type;
2473 } else {
2474 if ($edge['type'] != $edge_type) {
2475 $this_type = $edge['type'];
2476 throw new Exception(
2477 pht(
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.",
2481 $this_type,
2482 $edge_type));
2486 if (!isset($edge['data'])) {
2487 $edge['data'] = array();
2490 return $edge;
2493 protected function sortTransactions(array $xactions) {
2494 $head = array();
2495 $tail = array();
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) {
2501 $tail[] = $xaction;
2502 } else {
2503 $head[] = $xaction;
2507 return array_values(array_merge($head, $tail));
2511 protected function filterTransactions(
2512 PhabricatorLiskDAO $object,
2513 array $xactions) {
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;
2526 continue;
2529 if ($this->transactionHasEffect($object, $xaction)) {
2530 if ($xaction->getTransactionType() != $type_comment) {
2531 $any_effect = true;
2533 } else if ($xaction->getIgnoreOnNoEffect()) {
2534 unset($xactions[$key]);
2535 } else {
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;
2551 if (!$no_effect) {
2552 return $xactions;
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(
2564 $no_effect,
2565 $any_effect,
2566 $has_comment);
2569 if (!$any_effect && !$has_comment) {
2570 // If we only have empty comment transactions, just drop them all.
2571 return array();
2574 foreach ($no_effect as $key => $xaction) {
2575 if ($xaction->hasComment()) {
2576 $xaction->setTransactionType($type_comment);
2577 $xaction->setOldValue(null);
2578 $xaction->setNewValue(null);
2579 } else {
2580 unset($xactions[$key]);
2584 return $xactions;
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
2599 * given type.
2600 * @return list<PhabricatorApplicationTransactionValidationError> List of
2601 * validation errors.
2603 protected function validateTransaction(
2604 PhabricatorLiskDAO $object,
2605 $type,
2606 array $xactions) {
2608 $errors = array();
2610 $xtype = $this->getModularTransactionType($type);
2611 if ($xtype) {
2612 $errors[] = $xtype->validateTransactions($object, $xactions);
2615 switch ($type) {
2616 case PhabricatorTransactions::TYPE_VIEW_POLICY:
2617 $errors[] = $this->validatePolicyTransaction(
2618 $object,
2619 $xactions,
2620 $type,
2621 PhabricatorPolicyCapability::CAN_VIEW);
2622 break;
2623 case PhabricatorTransactions::TYPE_EDIT_POLICY:
2624 $errors[] = $this->validatePolicyTransaction(
2625 $object,
2626 $xactions,
2627 $type,
2628 PhabricatorPolicyCapability::CAN_EDIT);
2629 break;
2630 case PhabricatorTransactions::TYPE_SPACE:
2631 $errors[] = $this->validateSpaceTransactions(
2632 $object,
2633 $xactions,
2634 $type);
2635 break;
2636 case PhabricatorTransactions::TYPE_SUBTYPE:
2637 $errors[] = $this->validateSubtypeTransactions(
2638 $object,
2639 $xactions,
2640 $type);
2641 break;
2642 case PhabricatorTransactions::TYPE_MFA:
2643 $errors[] = $this->validateMFATransactions(
2644 $object,
2645 $xactions,
2646 $type);
2647 break;
2648 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
2649 $groups = array();
2650 foreach ($xactions as $xaction) {
2651 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2654 $field_list = PhabricatorCustomField::getObjectFields(
2655 $object,
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)) {
2662 continue;
2664 $errors[] = $field->validateApplicationTransactions(
2665 $this,
2666 $type,
2667 idx($groups, $field->getFieldKey(), array()));
2669 break;
2672 return array_mergev($errors);
2675 public function validatePolicyTransaction(
2676 PhabricatorLiskDAO $object,
2677 array $xactions,
2678 $transaction_type,
2679 $capability) {
2681 $actor = $this->requireActor();
2682 $errors = array();
2683 // Note $this->xactions is necessary; $xactions is $this->xactions of
2684 // $transaction_type
2685 $policy_object = $this->adjustObjectForPolicyChecks(
2686 $object,
2687 $this->xactions);
2689 // Make sure the user isn't editing away their ability to $capability this
2690 // object.
2691 foreach ($xactions as $xaction) {
2692 try {
2693 PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
2694 $actor,
2695 $policy_object,
2696 $capability,
2697 $xaction->getNewValue());
2698 } catch (PhabricatorPolicyException $ex) {
2699 $errors[] = new PhabricatorApplicationTransactionValidationError(
2700 $transaction_type,
2701 pht('Invalid'),
2702 pht(
2703 'You can not select this %s policy, because you would no longer '.
2704 'be able to %s the object.',
2705 $capability,
2706 $capability),
2707 $xaction);
2711 if ($this->getIsNewObject()) {
2712 if (!$xactions) {
2713 $has_capability = PhabricatorPolicyFilter::hasCapability(
2714 $actor,
2715 $policy_object,
2716 $capability);
2717 if (!$has_capability) {
2718 $errors[] = new PhabricatorApplicationTransactionValidationError(
2719 $transaction_type,
2720 pht('Invalid'),
2721 pht(
2722 'The selected %s policy excludes you. Choose a %s policy '.
2723 'which allows you to %s the object.',
2724 $capability,
2725 $capability,
2726 $capability));
2731 return $errors;
2735 private function validateSpaceTransactions(
2736 PhabricatorLiskDAO $object,
2737 array $xactions,
2738 $transaction_type) {
2739 $errors = array();
2741 $actor = $this->getActor();
2743 $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
2744 $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
2745 $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
2746 $actor);
2747 foreach ($xactions as $xaction) {
2748 $space_phid = $xaction->getNewValue();
2750 if ($space_phid === null) {
2751 if (!$has_spaces) {
2752 // The install doesn't have any spaces, so this is fine.
2753 continue;
2756 // The install has some spaces, so every object needs to be put
2757 // in a valid space.
2758 $errors[] = new PhabricatorApplicationTransactionValidationError(
2759 $transaction_type,
2760 pht('Invalid'),
2761 pht('You must choose a space for this object.'),
2762 $xaction);
2763 continue;
2766 // If the PHID isn't `null`, it needs to be a valid space that the
2767 // viewer can see.
2768 if (empty($actor_spaces[$space_phid])) {
2769 $errors[] = new PhabricatorApplicationTransactionValidationError(
2770 $transaction_type,
2771 pht('Invalid'),
2772 pht(
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.'),
2775 $xaction);
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) {
2782 continue;
2785 $errors[] = new PhabricatorApplicationTransactionValidationError(
2786 $transaction_type,
2787 pht('Archived'),
2788 pht(
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.'),
2792 $xaction);
2796 return $errors;
2799 private function validateSubtypeTransactions(
2800 PhabricatorLiskDAO $object,
2801 array $xactions,
2802 $transaction_type) {
2803 $errors = array();
2805 $map = $object->newEditEngineSubtypeMap();
2806 $old = $object->getEditEngineSubtype();
2807 foreach ($xactions as $xaction) {
2808 $new = $xaction->getNewValue();
2810 if ($old == $new) {
2811 continue;
2814 if (!$map->isValidSubtype($new)) {
2815 $errors[] = new PhabricatorApplicationTransactionValidationError(
2816 $transaction_type,
2817 pht('Invalid'),
2818 pht(
2819 'The subtype "%s" is not a valid subtype.',
2820 $new),
2821 $xaction);
2822 continue;
2826 return $errors;
2829 private function validateMFATransactions(
2830 PhabricatorLiskDAO $object,
2831 array $xactions,
2832 $transaction_type) {
2833 $errors = array();
2835 $factors = id(new PhabricatorAuthFactorConfigQuery())
2836 ->setViewer($this->getActor())
2837 ->withUserPHIDs(array($this->getActingAsPHID()))
2838 ->withFactorProviderStatuses(
2839 array(
2840 PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
2841 PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
2843 ->execute();
2845 foreach ($xactions as $xaction) {
2846 if (!$factors) {
2847 $errors[] = new PhabricatorApplicationTransactionValidationError(
2848 $transaction_type,
2849 pht('No MFA'),
2850 pht(
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.'),
2854 $xaction);
2858 if ($xactions) {
2859 $this->setShouldRequireMFA(true);
2862 return $errors;
2865 protected function adjustObjectForPolicyChecks(
2866 PhabricatorLiskDAO $object,
2867 array $xactions) {
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(
2878 $clone_xaction));
2880 PhabricatorPolicyRule::passTransactionHintToRule(
2881 $copy,
2882 new PhabricatorSubscriptionsSubscribersPolicyRule(),
2883 array_fuse($clone_xaction->getNewValue()));
2885 break;
2886 case PhabricatorTransactions::TYPE_SPACE:
2887 $space_phid = $this->getTransactionNewValue($object, $xaction);
2888 $copy->setSpacePHID($space_phid);
2889 break;
2893 return $copy;
2896 protected function validateAllTransactions(
2897 PhabricatorLiskDAO $object,
2898 array $xactions) {
2899 return array();
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}
2908 * more convenient:
2910 * $missing = $this->validateIsEmptyTextField(
2911 * $object->getName(),
2912 * $xactions);
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
2919 * field.
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)) {
2924 return false;
2927 if ($xactions && strlen(last($xactions)->getNewValue())) {
2928 return false;
2931 return true;
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,
2943 array $xactions) {
2945 if (!($object instanceof PhabricatorSubscribableInterface)) {
2946 // If the object isn't subscribable, we can't CC them.
2947 return $xactions;
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.
2956 return $xactions;
2959 if ($object->isAutomaticallySubscribed($actor_phid)) {
2960 // If they're auto-subscribed, don't CC them.
2961 return $xactions;
2964 $should_cc = false;
2965 foreach ($xactions as $xaction) {
2966 if ($this->shouldImplyCC($object, $xaction)) {
2967 $should_cc = true;
2968 break;
2972 if (!$should_cc) {
2973 // Only some types of actions imply a CC (like adding a comment).
2974 return $xactions;
2977 if ($object->getPHID()) {
2978 if (isset($this->subscribers[$actor_phid])) {
2979 // If the user is already subscribed, don't implicitly CC them.
2980 return $xactions;
2983 $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
2984 $object->getPHID(),
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.
2990 return $xactions;
2994 $actor = $this->getActor();
2996 $user = id(new PhabricatorPeopleQuery())
2997 ->setViewer($actor)
2998 ->withPHIDs(array($actor_phid))
2999 ->executeOne();
3000 if (!$user) {
3001 return $xactions;
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()) {
3009 return $xactions;
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);
3018 return $xactions;
3021 protected function shouldImplyCC(
3022 PhabricatorLiskDAO $object,
3023 PhabricatorApplicationTransaction $xaction) {
3025 return $xaction->isCommentTransaction();
3029 /* -( Sending Mail )------------------------------------------------------- */
3033 * @task mail
3035 protected function shouldSendMail(
3036 PhabricatorLiskDAO $object,
3037 array $xactions) {
3038 return false;
3043 * @task mail
3045 private function buildMail(
3046 PhabricatorLiskDAO $object,
3047 array $xactions) {
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(
3059 $object,
3060 $xactions,
3061 $email_to,
3062 $email_cc,
3063 $unexpandable);
3065 $this->runHeraldMailRules($messages);
3067 return $messages;
3070 private function buildMailWithRecipients(
3071 PhabricatorLiskDAO $object,
3072 array $xactions,
3073 array $email_to,
3074 array $email_cc,
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());
3094 $caught = null;
3095 $mail = null;
3096 try {
3097 // Reload the transactions for the current viewer.
3098 if ($xaction_phids) {
3099 $query = PhabricatorApplicationTransactionQuery::newQueryForObject(
3100 $object);
3102 $mail_xactions = $query
3103 ->setViewer($viewer)
3104 ->withObjectPHIDs(array($object->getPHID()))
3105 ->withPHIDs($xaction_phids)
3106 ->execute();
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);
3112 } else {
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);
3122 if ($mail) {
3123 if ($this->mustEncrypt) {
3124 $mail
3125 ->setMustEncrypt(true)
3126 ->setMustEncryptReasons($this->mustEncrypt);
3129 } catch (Exception $ex) {
3130 $caught = $ex;
3133 $this->setActor($original_actor);
3134 unset($locale);
3136 if ($caught) {
3137 throw $ex;
3140 if ($mail) {
3141 $messages[] = $mail;
3145 return $messages;
3148 protected function getTransactionsForMail(
3149 PhabricatorLiskDAO $object,
3150 array $xactions) {
3151 return $xactions;
3154 private function buildMailForTarget(
3155 PhabricatorLiskDAO $object,
3156 array $xactions,
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;
3166 break;
3170 if (!$any_visible) {
3171 return null;
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(
3185 $body,
3186 $object,
3187 $mail_xactions);
3190 $muted_phids = $this->mailMutedPHIDs;
3191 if (!is_array($muted_phids)) {
3192 $muted_phids = array();
3195 $mail
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)
3206 ->setIsBulk(true)
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.
3234 if ($stamps) {
3235 $mail->setMailStamps($stamps);
3238 return $target->willSendMail($mail);
3241 private function addMailProjectMetadata(
3242 PhabricatorLiskDAO $object,
3243 PhabricatorMetaMTAMail $template) {
3245 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3246 $object->getPHID(),
3247 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3249 if (!$project_phids) {
3250 return;
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
3255 // works today.
3257 $handles = id(new PhabricatorHandleQuery())
3258 ->setViewer($this->requireActor())
3259 ->withPHIDs($project_phids)
3260 ->execute();
3262 $project_tags = array();
3263 foreach ($handles as $handle) {
3264 if (!$handle->isComplete()) {
3265 continue;
3267 $project_tags[] = '<'.$handle->getObjectName().'>';
3270 if (!$project_tags) {
3271 return;
3274 $project_tags = implode(', ', $project_tags);
3275 $template->addHeader('X-Phabricator-Projects', $project_tags);
3279 protected function getMailThreadID(PhabricatorLiskDAO $object) {
3280 return $object->getPHID();
3285 * @task mail
3287 protected function getStrongestAction(
3288 PhabricatorLiskDAO $object,
3289 array $xactions) {
3290 return head(msortv($xactions, 'newActionStrengthSortVector'));
3295 * @task mail
3297 protected function buildReplyHandler(PhabricatorLiskDAO $object) {
3298 throw new Exception(pht('Capability not supported.'));
3302 * @task mail
3304 protected function getMailSubjectPrefix() {
3305 throw new Exception(pht('Capability not supported.'));
3310 * @task mail
3312 protected function getMailTags(
3313 PhabricatorLiskDAO $object,
3314 array $xactions) {
3315 $tags = array();
3317 foreach ($xactions as $xaction) {
3318 $tags[] = $xaction->getMailTags();
3321 return array_mergev($tags);
3325 * @task mail
3327 public function getMailTagsMap() {
3328 // TODO: We should move shared mail tags, like "comment", here.
3329 return array();
3334 * @task mail
3336 protected function getMailAction(
3337 PhabricatorLiskDAO $object,
3338 array $xactions) {
3339 return $this->getStrongestAction($object, $xactions)->getActionName();
3344 * @task mail
3346 protected function buildMailTemplate(PhabricatorLiskDAO $object) {
3347 throw new Exception(pht('Capability not supported.'));
3352 * @task mail
3354 protected function getMailTo(PhabricatorLiskDAO $object) {
3355 throw new Exception(pht('Capability not supported.'));
3359 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
3360 return array();
3365 * @task mail
3367 protected function getMailCC(PhabricatorLiskDAO $object) {
3368 $phids = array();
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(
3379 $object->getPHID(),
3380 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3382 if ($project_phids) {
3383 $projects = id(new PhabricatorProjectQuery())
3384 ->setViewer(PhabricatorUser::getOmnipotentUser())
3385 ->withPHIDs($project_phids)
3386 ->needWatchers(true)
3387 ->execute();
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)
3403 ->execute();
3405 $watchers = array();
3406 foreach ($users as $user) {
3407 $can_see = PhabricatorPolicyFilter::hasCapability(
3408 $user,
3409 $object,
3410 PhabricatorPolicyCapability::CAN_VIEW);
3411 if ($can_see) {
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);
3435 * @task mail
3437 protected function buildMailBody(
3438 PhabricatorLiskDAO $object,
3439 array $xactions) {
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(
3449 $body,
3450 $xactions,
3451 $button_label,
3452 $button_uri);
3454 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3456 return $body;
3459 protected function getObjectLinkButtonLabelForMail(
3460 PhabricatorLiskDAO $object) {
3461 return null;
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.
3471 try {
3472 $uri = $object->getURI();
3473 return PhabricatorEnv::getProductionURI($uri);
3474 } catch (Exception $ex) {
3475 return null;
3480 * @task mail
3482 protected function addEmailPreferenceSectionToMailBody(
3483 PhabricatorMetaMTAMailBody $body,
3484 PhabricatorLiskDAO $object,
3485 array $xactions) {
3487 $href = PhabricatorEnv::getProductionURI(
3488 '/settings/panel/emailpreferences/');
3489 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3494 * @task mail
3496 protected function addHeadersAndCommentsToMailBody(
3497 PhabricatorMetaMTAMailBody $body,
3498 array $xactions,
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]);
3509 $headers = array();
3510 $headers_html = array();
3511 $comments = array();
3512 $details = 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
3519 // transaction.
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
3525 // actual comment.
3527 $comment = $this->getBodyForTextMail($xaction);
3528 if ($comment !== null) {
3529 $is_comment = true;
3530 $comments[] = array(
3531 'xaction' => $xaction,
3532 'comment' => $comment,
3533 'initial' => !$seen_comment,
3535 } else {
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;
3555 if ($is_comment) {
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;',
3571 'float: right;',
3572 'color: #464C5C;',
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(
3582 'a',
3583 array(
3584 'style' => implode(' ', $button_style),
3585 'href' => $object_uri,
3587 $object_label);
3590 $xactions_style = array();
3592 $header_action = phutil_tag(
3593 'td',
3594 array(),
3595 $header_button);
3597 $header_action = phutil_tag(
3598 'td',
3599 array(
3600 'style' => implode(' ', $xactions_style),
3602 array(
3603 $headers_html,
3604 // Add an extra newline to prevent the "View Object" button from
3605 // running into the transaction text in Mail.app text snippet
3606 // previews.
3607 "\n",
3610 $headers_html = phutil_tag(
3611 'table',
3612 array(),
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.
3624 if (!$is_initial) {
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);
3653 if ($xtype) {
3654 return $xtype->getMailDiffSectionHeader();
3657 return pht('EDIT DETAILS');
3661 * @task mail
3663 protected function addCustomFieldsToMailBody(
3664 PhabricatorMetaMTAMailBody $body,
3665 PhabricatorLiskDAO $object,
3666 array $xactions) {
3668 if ($object instanceof PhabricatorCustomFieldInterface) {
3669 $field_list = PhabricatorCustomField::getObjectFields(
3670 $object,
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(
3677 $body,
3678 $this,
3679 $xactions);
3686 * @task mail
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 )-------------------------------------------- */
3705 * @task feed
3707 protected function shouldPublishFeedStory(
3708 PhabricatorLiskDAO $object,
3709 array $xactions) {
3710 return false;
3715 * @task feed
3717 protected function getFeedStoryType() {
3718 return 'PhabricatorApplicationTransactionFeedStory';
3723 * @task feed
3725 protected function getFeedRelatedPHIDs(
3726 PhabricatorLiskDAO $object,
3727 array $xactions) {
3729 $phids = array(
3730 $object->getPHID(),
3731 $this->getActingAsPHID(),
3734 if ($object instanceof PhabricatorProjectInterface) {
3735 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3736 $object->getPHID(),
3737 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3738 foreach ($project_phids as $project_phid) {
3739 $phids[] = $project_phid;
3743 return $phids;
3748 * @task feed
3750 protected function getFeedNotifyPHIDs(
3751 PhabricatorLiskDAO $object,
3752 array $xactions) {
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) {
3761 continue;
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);
3780 * @task feed
3782 protected function getFeedStoryData(
3783 PhabricatorLiskDAO $object,
3784 array $xactions) {
3786 $xactions = msortv($xactions, 'newActionStrengthSortVector');
3788 return array(
3789 'objectPHID' => $object->getPHID(),
3790 'transactionPHIDs' => mpull($xactions, 'getPHID'),
3796 * @task feed
3798 protected function publishFeedStory(
3799 PhabricatorLiskDAO $object,
3800 array $xactions,
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()) {
3807 continue;
3810 if (!$xaction->shouldHideForNotifications()) {
3811 continue;
3814 unset($xactions[$key]);
3817 if (!$xactions) {
3818 return;
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))
3855 ->publish();
3859 /* -( Search Index )------------------------------------------------------- */
3863 * @task search
3865 protected function supportsSearch() {
3866 return false;
3870 /* -( Herald Integration )-------------------------------------------------- */
3873 protected function shouldApplyHeraldRules(
3874 PhabricatorLiskDAO $object,
3875 array $xactions) {
3876 return false;
3879 protected function buildHeraldAdapter(
3880 PhabricatorLiskDAO $object,
3881 array $xactions) {
3882 throw new Exception(pht('No herald adapter specified.'));
3885 private function setHeraldAdapter(HeraldAdapter $adapter) {
3886 $this->heraldAdapter = $adapter;
3887 return $this;
3890 protected function getHeraldAdapter() {
3891 return $this->heraldAdapter;
3894 private function setHeraldTranscript(HeraldTranscript $transcript) {
3895 $this->heraldTranscript = $transcript;
3896 return $this;
3899 protected function getHeraldTranscript() {
3900 return $this->heraldTranscript;
3903 private function applyHeraldRules(
3904 PhabricatorLiskDAO $object,
3905 array $xactions) {
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(
3936 $buildable_phid,
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))
3947 ->execute();
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()) {
3952 continue;
3954 $buildable->sendMessage(
3955 $this->getActor(),
3956 HarbormasterMessageType::BUILDABLE_BUILD,
3957 true);
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();
3973 return array_merge(
3974 array_values($apply_xactions),
3975 array_values($queue_xactions));
3978 protected function didApplyHeraldRules(
3979 PhabricatorLiskDAO $object,
3980 HeraldAdapter $adapter,
3981 HeraldTranscript $transcript) {
3982 return array();
3986 /* -( Custom Fields )------------------------------------------------------ */
3990 * @task customfield
3992 private function getCustomFieldForTransaction(
3993 PhabricatorLiskDAO $object,
3994 PhabricatorApplicationTransaction $xaction) {
3996 $field_key = $xaction->getMetadataValue('customfield:key');
3997 if (!$field_key) {
3998 throw new Exception(
3999 pht(
4000 "Custom field transaction has no '%s'!",
4001 'customfield:key'));
4004 $field = PhabricatorCustomField::getObjectField(
4005 $object,
4006 PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
4007 $field_key);
4009 if (!$field) {
4010 throw new Exception(
4011 pht(
4012 "Custom field transaction has invalid '%s'; field '%s' ".
4013 "is disabled or does not exist.",
4014 'customfield:key',
4015 $field_key));
4018 if (!$field->shouldAppearInApplicationTransactions()) {
4019 throw new Exception(
4020 pht(
4021 "Custom field transaction '%s' does not implement ".
4022 "integration for %s.",
4023 $field_key,
4024 'ApplicationTransactions'));
4027 $field->setViewer($this->getActor());
4029 return $field;
4033 /* -( Files )-------------------------------------------------------------- */
4037 * Extract the PHIDs of any files which these transactions attach.
4039 * @task files
4041 private function extractFilePHIDs(
4042 PhabricatorLiskDAO $object,
4043 array $xactions) {
4045 $changes = $this->getRemarkupChanges($xactions);
4046 $blocks = mpull($changes, 'getNewValue');
4048 $phids = array();
4049 if ($blocks) {
4050 $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
4051 $this->getActor(),
4052 $blocks);
4055 foreach ($xactions as $xaction) {
4056 $type = $xaction->getTransactionType();
4058 $xtype = $this->getModularTransactionType($type);
4059 if ($xtype) {
4060 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4061 } else {
4062 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4063 $object,
4064 $xaction);
4068 $phids = array_unique(array_filter(array_mergev($phids)));
4069 if (!$phids) {
4070 return array();
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())
4079 ->withPHIDs($phids)
4080 ->execute();
4082 return mpull($files, 'getPHID');
4086 * @task files
4088 protected function extractFilePHIDsFromCustomTransaction(
4089 PhabricatorLiskDAO $object,
4090 PhabricatorApplicationTransaction $xaction) {
4091 return array();
4096 * @task files
4098 private function attachFiles(
4099 PhabricatorLiskDAO $object,
4100 array $file_phids) {
4102 if (!$file_phids) {
4103 return;
4106 $editor = new PhabricatorEdgeEditor();
4108 $src = $object->getPHID();
4109 $type = PhabricatorObjectHasFileEdgeType::EDGECONST;
4110 foreach ($file_phids as $dst) {
4111 $editor->addEdge($src, $type, $dst);
4114 $editor->save();
4117 private function applyInverseEdgeTransactions(
4118 PhabricatorLiskDAO $object,
4119 PhabricatorApplicationTransaction $xaction,
4120 $inverse_type) {
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);
4130 $all = $add + $rem;
4132 $nodes = id(new PhabricatorObjectQuery())
4133 ->setViewer($this->requireActor())
4134 ->withPHIDs($all)
4135 ->execute();
4137 $object_phid = $object->getPHID();
4139 foreach ($nodes as $node) {
4140 if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
4141 continue;
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.
4148 continue;
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]);
4174 } else {
4175 $old_phids[$object_phid] = $object_phid;
4178 $template
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.
4205 * @task workers
4207 protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
4208 return $object;
4213 * Convert the editor state to a serializable dictionary which can be passed
4214 * to a worker.
4216 * This data will be loaded with @{method:loadWorkerState} in the worker.
4218 * @return dict<string, wild> Serializable editor state.
4219 * @task workers
4221 private function getWorkerState() {
4222 $state = array();
4223 foreach ($this->getAutomaticStateProperties() as $property) {
4224 $state[$property] = $this->$property;
4227 $custom_state = $this->getCustomWorkerState();
4228 $custom_encoding = $this->getCustomWorkerStateEncoding();
4230 $state += array(
4231 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4232 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4233 'custom.encoding' => $custom_encoding,
4236 return $state;
4241 * Hook; return custom properties which need to be passed to workers.
4243 * @return dict<string, wild> Custom properties.
4244 * @task workers
4246 protected function getCustomWorkerState() {
4247 return array();
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
4256 * JSON encoding.
4258 * @return dict<string, string> Property encodings.
4259 * @task workers
4261 protected function getCustomWorkerStateEncoding() {
4262 return array();
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}.
4272 * @return this
4273 * @task workers
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);
4289 return $this;
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}.
4299 * @return this
4300 * @task workers
4302 protected function loadCustomWorkerState(array $state) {
4303 return $this;
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
4312 * the worker.
4314 * @return list<string> List of properties.
4315 * @task workers
4317 private function getAutomaticStateProperties() {
4318 return array(
4319 'parentMessageID',
4320 'isNewObject',
4321 'heraldEmailPHIDs',
4322 'heraldForcedEmailPHIDs',
4323 'heraldHeader',
4324 'mailToPHIDs',
4325 'mailCCPHIDs',
4326 'feedNotifyPHIDs',
4327 'feedRelatedPHIDs',
4328 'feedShouldPublish',
4329 'mailShouldSend',
4330 'mustEncrypt',
4331 'mailStamps',
4332 'mailUnexpandablePHIDs',
4333 'mailMutedPHIDs',
4334 'webhookMap',
4335 'silent',
4336 'sendHistory',
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.
4348 * @task workers
4350 private function encodeStateForStorage(
4351 array $state,
4352 array $encodings) {
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(
4365 pht(
4366 'Failed to serialize() value for key "%s".',
4367 $key));
4370 $value = base64_encode($value);
4371 if ($value === false) {
4372 throw new Exception(
4373 pht(
4374 'Failed to base64 encode value for key "%s".',
4375 $key));
4377 break;
4379 $state[$key] = $value;
4382 return $state;
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.
4394 * @task workers
4396 private function decodeStateFromStorage(
4397 array $state,
4398 array $encodings) {
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(
4407 pht(
4408 'Failed to base64_decode() value for key "%s".',
4409 $key));
4412 $value = unserialize($value);
4413 break;
4415 $state[$key] = $value;
4418 return $state;
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) {
4434 if (!$phids) {
4435 return array();
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
4440 // values).
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
4446 // last one.
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))
4462 ->execute();
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);
4475 if (!$project) {
4476 continue;
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
4499 // have conflicts.
4500 foreach ($phids as $phid => $ignored) {
4501 $project = idx($projects, $phid);
4503 if (!$project) {
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.
4506 continue;
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]);
4515 continue;
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]);
4525 continue;
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]);
4536 continue 2;
4541 return $phids;
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()) {
4552 return;
4555 // If the object is a file itself, scramble it.
4556 if ($object instanceof PhabricatorFile) {
4557 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4558 $object->scrambleSecret();
4559 $object->save();
4563 $phid = $object->getPHID();
4565 $attached_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
4566 $phid,
4567 PhabricatorObjectHasFileEdgeType::EDGECONST);
4568 if (!$attached_phids) {
4569 return;
4572 $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
4574 $files = id(new PhabricatorFileQuery())
4575 ->setViewer($omnipotent_viewer)
4576 ->withPHIDs($attached_phids)
4577 ->execute();
4578 foreach ($files as $file) {
4579 $view_policy = $file->getViewPolicy();
4580 if ($this->shouldScramblePolicy($view_policy)) {
4581 $file->scrambleSecret();
4582 $file->save();
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
4592 * objects change.
4594 private function shouldScramblePolicy($policy) {
4595 switch ($policy) {
4596 case PhabricatorPolicies::POLICY_PUBLIC:
4597 case PhabricatorPolicies::POLICY_USER:
4598 return false;
4601 return true;
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) {
4611 return;
4614 // TODO: This should likely be some future WorkboardInterface.
4615 $appears_on_workboards = ($object instanceof ManiphestTask);
4616 if (!$appears_on_workboards) {
4617 return;
4620 $removed_phids = array_keys(array_diff_key($old, $new));
4621 if (!$removed_phids) {
4622 return;
4625 // Find any proxy columns for the removed projects.
4626 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4627 ->setViewer(PhabricatorUser::getOmnipotentUser())
4628 ->withProxyPHIDs($removed_phids)
4629 ->execute();
4630 if (!$proxy_columns) {
4631 return array();
4634 $proxy_phids = mpull($proxy_columns, 'getPHID');
4636 $position_table = new PhabricatorProjectColumnPosition();
4637 $conn_w = $position_table->establishConnection('w');
4639 queryfx(
4640 $conn_w,
4641 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4642 $position_table->getTableName(),
4643 $object->getPHID(),
4644 $proxy_phids);
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;
4657 } else {
4658 $xtypes = array();
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;
4685 return $this;
4688 private function flushTransactionQueue($object) {
4689 if (!$this->transactionQueue) {
4690 return;
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;
4708 return $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();
4732 return $editor;
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(
4751 pht(
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),
4756 $key));
4759 $stamp->setViewer($actor);
4761 $templates[$key] = $stamp;
4765 return $templates;
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(
4775 pht(
4776 'Editor ("%s") has no mail stamp template with provided key ("%s").',
4777 get_class($this),
4778 $key));
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);
4790 $stamps = array();
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)
4806 ->setViewer($actor)
4807 ->setEditor($this);
4809 if ($extension->supportsObject($object)) {
4810 $extensions[$key] = $extension;
4814 return $extensions;
4817 protected function newAuxiliaryMail($object, array $xactions) {
4818 return array();
4821 private function generateMailStamps($object, $data) {
4822 if (!$data || !is_array($data)) {
4823 return null;
4826 $templates = $this->newMailStampTemplates($object);
4827 foreach ($data as $spec) {
4828 if (!is_array($spec)) {
4829 continue;
4832 $key = idx($spec, 'key');
4833 if (!isset($templates[$key])) {
4834 continue;
4837 $type = idx($spec, 'type');
4838 if ($templates[$key]->getStampType() !== $type) {
4839 continue;
4842 $value = idx($spec, 'value');
4843 $templates[$key]->setValueFromDictionary($value);
4846 $results = array();
4847 foreach ($templates as $template) {
4848 $value = $template->getValueForRendering();
4850 $rendered = $template->renderStamps($value);
4851 if ($rendered === null) {
4852 continue;
4855 $rendered = (array)$rendered;
4856 foreach ($rendered as $stamp) {
4857 $results[] = $stamp;
4861 natcasesort($results);
4863 return $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)) {
4877 return;
4880 $this->oldTo = $this->getMailTo($object);
4881 $this->oldCC = $this->getMailCC($object);
4883 return $this;
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) {
4900 continue;
4903 if (isset($map[$phid])) {
4904 continue;
4907 $this->mailToPHIDs[] = $phid;
4908 $this->mailRemovedPHIDs[] = $phid;
4911 foreach ($this->oldCC as $phid) {
4912 if ($phid === $actor_phid) {
4913 continue;
4916 if (isset($map[$phid])) {
4917 continue;
4920 $this->mailCCPHIDs[] = $phid;
4921 $this->mailRemovedPHIDs[] = $phid;
4924 return $this;
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)
4938 ->withStatuses(
4939 array(
4940 HeraldWebhook::HOOKSTATUS_FIREHOSE,
4942 ->execute();
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
4946 // hook.
4947 $hook_phid = $firehose_hook->getPHID();
4948 $webhook_map[$hook_phid][] = $hook_phid;
4951 if (!$webhook_map) {
4952 return;
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
4957 // visible.
4959 $call_hooks = id(new HeraldWebhookQuery())
4960 ->setViewer($hook_viewer)
4961 ->withPHIDs(array_keys($webhook_map))
4962 ->execute();
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())
4974 ->save();
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)) {
4985 return false;
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()) {
4997 return false;
5000 if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5001 return false;
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()]);
5016 if (!$add) {
5017 return false;
5020 return true;
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);
5030 $xactions = $query
5031 ->setViewer($viewer)
5032 ->withObjectPHIDs(array($object->getPHID()))
5033 ->execute();
5034 $xactions = array_reverse($xactions);
5036 $mail_messages = $this->buildMailWithRecipients(
5037 $object,
5038 $xactions,
5039 array($recipient_phid),
5040 array(),
5041 array());
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);
5048 return $mail;
5051 public function newAutomaticInlineTransactions(
5052 PhabricatorLiskDAO $object,
5053 $transaction_type,
5054 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5056 $actor = $this->getActor();
5058 $inlines = id(clone $query_template)
5059 ->setViewer($actor)
5060 ->withObjectPHIDs(array($object->getPHID()))
5061 ->withPublishableComments(true)
5062 ->needAppliedDrafts(true)
5063 ->needReplyToComments(true)
5064 ->execute();
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(
5076 $object,
5077 $query_template);
5079 if ($state_xaction) {
5080 $xactions[] = $state_xaction;
5083 return $xactions;
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();
5108 if (!$inlines) {
5109 return null;
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()) {
5142 return;
5145 $editor_class = get_class($this);
5147 $object_phid = $object->getPHID();
5148 if ($object_phid) {
5149 $workflow_key = sprintf(
5150 'editor(%s).phid(%s)',
5151 $editor_class,
5152 $object_phid);
5153 } else {
5154 $workflow_key = sprintf(
5155 'editor(%s).new()',
5156 $editor_class);
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);
5164 if ($is_conduit) {
5165 throw new Exception(
5166 pht(
5167 'This transaction group requires MFA to apply, but you can not '.
5168 'provide an MFA response via Conduit. Edit this object via the '.
5169 'web UI.'));
5170 } else {
5171 throw new Exception(
5172 pht(
5173 'This transaction group requires MFA to apply, but the Editor was '.
5174 'not configured with a Request. This workflow can not perform an '.
5175 'MFA check.'));
5179 $cancel_uri = $this->getCancelURI();
5180 if ($cancel_uri === null) {
5181 throw new Exception(
5182 pht(
5183 'This transaction group requires MFA to apply, but the Editor was '.
5184 'not configured with a Cancel URI. This workflow can not perform '.
5185 'an MFA check.'));
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,
5201 array $xactions) {
5203 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
5204 if ($has_engine) {
5205 $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
5206 ->setViewer($this->getActor());
5207 $require_mfa = $engine->shouldRequireMFA();
5208 $try_mfa = $engine->shouldTryMFA();
5209 } else {
5210 $require_mfa = false;
5211 $try_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()) {
5218 return $xactions;
5221 if (!$require_mfa) {
5222 // If the object hasn't already opted into MFA, see if any of the
5223 // transactions want it.
5224 if (!$try_mfa) {
5225 foreach ($xactions as $xaction) {
5226 $type = $xaction->getTransactionType();
5228 $xtype = $this->getModularTransactionType($type);
5229 if ($xtype) {
5230 $xtype = clone $xtype;
5231 $xtype->setStorage($xaction);
5232 if ($xtype->shouldTryMFA($object, $xaction)) {
5233 $try_mfa = true;
5234 break;
5240 if ($try_mfa) {
5241 $this->setShouldRequireMFA(true);
5244 return $xactions;
5247 $type_mfa = PhabricatorTransactions::TYPE_MFA;
5249 $has_mfa = false;
5250 foreach ($xactions as $xaction) {
5251 if ($xaction->getTransactionType() === $type_mfa) {
5252 $has_mfa = true;
5253 break;
5257 if ($has_mfa) {
5258 return $xactions;
5261 $template = $object->getApplicationTransactionTemplate();
5263 $mfa_xaction = id(clone $template)
5264 ->setTransactionType($type_mfa)
5265 ->setNewValue(true);
5267 array_unshift($xactions, $mfa_xaction);
5269 return $xactions;
5272 private function getTitleForTextMail(
5273 PhabricatorApplicationTransaction $xaction) {
5274 $type = $xaction->getTransactionType();
5276 $xtype = $this->getModularTransactionType($type);
5277 if ($xtype) {
5278 $xtype = clone $xtype;
5279 $xtype->setStorage($xaction);
5280 $comment = $xtype->getTitleForTextMail();
5281 if ($comment !== false) {
5282 return $comment;
5286 return $xaction->getTitleForTextMail();
5289 private function getTitleForHTMLMail(
5290 PhabricatorApplicationTransaction $xaction) {
5291 $type = $xaction->getTransactionType();
5293 $xtype = $this->getModularTransactionType($type);
5294 if ($xtype) {
5295 $xtype = clone $xtype;
5296 $xtype->setStorage($xaction);
5297 $comment = $xtype->getTitleForHTMLMail();
5298 if ($comment !== false) {
5299 return $comment;
5303 return $xaction->getTitleForHTMLMail();
5307 private function getBodyForTextMail(
5308 PhabricatorApplicationTransaction $xaction) {
5309 $type = $xaction->getTransactionType();
5311 $xtype = $this->getModularTransactionType($type);
5312 if ($xtype) {
5313 $xtype = clone $xtype;
5314 $xtype->setStorage($xaction);
5315 $comment = $xtype->getBodyForTextMail();
5316 if ($comment !== false) {
5317 return $comment;
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()) {
5344 return false;
5347 // For now, all edits other than subscribes always override locks.
5348 $type = $xaction->getTransactionType();
5349 if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5350 return true;
5353 // Subscribes override locks if they affect any users other than the
5354 // acting user.
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);
5363 $all = $add + $rem;
5364 foreach ($all as $phid) {
5365 if ($phid !== $acting_phid) {
5366 return true;
5370 return false;
5374 /* -( Extensions )--------------------------------------------------------- */
5377 private function validateTransactionsWithExtensions(
5378 PhabricatorLiskDAO $object,
5379 array $xactions) {
5380 $errors = array();
5382 $extensions = $this->getEditorExtensions();
5383 foreach ($extensions as $extension) {
5384 $extension_errors = $extension
5385 ->setObject($object)
5386 ->validateTransactions($object, $xactions);
5388 assert_instances_of(
5389 $extension_errors,
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)
5413 ->setViewer($actor)
5414 ->setEditor($this)
5415 ->setObject($object);
5417 if (!$extension->supportsObject($this, $object)) {
5418 unset($extensions[$key]);
5419 continue;
5422 $extensions[$key] = $extension;
5425 return $extensions;