Correct a parameter order swap in "diffusion.historyquery" for Mercurial
[phabricator.git] / src / applications / transactions / editor / PhabricatorApplicationTransactionEditor.php
blob437a7a1437388ce0ddb3c51d71763f9ebfbac0f7
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_SPACE:
432 if ($this->getIsNewObject()) {
433 return null;
436 $space_phid = $object->getSpacePHID();
437 if ($space_phid === null) {
438 $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
439 if ($default_space) {
440 $space_phid = $default_space->getPHID();
444 return $space_phid;
445 case PhabricatorTransactions::TYPE_EDGE:
446 $edge_type = $xaction->getMetadataValue('edge:type');
447 if (!$edge_type) {
448 throw new Exception(
449 pht(
450 "Edge transaction has no '%s'!",
451 'edge:type'));
454 // See T13082. If this is an inverse edit, the parent editor has
455 // already populated the transaction values correctly.
456 if ($this->getIsInverseEdgeEditor()) {
457 return $xaction->getOldValue();
460 $old_edges = array();
461 if ($object->getPHID()) {
462 $edge_src = $object->getPHID();
464 $old_edges = id(new PhabricatorEdgeQuery())
465 ->withSourcePHIDs(array($edge_src))
466 ->withEdgeTypes(array($edge_type))
467 ->needEdgeData(true)
468 ->execute();
470 $old_edges = $old_edges[$edge_src][$edge_type];
472 return $old_edges;
473 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
474 // NOTE: Custom fields have their old value pre-populated when they are
475 // built by PhabricatorCustomFieldList.
476 return $xaction->getOldValue();
477 case PhabricatorTransactions::TYPE_COMMENT:
478 return null;
479 default:
480 return $this->getCustomTransactionOldValue($object, $xaction);
484 private function getTransactionNewValue(
485 PhabricatorLiskDAO $object,
486 PhabricatorApplicationTransaction $xaction) {
488 $type = $xaction->getTransactionType();
490 $xtype = $this->getModularTransactionType($type);
491 if ($xtype) {
492 $xtype = clone $xtype;
493 $xtype->setStorage($xaction);
494 return $xtype->generateNewValue($object, $xaction->getNewValue());
497 switch ($type) {
498 case PhabricatorTransactions::TYPE_CREATE:
499 return null;
500 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
501 return $this->getPHIDTransactionNewValue($xaction);
502 case PhabricatorTransactions::TYPE_VIEW_POLICY:
503 case PhabricatorTransactions::TYPE_EDIT_POLICY:
504 case PhabricatorTransactions::TYPE_JOIN_POLICY:
505 case PhabricatorTransactions::TYPE_TOKEN:
506 case PhabricatorTransactions::TYPE_INLINESTATE:
507 case PhabricatorTransactions::TYPE_SUBTYPE:
508 case PhabricatorTransactions::TYPE_HISTORY:
509 return $xaction->getNewValue();
510 case PhabricatorTransactions::TYPE_MFA:
511 return true;
512 case PhabricatorTransactions::TYPE_SPACE:
513 $space_phid = $xaction->getNewValue();
514 if (!strlen($space_phid)) {
515 // If an install has no Spaces or the Spaces controls are not visible
516 // to the viewer, we might end up with the empty string here instead
517 // of a strict `null`, because some controller just used `getStr()`
518 // to read the space PHID from the request.
519 // Just make this work like callers might reasonably expect so we
520 // don't need to handle this specially in every EditController.
521 return $this->getActor()->getDefaultSpacePHID();
522 } else {
523 return $space_phid;
525 case PhabricatorTransactions::TYPE_EDGE:
526 // See T13082. If this is an inverse edit, the parent editor has
527 // already populated appropriate transaction values.
528 if ($this->getIsInverseEdgeEditor()) {
529 return $xaction->getNewValue();
532 $new_value = $this->getEdgeTransactionNewValue($xaction);
534 $edge_type = $xaction->getMetadataValue('edge:type');
535 $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
536 if ($edge_type == $type_project) {
537 $new_value = $this->applyProjectConflictRules($new_value);
540 return $new_value;
541 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
542 $field = $this->getCustomFieldForTransaction($object, $xaction);
543 return $field->getNewValueFromApplicationTransactions($xaction);
544 case PhabricatorTransactions::TYPE_COMMENT:
545 return null;
546 default:
547 return $this->getCustomTransactionNewValue($object, $xaction);
551 protected function getCustomTransactionOldValue(
552 PhabricatorLiskDAO $object,
553 PhabricatorApplicationTransaction $xaction) {
554 throw new Exception(pht('Capability not supported!'));
557 protected function getCustomTransactionNewValue(
558 PhabricatorLiskDAO $object,
559 PhabricatorApplicationTransaction $xaction) {
560 throw new Exception(pht('Capability not supported!'));
563 protected function transactionHasEffect(
564 PhabricatorLiskDAO $object,
565 PhabricatorApplicationTransaction $xaction) {
567 switch ($xaction->getTransactionType()) {
568 case PhabricatorTransactions::TYPE_CREATE:
569 case PhabricatorTransactions::TYPE_HISTORY:
570 return true;
571 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
572 $field = $this->getCustomFieldForTransaction($object, $xaction);
573 return $field->getApplicationTransactionHasEffect($xaction);
574 case PhabricatorTransactions::TYPE_EDGE:
575 // A straight value comparison here doesn't always get the right
576 // result, because newly added edges aren't fully populated. Instead,
577 // compare the changes in a more granular way.
578 $old = $xaction->getOldValue();
579 $new = $xaction->getNewValue();
581 $old_dst = array_keys($old);
582 $new_dst = array_keys($new);
584 // NOTE: For now, we don't consider edge reordering to be a change.
585 // We have very few order-dependent edges and effectively no order
586 // oriented UI. This might change in the future.
587 sort($old_dst);
588 sort($new_dst);
590 if ($old_dst !== $new_dst) {
591 // We've added or removed edges, so this transaction definitely
592 // has an effect.
593 return true;
596 // We haven't added or removed edges, but we might have changed
597 // edge data.
598 foreach ($old as $key => $old_value) {
599 $new_value = $new[$key];
600 if ($old_value['data'] !== $new_value['data']) {
601 return true;
605 return false;
608 $type = $xaction->getTransactionType();
609 $xtype = $this->getModularTransactionType($type);
610 if ($xtype) {
611 return $xtype->getTransactionHasEffect(
612 $object,
613 $xaction->getOldValue(),
614 $xaction->getNewValue());
617 if ($xaction->hasComment()) {
618 return true;
621 return ($xaction->getOldValue() !== $xaction->getNewValue());
624 protected function shouldApplyInitialEffects(
625 PhabricatorLiskDAO $object,
626 array $xactions) {
627 return false;
630 protected function applyInitialEffects(
631 PhabricatorLiskDAO $object,
632 array $xactions) {
633 throw new PhutilMethodNotImplementedException();
636 private function applyInternalEffects(
637 PhabricatorLiskDAO $object,
638 PhabricatorApplicationTransaction $xaction) {
640 $type = $xaction->getTransactionType();
642 $xtype = $this->getModularTransactionType($type);
643 if ($xtype) {
644 $xtype = clone $xtype;
645 $xtype->setStorage($xaction);
646 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
649 switch ($type) {
650 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
651 $field = $this->getCustomFieldForTransaction($object, $xaction);
652 return $field->applyApplicationTransactionInternalEffects($xaction);
653 case PhabricatorTransactions::TYPE_CREATE:
654 case PhabricatorTransactions::TYPE_HISTORY:
655 case PhabricatorTransactions::TYPE_SUBTYPE:
656 case PhabricatorTransactions::TYPE_MFA:
657 case PhabricatorTransactions::TYPE_TOKEN:
658 case PhabricatorTransactions::TYPE_VIEW_POLICY:
659 case PhabricatorTransactions::TYPE_EDIT_POLICY:
660 case PhabricatorTransactions::TYPE_JOIN_POLICY:
661 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
662 case PhabricatorTransactions::TYPE_INLINESTATE:
663 case PhabricatorTransactions::TYPE_EDGE:
664 case PhabricatorTransactions::TYPE_SPACE:
665 case PhabricatorTransactions::TYPE_COMMENT:
666 return $this->applyBuiltinInternalTransaction($object, $xaction);
669 return $this->applyCustomInternalTransaction($object, $xaction);
672 private function applyExternalEffects(
673 PhabricatorLiskDAO $object,
674 PhabricatorApplicationTransaction $xaction) {
676 $type = $xaction->getTransactionType();
678 $xtype = $this->getModularTransactionType($type);
679 if ($xtype) {
680 $xtype = clone $xtype;
681 $xtype->setStorage($xaction);
682 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
685 switch ($type) {
686 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
687 $subeditor = id(new PhabricatorSubscriptionsEditor())
688 ->setObject($object)
689 ->setActor($this->requireActor());
691 $old_map = array_fuse($xaction->getOldValue());
692 $new_map = array_fuse($xaction->getNewValue());
694 $subeditor->unsubscribe(
695 array_keys(
696 array_diff_key($old_map, $new_map)));
698 $subeditor->subscribeExplicit(
699 array_keys(
700 array_diff_key($new_map, $old_map)));
702 $subeditor->save();
704 // for the rest of these edits, subscribers should include those just
705 // added as well as those just removed.
706 $subscribers = array_unique(array_merge(
707 $this->subscribers,
708 $xaction->getOldValue(),
709 $xaction->getNewValue()));
710 $this->subscribers = $subscribers;
711 return $this->applyBuiltinExternalTransaction($object, $xaction);
713 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
714 $field = $this->getCustomFieldForTransaction($object, $xaction);
715 return $field->applyApplicationTransactionExternalEffects($xaction);
716 case PhabricatorTransactions::TYPE_CREATE:
717 case PhabricatorTransactions::TYPE_HISTORY:
718 case PhabricatorTransactions::TYPE_SUBTYPE:
719 case PhabricatorTransactions::TYPE_MFA:
720 case PhabricatorTransactions::TYPE_EDGE:
721 case PhabricatorTransactions::TYPE_TOKEN:
722 case PhabricatorTransactions::TYPE_VIEW_POLICY:
723 case PhabricatorTransactions::TYPE_EDIT_POLICY:
724 case PhabricatorTransactions::TYPE_JOIN_POLICY:
725 case PhabricatorTransactions::TYPE_INLINESTATE:
726 case PhabricatorTransactions::TYPE_SPACE:
727 case PhabricatorTransactions::TYPE_COMMENT:
728 return $this->applyBuiltinExternalTransaction($object, $xaction);
731 return $this->applyCustomExternalTransaction($object, $xaction);
734 protected function applyCustomInternalTransaction(
735 PhabricatorLiskDAO $object,
736 PhabricatorApplicationTransaction $xaction) {
737 $type = $xaction->getTransactionType();
738 throw new Exception(
739 pht(
740 "Transaction type '%s' is missing an internal apply implementation!",
741 $type));
744 protected function applyCustomExternalTransaction(
745 PhabricatorLiskDAO $object,
746 PhabricatorApplicationTransaction $xaction) {
747 $type = $xaction->getTransactionType();
748 throw new Exception(
749 pht(
750 "Transaction type '%s' is missing an external apply implementation!",
751 $type));
755 * @{class:PhabricatorTransactions} provides many built-in transactions
756 * which should not require much - if any - code in specific applications.
758 * This method is a hook for the exceedingly-rare cases where you may need
759 * to do **additional** work for built-in transactions. Developers should
760 * extend this method, making sure to return the parent implementation
761 * regardless of handling any transactions.
763 * See also @{method:applyBuiltinExternalTransaction}.
765 protected function applyBuiltinInternalTransaction(
766 PhabricatorLiskDAO $object,
767 PhabricatorApplicationTransaction $xaction) {
769 switch ($xaction->getTransactionType()) {
770 case PhabricatorTransactions::TYPE_VIEW_POLICY:
771 $object->setViewPolicy($xaction->getNewValue());
772 break;
773 case PhabricatorTransactions::TYPE_EDIT_POLICY:
774 $object->setEditPolicy($xaction->getNewValue());
775 break;
776 case PhabricatorTransactions::TYPE_JOIN_POLICY:
777 $object->setJoinPolicy($xaction->getNewValue());
778 break;
779 case PhabricatorTransactions::TYPE_SPACE:
780 $object->setSpacePHID($xaction->getNewValue());
781 break;
782 case PhabricatorTransactions::TYPE_SUBTYPE:
783 $object->setEditEngineSubtype($xaction->getNewValue());
784 break;
789 * See @{method::applyBuiltinInternalTransaction}.
791 protected function applyBuiltinExternalTransaction(
792 PhabricatorLiskDAO $object,
793 PhabricatorApplicationTransaction $xaction) {
795 switch ($xaction->getTransactionType()) {
796 case PhabricatorTransactions::TYPE_EDGE:
797 if ($this->getIsInverseEdgeEditor()) {
798 // If we're writing an inverse edge transaction, don't actually
799 // do anything. The initiating editor on the other side of the
800 // transaction will take care of the edge writes.
801 break;
804 $old = $xaction->getOldValue();
805 $new = $xaction->getNewValue();
806 $src = $object->getPHID();
807 $const = $xaction->getMetadataValue('edge:type');
809 foreach ($new as $dst_phid => $edge) {
810 $new[$dst_phid]['src'] = $src;
813 $editor = new PhabricatorEdgeEditor();
815 foreach ($old as $dst_phid => $edge) {
816 if (!empty($new[$dst_phid])) {
817 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
818 continue;
821 $editor->removeEdge($src, $const, $dst_phid);
824 foreach ($new as $dst_phid => $edge) {
825 if (!empty($old[$dst_phid])) {
826 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
827 continue;
831 $data = array(
832 'data' => $edge['data'],
835 $editor->addEdge($src, $const, $dst_phid, $data);
838 $editor->save();
840 $this->updateWorkboardColumns($object, $const, $old, $new);
841 break;
842 case PhabricatorTransactions::TYPE_VIEW_POLICY:
843 case PhabricatorTransactions::TYPE_SPACE:
844 $this->scrambleFileSecrets($object);
845 break;
846 case PhabricatorTransactions::TYPE_HISTORY:
847 $this->sendHistory = true;
848 break;
853 * Fill in a transaction's common values, like author and content source.
855 protected function populateTransaction(
856 PhabricatorLiskDAO $object,
857 PhabricatorApplicationTransaction $xaction) {
859 $actor = $this->getActor();
861 // TODO: This needs to be more sophisticated once we have meta-policies.
862 $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
864 if ($actor->isOmnipotent()) {
865 $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
866 } else {
867 $xaction->setEditPolicy($this->getActingAsPHID());
870 // If the transaction already has an explicit author PHID, allow it to
871 // stand. This is used by applications like Owners that hook into the
872 // post-apply change pipeline.
873 if (!$xaction->getAuthorPHID()) {
874 $xaction->setAuthorPHID($this->getActingAsPHID());
877 $xaction->setContentSource($this->getContentSource());
878 $xaction->attachViewer($actor);
879 $xaction->attachObject($object);
881 if ($object->getPHID()) {
882 $xaction->setObjectPHID($object->getPHID());
885 if ($this->getIsSilent()) {
886 $xaction->setIsSilentTransaction(true);
889 return $xaction;
892 protected function didApplyInternalEffects(
893 PhabricatorLiskDAO $object,
894 array $xactions) {
895 return $xactions;
898 protected function applyFinalEffects(
899 PhabricatorLiskDAO $object,
900 array $xactions) {
901 return $xactions;
904 final protected function didCommitTransactions(
905 PhabricatorLiskDAO $object,
906 array $xactions) {
908 foreach ($xactions as $xaction) {
909 $type = $xaction->getTransactionType();
911 // See T13082. When we're writing edges that imply corresponding inverse
912 // transactions, apply those inverse transactions now. We have to wait
913 // until the object we're editing (with this editor) has committed its
914 // transactions to do this. If we don't, the inverse editor may race,
915 // build a mail before we actually commit this object, and render "alice
916 // added an edge: Unknown Object".
918 if ($type === PhabricatorTransactions::TYPE_EDGE) {
919 // Don't do anything if we're already an inverse edge editor.
920 if ($this->getIsInverseEdgeEditor()) {
921 continue;
924 $edge_const = $xaction->getMetadataValue('edge:type');
925 $edge_type = PhabricatorEdgeType::getByConstant($edge_const);
926 if ($edge_type->shouldWriteInverseTransactions()) {
927 $this->applyInverseEdgeTransactions(
928 $object,
929 $xaction,
930 $edge_type->getInverseEdgeConstant());
932 continue;
935 $xtype = $this->getModularTransactionType($type);
936 if (!$xtype) {
937 continue;
940 $xtype = clone $xtype;
941 $xtype->setStorage($xaction);
942 $xtype->didCommitTransaction($object, $xaction->getNewValue());
946 public function setContentSource(PhabricatorContentSource $content_source) {
947 $this->contentSource = $content_source;
948 return $this;
951 public function setContentSourceFromRequest(AphrontRequest $request) {
952 $this->setRequest($request);
953 return $this->setContentSource(
954 PhabricatorContentSource::newFromRequest($request));
957 public function getContentSource() {
958 return $this->contentSource;
961 public function setRequest(AphrontRequest $request) {
962 $this->request = $request;
963 return $this;
966 public function getRequest() {
967 return $this->request;
970 public function setCancelURI($cancel_uri) {
971 $this->cancelURI = $cancel_uri;
972 return $this;
975 public function getCancelURI() {
976 return $this->cancelURI;
979 protected function getTransactionGroupID() {
980 if ($this->transactionGroupID === null) {
981 $this->transactionGroupID = Filesystem::readRandomCharacters(32);
984 return $this->transactionGroupID;
987 final public function applyTransactions(
988 PhabricatorLiskDAO $object,
989 array $xactions) {
991 $is_new = ($object->getID() === null);
992 $this->isNewObject = $is_new;
994 $is_preview = $this->getIsPreview();
995 $read_locking = false;
996 $transaction_open = false;
998 // If we're attempting to apply transactions, lock and reload the object
999 // before we go anywhere. If we don't do this at the very beginning, we
1000 // may be looking at an older version of the object when we populate and
1001 // filter the transactions. See PHI1165 for an example.
1003 if (!$is_preview) {
1004 if (!$is_new) {
1005 $this->buildOldRecipientLists($object, $xactions);
1007 $object->openTransaction();
1008 $transaction_open = true;
1010 $object->beginReadLocking();
1011 $read_locking = true;
1013 $object->reload();
1017 try {
1018 $this->object = $object;
1019 $this->xactions = $xactions;
1021 $this->validateEditParameters($object, $xactions);
1022 $xactions = $this->newMFATransactions($object, $xactions);
1024 $actor = $this->requireActor();
1026 // NOTE: Some transaction expansion requires that the edited object be
1027 // attached.
1028 foreach ($xactions as $xaction) {
1029 $xaction->attachObject($object);
1030 $xaction->attachViewer($actor);
1033 $xactions = $this->expandTransactions($object, $xactions);
1034 $xactions = $this->expandSupportTransactions($object, $xactions);
1035 $xactions = $this->combineTransactions($xactions);
1037 foreach ($xactions as $xaction) {
1038 $xaction = $this->populateTransaction($object, $xaction);
1041 if (!$is_preview) {
1042 $errors = array();
1043 $type_map = mgroup($xactions, 'getTransactionType');
1044 foreach ($this->getTransactionTypes() as $type) {
1045 $type_xactions = idx($type_map, $type, array());
1046 $errors[] = $this->validateTransaction(
1047 $object,
1048 $type,
1049 $type_xactions);
1052 $errors[] = $this->validateAllTransactions($object, $xactions);
1053 $errors[] = $this->validateTransactionsWithExtensions(
1054 $object,
1055 $xactions);
1056 $errors = array_mergev($errors);
1058 $continue_on_missing = $this->getContinueOnMissingFields();
1059 foreach ($errors as $key => $error) {
1060 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1061 unset($errors[$key]);
1065 if ($errors) {
1066 throw new PhabricatorApplicationTransactionValidationException(
1067 $errors);
1070 if ($this->raiseWarnings) {
1071 $warnings = array();
1072 foreach ($xactions as $xaction) {
1073 if ($this->hasWarnings($object, $xaction)) {
1074 $warnings[] = $xaction;
1077 if ($warnings) {
1078 throw new PhabricatorApplicationTransactionWarningException(
1079 $warnings);
1084 foreach ($xactions as $xaction) {
1085 $this->adjustTransactionValues($object, $xaction);
1088 // Now that we've merged and combined transactions, check for required
1089 // capabilities. Note that we're doing this before filtering
1090 // transactions: if you try to apply an edit which you do not have
1091 // permission to apply, we want to give you a permissions error even
1092 // if the edit would have no effect.
1093 $this->applyCapabilityChecks($object, $xactions);
1095 $xactions = $this->filterTransactions($object, $xactions);
1097 if (!$is_preview) {
1098 $this->hasRequiredMFA = true;
1099 if ($this->getShouldRequireMFA()) {
1100 $this->requireMFA($object, $xactions);
1103 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1104 if (!$transaction_open) {
1105 $object->openTransaction();
1106 $transaction_open = true;
1111 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1112 $this->applyInitialEffects($object, $xactions);
1115 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1116 // figure this out instead.
1117 $mark_as_create = false;
1118 $create_type = PhabricatorTransactions::TYPE_CREATE;
1119 foreach ($xactions as $xaction) {
1120 if ($xaction->getTransactionType() == $create_type) {
1121 $mark_as_create = true;
1125 if ($mark_as_create) {
1126 foreach ($xactions as $xaction) {
1127 $xaction->setIsCreateTransaction(true);
1131 $xactions = $this->sortTransactions($xactions);
1132 $file_phids = $this->extractFilePHIDs($object, $xactions);
1134 if ($is_preview) {
1135 $this->loadHandles($xactions);
1136 return $xactions;
1139 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1140 ->setActor($actor)
1141 ->setActingAsPHID($this->getActingAsPHID())
1142 ->setContentSource($this->getContentSource())
1143 ->setIsNewComment(true);
1145 if (!$transaction_open) {
1146 $object->openTransaction();
1147 $transaction_open = true;
1150 // We can technically test any object for CAN_INTERACT, but we can
1151 // run into some issues in doing so (for example, in project unit tests).
1152 // For now, only test for CAN_INTERACT if the object is explicitly a
1153 // lockable object.
1155 $was_locked = false;
1156 if ($object instanceof PhabricatorEditEngineLockableInterface) {
1157 $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object);
1160 foreach ($xactions as $xaction) {
1161 $this->applyInternalEffects($object, $xaction);
1164 $xactions = $this->didApplyInternalEffects($object, $xactions);
1166 try {
1167 $object->save();
1168 } catch (AphrontDuplicateKeyQueryException $ex) {
1169 // This callback has an opportunity to throw a better exception,
1170 // so execution may end here.
1171 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1173 throw $ex;
1176 $group_id = $this->getTransactionGroupID();
1178 foreach ($xactions as $xaction) {
1179 if ($was_locked) {
1180 $is_override = $this->isLockOverrideTransaction($xaction);
1181 if ($is_override) {
1182 $xaction->setIsLockOverrideTransaction(true);
1186 $xaction->setObjectPHID($object->getPHID());
1187 $xaction->setTransactionGroupID($group_id);
1189 if ($xaction->getComment()) {
1190 $xaction->setPHID($xaction->generatePHID());
1191 $comment_editor->applyEdit($xaction, $xaction->getComment());
1192 } else {
1194 // TODO: This is a transitional hack to let us migrate edge
1195 // transactions to a more efficient storage format. For now, we're
1196 // going to write a new slim format to the database but keep the old
1197 // bulky format on the objects so we don't have to upgrade all the
1198 // edit logic to the new format yet. See T13051.
1200 $edge_type = PhabricatorTransactions::TYPE_EDGE;
1201 if ($xaction->getTransactionType() == $edge_type) {
1202 $bulky_old = $xaction->getOldValue();
1203 $bulky_new = $xaction->getNewValue();
1205 $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
1206 $slim_old = $record->getModernOldEdgeTransactionData();
1207 $slim_new = $record->getModernNewEdgeTransactionData();
1209 $xaction->setOldValue($slim_old);
1210 $xaction->setNewValue($slim_new);
1211 $xaction->save();
1213 $xaction->setOldValue($bulky_old);
1214 $xaction->setNewValue($bulky_new);
1215 } else {
1216 $xaction->save();
1221 if ($file_phids) {
1222 $this->attachFiles($object, $file_phids);
1225 foreach ($xactions as $xaction) {
1226 $this->applyExternalEffects($object, $xaction);
1229 $xactions = $this->applyFinalEffects($object, $xactions);
1231 if ($read_locking) {
1232 $object->endReadLocking();
1233 $read_locking = false;
1236 if ($transaction_open) {
1237 $object->saveTransaction();
1238 $transaction_open = false;
1241 $this->didCommitTransactions($object, $xactions);
1243 } catch (Exception $ex) {
1244 if ($read_locking) {
1245 $object->endReadLocking();
1246 $read_locking = false;
1249 if ($transaction_open) {
1250 $object->killTransaction();
1251 $transaction_open = false;
1254 throw $ex;
1257 // If we need to perform cache engine updates, execute them now.
1258 id(new PhabricatorCacheEngine())
1259 ->updateObject($object);
1261 // Now that we've completely applied the core transaction set, try to apply
1262 // Herald rules. Herald rules are allowed to either take direct actions on
1263 // the database (like writing flags), or take indirect actions (like saving
1264 // some targets for CC when we generate mail a little later), or return
1265 // transactions which we'll apply normally using another Editor.
1267 // First, check if *this* is a sub-editor which is itself applying Herald
1268 // rules: if it is, stop working and return so we don't descend into
1269 // madness.
1271 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1272 // using a Herald editor to apply resulting transactions) and then send out
1273 // mail, notifications, and feed updates about everything.
1275 if ($this->getIsHeraldEditor()) {
1276 // We are the Herald editor, so stop work here and return the updated
1277 // transactions.
1278 return $xactions;
1279 } else if ($this->getIsInverseEdgeEditor()) {
1280 // Do not run Herald if we're just recording that this object was
1281 // mentioned elsewhere. This tends to create Herald side effects which
1282 // feel arbitrary, and can really slow down edits which mention a large
1283 // number of other objects. See T13114.
1284 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1285 // We are not the Herald editor, so try to apply Herald rules.
1286 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1288 if ($herald_xactions) {
1289 $xscript_id = $this->getHeraldTranscript()->getID();
1290 foreach ($herald_xactions as $herald_xaction) {
1291 // Don't set a transcript ID if this is a transaction from another
1292 // application or source, like Owners.
1293 if ($herald_xaction->getAuthorPHID()) {
1294 continue;
1297 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1300 // NOTE: We're acting as the omnipotent user because rules deal with
1301 // their own policy issues. We use a synthetic author PHID (the
1302 // Herald application) as the author of record, so that transactions
1303 // will render in a reasonable way ("Herald assigned this task ...").
1304 $herald_actor = PhabricatorUser::getOmnipotentUser();
1305 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1307 // TODO: It would be nice to give transactions a more specific source
1308 // which points at the rule which generated them. You can figure this
1309 // out from transcripts, but it would be cleaner if you didn't have to.
1311 $herald_source = PhabricatorContentSource::newForSource(
1312 PhabricatorHeraldContentSource::SOURCECONST);
1314 $herald_editor = $this->newEditorCopy()
1315 ->setContinueOnNoEffect(true)
1316 ->setContinueOnMissingFields(true)
1317 ->setIsHeraldEditor(true)
1318 ->setActor($herald_actor)
1319 ->setActingAsPHID($herald_phid)
1320 ->setContentSource($herald_source);
1322 $herald_xactions = $herald_editor->applyTransactions(
1323 $object,
1324 $herald_xactions);
1326 // Merge the new transactions into the transaction list: we want to
1327 // send email and publish feed stories about them, too.
1328 $xactions = array_merge($xactions, $herald_xactions);
1331 // If Herald did not generate transactions, we may still need to handle
1332 // "Send an Email" rules.
1333 $adapter = $this->getHeraldAdapter();
1334 $this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
1335 $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
1336 $this->webhookMap = $adapter->getWebhookMap();
1339 $xactions = $this->didApplyTransactions($object, $xactions);
1341 if ($object instanceof PhabricatorCustomFieldInterface) {
1342 // Maybe this makes more sense to move into the search index itself? For
1343 // now I'm putting it here since I think we might end up with things that
1344 // need it to be up to date once the next page loads, but if we don't go
1345 // there we could move it into search once search moves to the daemons.
1347 // It now happens in the search indexer as well, but the search indexer is
1348 // always daemonized, so the logic above still potentially holds. We could
1349 // possibly get rid of this. The major motivation for putting it in the
1350 // indexer was to enable reindexing to work.
1352 $fields = PhabricatorCustomField::getObjectFields(
1353 $object,
1354 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1355 $fields->readFieldsFromStorage($object);
1356 $fields->rebuildIndexes($object);
1359 $herald_xscript = $this->getHeraldTranscript();
1360 if ($herald_xscript) {
1361 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1362 $herald_header = HeraldTranscript::saveXHeraldRulesHeader(
1363 $object->getPHID(),
1364 $herald_header);
1365 } else {
1366 $herald_header = HeraldTranscript::loadXHeraldRulesHeader(
1367 $object->getPHID());
1369 $this->heraldHeader = $herald_header;
1371 // See PHI1134. If we're a subeditor, we don't publish information about
1372 // the edit yet. Our parent editor still needs to finish applying
1373 // transactions and execute Herald, which may change the information we
1374 // publish.
1376 // For example, Herald actions may change the parent object's title or
1377 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1378 // email.
1380 // Once the parent finishes work, it will queue its own publish step and
1381 // then queue publish steps for its children.
1383 $this->publishableObject = $object;
1384 $this->publishableTransactions = $xactions;
1385 if (!$this->parentEditor) {
1386 $this->queuePublishing();
1389 return $xactions;
1392 private function queuePublishing() {
1393 $object = $this->publishableObject;
1394 $xactions = $this->publishableTransactions;
1396 if (!$object) {
1397 throw new Exception(
1398 pht(
1399 'Editor method "queuePublishing()" was called, but no publishable '.
1400 'object is present. This Editor is not ready to publish.'));
1403 // We're going to compute some of the data we'll use to publish these
1404 // transactions here, before queueing a worker.
1406 // Primarily, this is more correct: we want to publish the object as it
1407 // exists right now. The worker may not execute for some time, and we want
1408 // to use the current To/CC list, not respect any changes which may occur
1409 // between now and when the worker executes.
1411 // As a secondary benefit, this tends to reduce the amount of state that
1412 // Editors need to pass into workers.
1413 $object = $this->willPublish($object, $xactions);
1415 if (!$this->getIsSilent()) {
1416 if ($this->shouldSendMail($object, $xactions)) {
1417 $this->mailShouldSend = true;
1418 $this->mailToPHIDs = $this->getMailTo($object);
1419 $this->mailCCPHIDs = $this->getMailCC($object);
1420 $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object);
1422 // Add any recipients who were previously on the notification list
1423 // but were removed by this change.
1424 $this->applyOldRecipientLists();
1426 if ($object instanceof PhabricatorSubscribableInterface) {
1427 $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs(
1428 $object->getPHID(),
1429 PhabricatorMutedByEdgeType::EDGECONST);
1430 } else {
1431 $this->mailMutedPHIDs = array();
1434 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1435 $stamps = $this->newMailStamps($object, $xactions);
1436 foreach ($stamps as $stamp) {
1437 $this->mailStamps[] = $stamp->toDictionary();
1441 if ($this->shouldPublishFeedStory($object, $xactions)) {
1442 $this->feedShouldPublish = true;
1443 $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs(
1444 $object,
1445 $xactions);
1446 $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs(
1447 $object,
1448 $xactions);
1452 PhabricatorWorker::scheduleTask(
1453 'PhabricatorApplicationTransactionPublishWorker',
1454 array(
1455 'objectPHID' => $object->getPHID(),
1456 'actorPHID' => $this->getActingAsPHID(),
1457 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1458 'state' => $this->getWorkerState(),
1460 array(
1461 'objectPHID' => $object->getPHID(),
1462 'priority' => PhabricatorWorker::PRIORITY_ALERTS,
1465 foreach ($this->subEditors as $sub_editor) {
1466 $sub_editor->queuePublishing();
1469 $this->flushTransactionQueue($object);
1472 protected function didCatchDuplicateKeyException(
1473 PhabricatorLiskDAO $object,
1474 array $xactions,
1475 Exception $ex) {
1476 return;
1479 public function publishTransactions(
1480 PhabricatorLiskDAO $object,
1481 array $xactions) {
1483 $this->object = $object;
1484 $this->xactions = $xactions;
1486 // Hook for edges or other properties that may need (re-)loading
1487 $object = $this->willPublish($object, $xactions);
1489 // The object might have changed, so reassign it.
1490 $this->object = $object;
1492 $messages = array();
1493 if ($this->mailShouldSend) {
1494 $messages = $this->buildMail($object, $xactions);
1497 if ($this->supportsSearch()) {
1498 PhabricatorSearchWorker::queueDocumentForIndexing(
1499 $object->getPHID(),
1500 array(
1501 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1505 if ($this->feedShouldPublish) {
1506 $mailed = array();
1507 foreach ($messages as $mail) {
1508 foreach ($mail->buildRecipientList() as $phid) {
1509 $mailed[$phid] = $phid;
1513 $this->publishFeedStory($object, $xactions, $mailed);
1516 if ($this->sendHistory) {
1517 $history_mail = $this->buildHistoryMail($object);
1518 if ($history_mail) {
1519 $messages[] = $history_mail;
1523 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1524 $messages[] = $message;
1527 // NOTE: This actually sends the mail. We do this last to reduce the chance
1528 // that we send some mail, hit an exception, then send the mail again when
1529 // retrying.
1530 foreach ($messages as $mail) {
1531 $mail->save();
1534 $this->queueWebhooks($object, $xactions);
1536 return $xactions;
1539 protected function didApplyTransactions($object, array $xactions) {
1540 // Hook for subclasses.
1541 return $xactions;
1544 private function loadHandles(array $xactions) {
1545 $phids = array();
1546 foreach ($xactions as $key => $xaction) {
1547 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1549 $handles = array();
1550 $merged = array_mergev($phids);
1551 if ($merged) {
1552 $handles = id(new PhabricatorHandleQuery())
1553 ->setViewer($this->requireActor())
1554 ->withPHIDs($merged)
1555 ->execute();
1557 foreach ($xactions as $key => $xaction) {
1558 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1562 private function loadSubscribers(PhabricatorLiskDAO $object) {
1563 if ($object->getPHID() &&
1564 ($object instanceof PhabricatorSubscribableInterface)) {
1565 $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
1566 $object->getPHID());
1567 $this->subscribers = array_fuse($subs);
1568 } else {
1569 $this->subscribers = array();
1573 private function validateEditParameters(
1574 PhabricatorLiskDAO $object,
1575 array $xactions) {
1577 if (!$this->getContentSource()) {
1578 throw new PhutilInvalidStateException('setContentSource');
1581 // Do a bunch of sanity checks that the incoming transactions are fresh.
1582 // They should be unsaved and have only "transactionType" and "newValue"
1583 // set.
1585 $types = array_fill_keys($this->getTransactionTypes(), true);
1587 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1588 foreach ($xactions as $xaction) {
1589 if ($xaction->getPHID() || $xaction->getID()) {
1590 throw new PhabricatorApplicationTransactionStructureException(
1591 $xaction,
1592 pht('You can not apply transactions which already have IDs/PHIDs!'));
1595 if ($xaction->getObjectPHID()) {
1596 throw new PhabricatorApplicationTransactionStructureException(
1597 $xaction,
1598 pht(
1599 'You can not apply transactions which already have %s!',
1600 'objectPHIDs'));
1603 if ($xaction->getCommentPHID()) {
1604 throw new PhabricatorApplicationTransactionStructureException(
1605 $xaction,
1606 pht(
1607 'You can not apply transactions which already have %s!',
1608 'commentPHIDs'));
1611 if ($xaction->getCommentVersion() !== 0) {
1612 throw new PhabricatorApplicationTransactionStructureException(
1613 $xaction,
1614 pht(
1615 'You can not apply transactions which already have '.
1616 'commentVersions!'));
1619 $expect_value = !$xaction->shouldGenerateOldValue();
1620 $has_value = $xaction->hasOldValue();
1622 // See T13082. In the narrow case of applying inverse edge edits, we
1623 // expect the old value to be populated.
1624 if ($this->getIsInverseEdgeEditor()) {
1625 $expect_value = true;
1628 if ($expect_value && !$has_value) {
1629 throw new PhabricatorApplicationTransactionStructureException(
1630 $xaction,
1631 pht(
1632 'This transaction is supposed to have an %s set, but it does not!',
1633 'oldValue'));
1636 if ($has_value && !$expect_value) {
1637 throw new PhabricatorApplicationTransactionStructureException(
1638 $xaction,
1639 pht(
1640 'This transaction should generate its %s automatically, '.
1641 'but has already had one set!',
1642 'oldValue'));
1645 $type = $xaction->getTransactionType();
1646 if (empty($types[$type])) {
1647 throw new PhabricatorApplicationTransactionStructureException(
1648 $xaction,
1649 pht(
1650 'Transaction has type "%s", but that transaction type is not '.
1651 'supported by this editor (%s).',
1652 $type,
1653 get_class($this)));
1658 private function applyCapabilityChecks(
1659 PhabricatorLiskDAO $object,
1660 array $xactions) {
1661 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1663 $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
1665 if ($this->getIsNewObject()) {
1666 // If we're creating a new object, we don't need any special capabilities
1667 // on the object. The actor has already made it through creation checks,
1668 // and objects which haven't been created yet often can not be
1669 // meaningfully tested for capabilities anyway.
1670 $required_capabilities = array();
1671 } else {
1672 if (!$xactions && !$this->xactions) {
1673 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1674 $required_capabilities = array($can_edit);
1675 } else {
1676 $required_capabilities = array();
1678 foreach ($xactions as $xaction) {
1679 $type = $xaction->getTransactionType();
1681 $xtype = $this->getModularTransactionType($type);
1682 if (!$xtype) {
1683 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1684 } else {
1685 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1688 // For convenience, we allow flexibility in the return types because
1689 // it's very unusual that a transaction actually requires multiple
1690 // capability checks.
1691 if ($capabilities === null) {
1692 $capabilities = array();
1693 } else {
1694 $capabilities = (array)$capabilities;
1697 foreach ($capabilities as $capability) {
1698 $required_capabilities[$capability] = $capability;
1704 $required_capabilities = array_fuse($required_capabilities);
1705 $actor = $this->getActor();
1707 if ($required_capabilities) {
1708 id(new PhabricatorPolicyFilter())
1709 ->setViewer($actor)
1710 ->requireCapabilities($required_capabilities)
1711 ->raisePolicyExceptions(true)
1712 ->apply(array($object));
1716 private function getLegacyRequiredCapabilities(
1717 PhabricatorApplicationTransaction $xaction) {
1719 $type = $xaction->getTransactionType();
1720 switch ($type) {
1721 case PhabricatorTransactions::TYPE_COMMENT:
1722 // TODO: Comments technically require CAN_INTERACT, but this is
1723 // currently somewhat special and handled through EditEngine. For now,
1724 // don't enforce it here.
1725 return null;
1726 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
1727 // Anyone can subscribe to or unsubscribe from anything they can view,
1728 // with no other permissions.
1730 $old = array_fuse($xaction->getOldValue());
1731 $new = array_fuse($xaction->getNewValue());
1733 // To remove users other than yourself, you must be able to edit the
1734 // object.
1735 $rem = array_diff_key($old, $new);
1736 foreach ($rem as $phid) {
1737 if ($phid !== $this->getActingAsPHID()) {
1738 return PhabricatorPolicyCapability::CAN_EDIT;
1742 // To add users other than yourself, you must be able to interact.
1743 // This allows "@mentioning" users to work as long as you can comment
1744 // on objects.
1746 // If you can edit, we return that policy instead so that you can
1747 // override a soft lock and still make edits.
1749 // TODO: This is a little bit hacky. We really want to be able to say
1750 // "this requires either interact or edit", but there's currently no
1751 // way to specify this kind of requirement.
1753 $can_edit = PhabricatorPolicyFilter::hasCapability(
1754 $this->getActor(),
1755 $this->object,
1756 PhabricatorPolicyCapability::CAN_EDIT);
1758 $add = array_diff_key($new, $old);
1759 foreach ($add as $phid) {
1760 if ($phid !== $this->getActingAsPHID()) {
1761 if ($can_edit) {
1762 return PhabricatorPolicyCapability::CAN_EDIT;
1763 } else {
1764 return PhabricatorPolicyCapability::CAN_INTERACT;
1769 return null;
1770 case PhabricatorTransactions::TYPE_TOKEN:
1771 // TODO: This technically requires CAN_INTERACT, like comments.
1772 return null;
1773 case PhabricatorTransactions::TYPE_HISTORY:
1774 // This is a special magic transaction which sends you history via
1775 // email and is only partially supported in the upstream. You don't
1776 // need any capabilities to apply it.
1777 return null;
1778 case PhabricatorTransactions::TYPE_MFA:
1779 // Signing a transaction group with MFA does not require permissions
1780 // on its own.
1781 return null;
1782 case PhabricatorTransactions::TYPE_EDGE:
1783 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1784 default:
1785 // For other older (non-modular) transactions, always require exactly
1786 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1787 // capabilities must move to ModularTransactions.
1788 return PhabricatorPolicyCapability::CAN_EDIT;
1792 private function getLegacyRequiredEdgeCapabilities(
1793 PhabricatorApplicationTransaction $xaction) {
1795 // You don't need to have edit permission on an object to mention it or
1796 // otherwise add a relationship pointing toward it.
1797 if ($this->getIsInverseEdgeEditor()) {
1798 return null;
1801 $edge_type = $xaction->getMetadataValue('edge:type');
1802 switch ($edge_type) {
1803 case PhabricatorMutedByEdgeType::EDGECONST:
1804 // At time of writing, you can only write this edge for yourself, so
1805 // you don't need permissions. If you can eventually mute an object
1806 // for other users, this would need to be revisited.
1807 return null;
1808 case PhabricatorProjectSilencedEdgeType::EDGECONST:
1809 // At time of writing, you can only write this edge for yourself, so
1810 // you don't need permissions. If you can eventually silence project
1811 // for other users, this would need to be revisited.
1812 return null;
1813 case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
1814 return null;
1815 case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST:
1816 $old = $xaction->getOldValue();
1817 $new = $xaction->getNewValue();
1819 $add = array_keys(array_diff_key($new, $old));
1820 $rem = array_keys(array_diff_key($old, $new));
1822 $actor_phid = $this->requireActor()->getPHID();
1824 $is_join = (($add === array($actor_phid)) && !$rem);
1825 $is_leave = (($rem === array($actor_phid)) && !$add);
1827 if ($is_join) {
1828 // You need CAN_JOIN to join a project.
1829 return PhabricatorPolicyCapability::CAN_JOIN;
1832 if ($is_leave) {
1833 $object = $this->object;
1834 // You usually don't need any capabilities to leave a project...
1835 if ($object->getIsMembershipLocked()) {
1836 // ...you must be able to edit to leave locked projects, though.
1837 return PhabricatorPolicyCapability::CAN_EDIT;
1838 } else {
1839 return null;
1843 // You need CAN_EDIT to change members other than yourself.
1844 return PhabricatorPolicyCapability::CAN_EDIT;
1845 case PhabricatorObjectHasWatcherEdgeType::EDGECONST:
1846 // See PHI1024. Watching a project does not require CAN_EDIT.
1847 return null;
1848 default:
1849 return PhabricatorPolicyCapability::CAN_EDIT;
1854 private function buildSubscribeTransaction(
1855 PhabricatorLiskDAO $object,
1856 array $xactions,
1857 array $changes) {
1859 if (!($object instanceof PhabricatorSubscribableInterface)) {
1860 return null;
1863 if ($this->shouldEnableMentions($object, $xactions)) {
1864 // Identify newly mentioned users. We ignore users who were previously
1865 // mentioned so that we don't re-subscribe users after an edit of text
1866 // which mentions them.
1867 $old_texts = mpull($changes, 'getOldValue');
1868 $new_texts = mpull($changes, 'getNewValue');
1870 $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
1871 $this->getActor(),
1872 $old_texts);
1874 $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
1875 $this->getActor(),
1876 $new_texts);
1878 $phids = array_diff($new_phids, $old_phids);
1879 } else {
1880 $phids = array();
1883 $this->mentionedPHIDs = $phids;
1885 if ($object->getPHID()) {
1886 // Don't try to subscribe already-subscribed mentions: we want to generate
1887 // a dialog about an action having no effect if the user explicitly adds
1888 // existing CCs, but not if they merely mention existing subscribers.
1889 $phids = array_diff($phids, $this->subscribers);
1892 if ($phids) {
1893 $users = id(new PhabricatorPeopleQuery())
1894 ->setViewer($this->getActor())
1895 ->withPHIDs($phids)
1896 ->execute();
1897 $users = mpull($users, null, 'getPHID');
1899 foreach ($phids as $key => $phid) {
1900 $user = idx($users, $phid);
1902 // Don't subscribe invalid users.
1903 if (!$user) {
1904 unset($phids[$key]);
1905 continue;
1908 // Don't subscribe bots that get mentioned. If users truly intend
1909 // to subscribe them, they can add them explicitly, but it's generally
1910 // not useful to subscribe bots to objects.
1911 if ($user->getIsSystemAgent()) {
1912 unset($phids[$key]);
1913 continue;
1916 // Do not subscribe mentioned users who do not have permission to see
1917 // the object.
1918 if ($object instanceof PhabricatorPolicyInterface) {
1919 $can_view = PhabricatorPolicyFilter::hasCapability(
1920 $user,
1921 $object,
1922 PhabricatorPolicyCapability::CAN_VIEW);
1923 if (!$can_view) {
1924 unset($phids[$key]);
1925 continue;
1929 // Don't subscribe users who are already automatically subscribed.
1930 if ($object->isAutomaticallySubscribed($phid)) {
1931 unset($phids[$key]);
1932 continue;
1936 $phids = array_values($phids);
1939 if (!$phids) {
1940 return null;
1943 $xaction = $object->getApplicationTransactionTemplate()
1944 ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
1945 ->setNewValue(array('+' => $phids));
1947 return $xaction;
1950 protected function mergeTransactions(
1951 PhabricatorApplicationTransaction $u,
1952 PhabricatorApplicationTransaction $v) {
1954 $type = $u->getTransactionType();
1956 $xtype = $this->getModularTransactionType($type);
1957 if ($xtype) {
1958 $object = $this->object;
1959 return $xtype->mergeTransactions($object, $u, $v);
1962 switch ($type) {
1963 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
1964 return $this->mergePHIDOrEdgeTransactions($u, $v);
1965 case PhabricatorTransactions::TYPE_EDGE:
1966 $u_type = $u->getMetadataValue('edge:type');
1967 $v_type = $v->getMetadataValue('edge:type');
1968 if ($u_type == $v_type) {
1969 return $this->mergePHIDOrEdgeTransactions($u, $v);
1971 return null;
1974 // By default, do not merge the transactions.
1975 return null;
1979 * Optionally expand transactions which imply other effects. For example,
1980 * resigning from a revision in Differential implies removing yourself as
1981 * a reviewer.
1983 protected function expandTransactions(
1984 PhabricatorLiskDAO $object,
1985 array $xactions) {
1987 $results = array();
1988 foreach ($xactions as $xaction) {
1989 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
1990 $results[] = $expanded;
1994 return $results;
1997 protected function expandTransaction(
1998 PhabricatorLiskDAO $object,
1999 PhabricatorApplicationTransaction $xaction) {
2000 return array($xaction);
2004 public function getExpandedSupportTransactions(
2005 PhabricatorLiskDAO $object,
2006 PhabricatorApplicationTransaction $xaction) {
2008 $xactions = array($xaction);
2009 $xactions = $this->expandSupportTransactions(
2010 $object,
2011 $xactions);
2013 if (count($xactions) == 1) {
2014 return array();
2017 foreach ($xactions as $index => $cxaction) {
2018 if ($cxaction === $xaction) {
2019 unset($xactions[$index]);
2020 break;
2024 return $xactions;
2027 private function expandSupportTransactions(
2028 PhabricatorLiskDAO $object,
2029 array $xactions) {
2030 $this->loadSubscribers($object);
2032 $xactions = $this->applyImplicitCC($object, $xactions);
2034 $changes = $this->getRemarkupChanges($xactions);
2036 $subscribe_xaction = $this->buildSubscribeTransaction(
2037 $object,
2038 $xactions,
2039 $changes);
2040 if ($subscribe_xaction) {
2041 $xactions[] = $subscribe_xaction;
2044 // TODO: For now, this is just a placeholder.
2045 $engine = PhabricatorMarkupEngine::getEngine('extract');
2046 $engine->setConfig('viewer', $this->requireActor());
2048 $block_xactions = $this->expandRemarkupBlockTransactions(
2049 $object,
2050 $xactions,
2051 $changes,
2052 $engine);
2054 foreach ($block_xactions as $xaction) {
2055 $xactions[] = $xaction;
2058 return $xactions;
2061 private function getRemarkupChanges(array $xactions) {
2062 $changes = array();
2064 foreach ($xactions as $key => $xaction) {
2065 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2066 $changes[] = $change;
2070 return $changes;
2073 private function getRemarkupChangesFromTransaction(
2074 PhabricatorApplicationTransaction $transaction) {
2075 return $transaction->getRemarkupChanges();
2078 private function expandRemarkupBlockTransactions(
2079 PhabricatorLiskDAO $object,
2080 array $xactions,
2081 array $changes,
2082 PhutilMarkupEngine $engine) {
2084 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2085 $object,
2086 $xactions,
2087 $changes,
2088 $engine);
2090 $mentioned_phids = array();
2091 if ($this->shouldEnableMentions($object, $xactions)) {
2092 foreach ($changes as $change) {
2093 // Here, we don't care about processing only new mentions after an edit
2094 // because there is no way for an object to ever "unmention" itself on
2095 // another object, so we can ignore the old value.
2096 $engine->markupText($change->getNewValue());
2098 $mentioned_phids += $engine->getTextMetadata(
2099 PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
2100 array());
2104 if (!$mentioned_phids) {
2105 return $block_xactions;
2108 $mentioned_objects = id(new PhabricatorObjectQuery())
2109 ->setViewer($this->getActor())
2110 ->withPHIDs($mentioned_phids)
2111 ->execute();
2113 $unmentionable_map = $this->getUnmentionablePHIDMap();
2115 $mentionable_phids = array();
2116 if ($this->shouldEnableMentions($object, $xactions)) {
2117 foreach ($mentioned_objects as $mentioned_object) {
2118 if ($mentioned_object instanceof PhabricatorMentionableInterface) {
2119 $mentioned_phid = $mentioned_object->getPHID();
2120 if (isset($unmentionable_map[$mentioned_phid])) {
2121 continue;
2123 // don't let objects mention themselves
2124 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2125 continue;
2127 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2132 if ($mentionable_phids) {
2133 $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
2134 $block_xactions[] = newv(get_class(head($xactions)), array())
2135 ->setIgnoreOnNoEffect(true)
2136 ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
2137 ->setMetadataValue('edge:type', $edge_type)
2138 ->setNewValue(array('+' => $mentionable_phids));
2141 return $block_xactions;
2144 protected function expandCustomRemarkupBlockTransactions(
2145 PhabricatorLiskDAO $object,
2146 array $xactions,
2147 array $changes,
2148 PhutilMarkupEngine $engine) {
2149 return array();
2154 * Attempt to combine similar transactions into a smaller number of total
2155 * transactions. For example, two transactions which edit the title of an
2156 * object can be merged into a single edit.
2158 private function combineTransactions(array $xactions) {
2159 $stray_comments = array();
2161 $result = array();
2162 $types = array();
2163 foreach ($xactions as $key => $xaction) {
2164 $type = $xaction->getTransactionType();
2165 if (isset($types[$type])) {
2166 foreach ($types[$type] as $other_key) {
2167 $other_xaction = $result[$other_key];
2169 // Don't merge transactions with different authors. For example,
2170 // don't merge Herald transactions and owners transactions.
2171 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2172 continue;
2175 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2176 if ($merged) {
2177 $result[$other_key] = $merged;
2179 if ($xaction->getComment() &&
2180 ($xaction->getComment() !== $merged->getComment())) {
2181 $stray_comments[] = $xaction->getComment();
2184 if ($result[$other_key]->getComment() &&
2185 ($result[$other_key]->getComment() !== $merged->getComment())) {
2186 $stray_comments[] = $result[$other_key]->getComment();
2189 // Move on to the next transaction.
2190 continue 2;
2194 $result[$key] = $xaction;
2195 $types[$type][] = $key;
2198 // If we merged any comments away, restore them.
2199 foreach ($stray_comments as $comment) {
2200 $xaction = newv(get_class(head($result)), array());
2201 $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
2202 $xaction->setComment($comment);
2203 $result[] = $xaction;
2206 return array_values($result);
2209 public function mergePHIDOrEdgeTransactions(
2210 PhabricatorApplicationTransaction $u,
2211 PhabricatorApplicationTransaction $v) {
2213 $result = $u->getNewValue();
2214 foreach ($v->getNewValue() as $key => $value) {
2215 if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
2216 if (empty($result[$key])) {
2217 $result[$key] = $value;
2218 } else {
2219 // We're merging two lists of edge adds, sets, or removes. Merge
2220 // them by merging individual PHIDs within them.
2221 $merged = $result[$key];
2223 foreach ($value as $dst => $v_spec) {
2224 if (empty($merged[$dst])) {
2225 $merged[$dst] = $v_spec;
2226 } else {
2227 // Two transactions are trying to perform the same operation on
2228 // the same edge. Normalize the edge data and then merge it. This
2229 // allows transactions to specify how data merges execute in a
2230 // precise way.
2232 $u_spec = $merged[$dst];
2234 if (!is_array($u_spec)) {
2235 $u_spec = array('dst' => $u_spec);
2237 if (!is_array($v_spec)) {
2238 $v_spec = array('dst' => $v_spec);
2241 $ux_data = idx($u_spec, 'data', array());
2242 $vx_data = idx($v_spec, 'data', array());
2244 $merged_data = $this->mergeEdgeData(
2245 $u->getMetadataValue('edge:type'),
2246 $ux_data,
2247 $vx_data);
2249 $u_spec['data'] = $merged_data;
2250 $merged[$dst] = $u_spec;
2254 $result[$key] = $merged;
2256 } else {
2257 $result[$key] = array_merge($value, idx($result, $key, array()));
2260 $u->setNewValue($result);
2262 // When combining an "ignore" transaction with a normal transaction, make
2263 // sure we don't propagate the "ignore" flag.
2264 if (!$v->getIgnoreOnNoEffect()) {
2265 $u->setIgnoreOnNoEffect(false);
2268 return $u;
2271 protected function mergeEdgeData($type, array $u, array $v) {
2272 return $v + $u;
2275 protected function getPHIDTransactionNewValue(
2276 PhabricatorApplicationTransaction $xaction,
2277 $old = null) {
2279 if ($old !== null) {
2280 $old = array_fuse($old);
2281 } else {
2282 $old = array_fuse($xaction->getOldValue());
2285 return $this->getPHIDList($old, $xaction->getNewValue());
2288 public function getPHIDList(array $old, array $new) {
2289 $new_add = idx($new, '+', array());
2290 unset($new['+']);
2291 $new_rem = idx($new, '-', array());
2292 unset($new['-']);
2293 $new_set = idx($new, '=', null);
2294 if ($new_set !== null) {
2295 $new_set = array_fuse($new_set);
2297 unset($new['=']);
2299 if ($new) {
2300 throw new Exception(
2301 pht(
2302 "Invalid '%s' value for PHID transaction. Value should contain only ".
2303 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2304 'new',
2305 '+',
2306 '-',
2307 '='));
2310 $result = array();
2312 foreach ($old as $phid) {
2313 if ($new_set !== null && empty($new_set[$phid])) {
2314 continue;
2316 $result[$phid] = $phid;
2319 if ($new_set !== null) {
2320 foreach ($new_set as $phid) {
2321 $result[$phid] = $phid;
2325 foreach ($new_add as $phid) {
2326 $result[$phid] = $phid;
2329 foreach ($new_rem as $phid) {
2330 unset($result[$phid]);
2333 return array_values($result);
2336 protected function getEdgeTransactionNewValue(
2337 PhabricatorApplicationTransaction $xaction) {
2339 $new = $xaction->getNewValue();
2340 $new_add = idx($new, '+', array());
2341 unset($new['+']);
2342 $new_rem = idx($new, '-', array());
2343 unset($new['-']);
2344 $new_set = idx($new, '=', null);
2345 unset($new['=']);
2347 if ($new) {
2348 throw new Exception(
2349 pht(
2350 "Invalid '%s' value for Edge transaction. Value should contain only ".
2351 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2352 'new',
2353 '+',
2354 '-',
2355 '='));
2358 $old = $xaction->getOldValue();
2360 $lists = array($new_set, $new_add, $new_rem);
2361 foreach ($lists as $list) {
2362 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2365 $result = array();
2366 foreach ($old as $dst_phid => $edge) {
2367 if ($new_set !== null && empty($new_set[$dst_phid])) {
2368 continue;
2370 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2371 $xaction,
2372 $edge,
2373 $dst_phid);
2376 if ($new_set !== null) {
2377 foreach ($new_set as $dst_phid => $edge) {
2378 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2379 $xaction,
2380 $edge,
2381 $dst_phid);
2385 foreach ($new_add as $dst_phid => $edge) {
2386 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2387 $xaction,
2388 $edge,
2389 $dst_phid);
2392 foreach ($new_rem as $dst_phid => $edge) {
2393 unset($result[$dst_phid]);
2396 return $result;
2399 private function checkEdgeList($list, $edge_type) {
2400 if (!$list) {
2401 return;
2403 foreach ($list as $key => $item) {
2404 if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
2405 throw new Exception(
2406 pht(
2407 'Edge transactions must have destination PHIDs as in edge '.
2408 'lists (found key "%s" on transaction of type "%s").',
2409 $key,
2410 $edge_type));
2412 if (!is_array($item) && $item !== $key) {
2413 throw new Exception(
2414 pht(
2415 'Edge transactions must have PHIDs or edge specs as values '.
2416 '(found value "%s" on transaction of type "%s").',
2417 $item,
2418 $edge_type));
2423 private function normalizeEdgeTransactionValue(
2424 PhabricatorApplicationTransaction $xaction,
2425 $edge,
2426 $dst_phid) {
2428 if (!is_array($edge)) {
2429 if ($edge != $dst_phid) {
2430 throw new Exception(
2431 pht(
2432 'Transaction edge data must either be the edge PHID or an edge '.
2433 'specification dictionary.'));
2435 $edge = array();
2436 } else {
2437 foreach ($edge as $key => $value) {
2438 switch ($key) {
2439 case 'src':
2440 case 'dst':
2441 case 'type':
2442 case 'data':
2443 case 'dateCreated':
2444 case 'dateModified':
2445 case 'seq':
2446 case 'dataID':
2447 break;
2448 default:
2449 throw new Exception(
2450 pht(
2451 'Transaction edge specification contains unexpected key "%s".',
2452 $key));
2457 $edge['dst'] = $dst_phid;
2459 $edge_type = $xaction->getMetadataValue('edge:type');
2460 if (empty($edge['type'])) {
2461 $edge['type'] = $edge_type;
2462 } else {
2463 if ($edge['type'] != $edge_type) {
2464 $this_type = $edge['type'];
2465 throw new Exception(
2466 pht(
2467 "Edge transaction includes edge of type '%s', but ".
2468 "transaction is of type '%s'. Each edge transaction ".
2469 "must alter edges of only one type.",
2470 $this_type,
2471 $edge_type));
2475 if (!isset($edge['data'])) {
2476 $edge['data'] = array();
2479 return $edge;
2482 protected function sortTransactions(array $xactions) {
2483 $head = array();
2484 $tail = array();
2486 // Move bare comments to the end, so the actions precede them.
2487 foreach ($xactions as $xaction) {
2488 $type = $xaction->getTransactionType();
2489 if ($type == PhabricatorTransactions::TYPE_COMMENT) {
2490 $tail[] = $xaction;
2491 } else {
2492 $head[] = $xaction;
2496 return array_values(array_merge($head, $tail));
2500 protected function filterTransactions(
2501 PhabricatorLiskDAO $object,
2502 array $xactions) {
2504 $type_comment = PhabricatorTransactions::TYPE_COMMENT;
2505 $type_mfa = PhabricatorTransactions::TYPE_MFA;
2507 $no_effect = array();
2508 $has_comment = false;
2509 $any_effect = false;
2511 $meta_xactions = array();
2512 foreach ($xactions as $key => $xaction) {
2513 if ($xaction->getTransactionType() === $type_mfa) {
2514 $meta_xactions[$key] = $xaction;
2515 continue;
2518 if ($this->transactionHasEffect($object, $xaction)) {
2519 if ($xaction->getTransactionType() != $type_comment) {
2520 $any_effect = true;
2522 } else if ($xaction->getIgnoreOnNoEffect()) {
2523 unset($xactions[$key]);
2524 } else {
2525 $no_effect[$key] = $xaction;
2528 if ($xaction->hasComment()) {
2529 $has_comment = true;
2533 // If every transaction is a meta-transaction applying to the transaction
2534 // group, these transactions are junk.
2535 if (count($meta_xactions) == count($xactions)) {
2536 $no_effect = $xactions;
2537 $any_effect = false;
2540 if (!$no_effect) {
2541 return $xactions;
2544 // If none of the transactions have an effect, the meta-transactions also
2545 // have no effect. Add them to the "no effect" list so we get a full set
2546 // of errors for everything.
2547 if (!$any_effect && !$has_comment) {
2548 $no_effect += $meta_xactions;
2551 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2552 throw new PhabricatorApplicationTransactionNoEffectException(
2553 $no_effect,
2554 $any_effect,
2555 $has_comment);
2558 if (!$any_effect && !$has_comment) {
2559 // If we only have empty comment transactions, just drop them all.
2560 return array();
2563 foreach ($no_effect as $key => $xaction) {
2564 if ($xaction->hasComment()) {
2565 $xaction->setTransactionType($type_comment);
2566 $xaction->setOldValue(null);
2567 $xaction->setNewValue(null);
2568 } else {
2569 unset($xactions[$key]);
2573 return $xactions;
2578 * Hook for validating transactions. This callback will be invoked for each
2579 * available transaction type, even if an edit does not apply any transactions
2580 * of that type. This allows you to raise exceptions when required fields are
2581 * missing, by detecting that the object has no field value and there is no
2582 * transaction which sets one.
2584 * @param PhabricatorLiskDAO Object being edited.
2585 * @param string Transaction type to validate.
2586 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2587 * which may be empty if the edit does not apply any transactions of the
2588 * given type.
2589 * @return list<PhabricatorApplicationTransactionValidationError> List of
2590 * validation errors.
2592 protected function validateTransaction(
2593 PhabricatorLiskDAO $object,
2594 $type,
2595 array $xactions) {
2597 $errors = array();
2599 $xtype = $this->getModularTransactionType($type);
2600 if ($xtype) {
2601 $errors[] = $xtype->validateTransactions($object, $xactions);
2604 switch ($type) {
2605 case PhabricatorTransactions::TYPE_VIEW_POLICY:
2606 $errors[] = $this->validatePolicyTransaction(
2607 $object,
2608 $xactions,
2609 $type,
2610 PhabricatorPolicyCapability::CAN_VIEW);
2611 break;
2612 case PhabricatorTransactions::TYPE_EDIT_POLICY:
2613 $errors[] = $this->validatePolicyTransaction(
2614 $object,
2615 $xactions,
2616 $type,
2617 PhabricatorPolicyCapability::CAN_EDIT);
2618 break;
2619 case PhabricatorTransactions::TYPE_SPACE:
2620 $errors[] = $this->validateSpaceTransactions(
2621 $object,
2622 $xactions,
2623 $type);
2624 break;
2625 case PhabricatorTransactions::TYPE_SUBTYPE:
2626 $errors[] = $this->validateSubtypeTransactions(
2627 $object,
2628 $xactions,
2629 $type);
2630 break;
2631 case PhabricatorTransactions::TYPE_MFA:
2632 $errors[] = $this->validateMFATransactions(
2633 $object,
2634 $xactions,
2635 $type);
2636 break;
2637 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
2638 $groups = array();
2639 foreach ($xactions as $xaction) {
2640 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2643 $field_list = PhabricatorCustomField::getObjectFields(
2644 $object,
2645 PhabricatorCustomField::ROLE_EDIT);
2646 $field_list->setViewer($this->getActor());
2648 $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
2649 foreach ($field_list->getFields() as $field) {
2650 if (!$field->shouldEnableForRole($role_xactions)) {
2651 continue;
2653 $errors[] = $field->validateApplicationTransactions(
2654 $this,
2655 $type,
2656 idx($groups, $field->getFieldKey(), array()));
2658 break;
2661 return array_mergev($errors);
2664 public function validatePolicyTransaction(
2665 PhabricatorLiskDAO $object,
2666 array $xactions,
2667 $transaction_type,
2668 $capability) {
2670 $actor = $this->requireActor();
2671 $errors = array();
2672 // Note $this->xactions is necessary; $xactions is $this->xactions of
2673 // $transaction_type
2674 $policy_object = $this->adjustObjectForPolicyChecks(
2675 $object,
2676 $this->xactions);
2678 // Make sure the user isn't editing away their ability to $capability this
2679 // object.
2680 foreach ($xactions as $xaction) {
2681 try {
2682 PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
2683 $actor,
2684 $policy_object,
2685 $capability,
2686 $xaction->getNewValue());
2687 } catch (PhabricatorPolicyException $ex) {
2688 $errors[] = new PhabricatorApplicationTransactionValidationError(
2689 $transaction_type,
2690 pht('Invalid'),
2691 pht(
2692 'You can not select this %s policy, because you would no longer '.
2693 'be able to %s the object.',
2694 $capability,
2695 $capability),
2696 $xaction);
2700 if ($this->getIsNewObject()) {
2701 if (!$xactions) {
2702 $has_capability = PhabricatorPolicyFilter::hasCapability(
2703 $actor,
2704 $policy_object,
2705 $capability);
2706 if (!$has_capability) {
2707 $errors[] = new PhabricatorApplicationTransactionValidationError(
2708 $transaction_type,
2709 pht('Invalid'),
2710 pht(
2711 'The selected %s policy excludes you. Choose a %s policy '.
2712 'which allows you to %s the object.',
2713 $capability,
2714 $capability,
2715 $capability));
2720 return $errors;
2724 private function validateSpaceTransactions(
2725 PhabricatorLiskDAO $object,
2726 array $xactions,
2727 $transaction_type) {
2728 $errors = array();
2730 $actor = $this->getActor();
2732 $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
2733 $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
2734 $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
2735 $actor);
2736 foreach ($xactions as $xaction) {
2737 $space_phid = $xaction->getNewValue();
2739 if ($space_phid === null) {
2740 if (!$has_spaces) {
2741 // The install doesn't have any spaces, so this is fine.
2742 continue;
2745 // The install has some spaces, so every object needs to be put
2746 // in a valid space.
2747 $errors[] = new PhabricatorApplicationTransactionValidationError(
2748 $transaction_type,
2749 pht('Invalid'),
2750 pht('You must choose a space for this object.'),
2751 $xaction);
2752 continue;
2755 // If the PHID isn't `null`, it needs to be a valid space that the
2756 // viewer can see.
2757 if (empty($actor_spaces[$space_phid])) {
2758 $errors[] = new PhabricatorApplicationTransactionValidationError(
2759 $transaction_type,
2760 pht('Invalid'),
2761 pht(
2762 'You can not shift this object in the selected space, because '.
2763 'the space does not exist or you do not have access to it.'),
2764 $xaction);
2765 } else if (empty($active_spaces[$space_phid])) {
2767 // It's OK to edit objects in an archived space, so just move on if
2768 // we aren't adjusting the value.
2769 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
2770 if ($space_phid == $old_space_phid) {
2771 continue;
2774 $errors[] = new PhabricatorApplicationTransactionValidationError(
2775 $transaction_type,
2776 pht('Archived'),
2777 pht(
2778 'You can not shift this object into the selected space, because '.
2779 'the space is archived. Objects can not be created inside (or '.
2780 'moved into) archived spaces.'),
2781 $xaction);
2785 return $errors;
2788 private function validateSubtypeTransactions(
2789 PhabricatorLiskDAO $object,
2790 array $xactions,
2791 $transaction_type) {
2792 $errors = array();
2794 $map = $object->newEditEngineSubtypeMap();
2795 $old = $object->getEditEngineSubtype();
2796 foreach ($xactions as $xaction) {
2797 $new = $xaction->getNewValue();
2799 if ($old == $new) {
2800 continue;
2803 if (!$map->isValidSubtype($new)) {
2804 $errors[] = new PhabricatorApplicationTransactionValidationError(
2805 $transaction_type,
2806 pht('Invalid'),
2807 pht(
2808 'The subtype "%s" is not a valid subtype.',
2809 $new),
2810 $xaction);
2811 continue;
2815 return $errors;
2818 private function validateMFATransactions(
2819 PhabricatorLiskDAO $object,
2820 array $xactions,
2821 $transaction_type) {
2822 $errors = array();
2824 $factors = id(new PhabricatorAuthFactorConfigQuery())
2825 ->setViewer($this->getActor())
2826 ->withUserPHIDs(array($this->getActingAsPHID()))
2827 ->withFactorProviderStatuses(
2828 array(
2829 PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
2830 PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
2832 ->execute();
2834 foreach ($xactions as $xaction) {
2835 if (!$factors) {
2836 $errors[] = new PhabricatorApplicationTransactionValidationError(
2837 $transaction_type,
2838 pht('No MFA'),
2839 pht(
2840 'You do not have any MFA factors attached to your account, so '.
2841 'you can not sign this transaction group with MFA. Add MFA to '.
2842 'your account in Settings.'),
2843 $xaction);
2847 if ($xactions) {
2848 $this->setShouldRequireMFA(true);
2851 return $errors;
2854 protected function adjustObjectForPolicyChecks(
2855 PhabricatorLiskDAO $object,
2856 array $xactions) {
2858 $copy = clone $object;
2860 foreach ($xactions as $xaction) {
2861 switch ($xaction->getTransactionType()) {
2862 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
2863 $clone_xaction = clone $xaction;
2864 $clone_xaction->setOldValue(array_values($this->subscribers));
2865 $clone_xaction->setNewValue(
2866 $this->getPHIDTransactionNewValue(
2867 $clone_xaction));
2869 PhabricatorPolicyRule::passTransactionHintToRule(
2870 $copy,
2871 new PhabricatorSubscriptionsSubscribersPolicyRule(),
2872 array_fuse($clone_xaction->getNewValue()));
2874 break;
2875 case PhabricatorTransactions::TYPE_SPACE:
2876 $space_phid = $this->getTransactionNewValue($object, $xaction);
2877 $copy->setSpacePHID($space_phid);
2878 break;
2882 return $copy;
2885 protected function validateAllTransactions(
2886 PhabricatorLiskDAO $object,
2887 array $xactions) {
2888 return array();
2892 * Check for a missing text field.
2894 * A text field is missing if the object has no value and there are no
2895 * transactions which set a value, or if the transactions remove the value.
2896 * This method is intended to make implementing @{method:validateTransaction}
2897 * more convenient:
2899 * $missing = $this->validateIsEmptyTextField(
2900 * $object->getName(),
2901 * $xactions);
2903 * This will return `true` if the net effect of the object and transactions
2904 * is an empty field.
2906 * @param wild Current field value.
2907 * @param list<PhabricatorApplicationTransaction> Transactions editing the
2908 * field.
2909 * @return bool True if the field will be an empty text field after edits.
2911 protected function validateIsEmptyTextField($field_value, array $xactions) {
2912 if (strlen($field_value) && empty($xactions)) {
2913 return false;
2916 if ($xactions && strlen(last($xactions)->getNewValue())) {
2917 return false;
2920 return true;
2924 /* -( Implicit CCs )------------------------------------------------------- */
2928 * When a user interacts with an object, we might want to add them to CC.
2930 final public function applyImplicitCC(
2931 PhabricatorLiskDAO $object,
2932 array $xactions) {
2934 if (!($object instanceof PhabricatorSubscribableInterface)) {
2935 // If the object isn't subscribable, we can't CC them.
2936 return $xactions;
2939 $actor_phid = $this->getActingAsPHID();
2941 $type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
2942 if (phid_get_type($actor_phid) != $type_user) {
2943 // Transactions by application actors like Herald, Harbormaster and
2944 // Diffusion should not CC the applications.
2945 return $xactions;
2948 if ($object->isAutomaticallySubscribed($actor_phid)) {
2949 // If they're auto-subscribed, don't CC them.
2950 return $xactions;
2953 $should_cc = false;
2954 foreach ($xactions as $xaction) {
2955 if ($this->shouldImplyCC($object, $xaction)) {
2956 $should_cc = true;
2957 break;
2961 if (!$should_cc) {
2962 // Only some types of actions imply a CC (like adding a comment).
2963 return $xactions;
2966 if ($object->getPHID()) {
2967 if (isset($this->subscribers[$actor_phid])) {
2968 // If the user is already subscribed, don't implicitly CC them.
2969 return $xactions;
2972 $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
2973 $object->getPHID(),
2974 PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
2975 $unsub = array_fuse($unsub);
2976 if (isset($unsub[$actor_phid])) {
2977 // If the user has previously unsubscribed from this object explicitly,
2978 // don't implicitly CC them.
2979 return $xactions;
2983 $actor = $this->getActor();
2985 $user = id(new PhabricatorPeopleQuery())
2986 ->setViewer($actor)
2987 ->withPHIDs(array($actor_phid))
2988 ->executeOne();
2989 if (!$user) {
2990 return $xactions;
2993 // When a bot acts (usually via the API), don't automatically subscribe
2994 // them as a side effect. They can always subscribe explicitly if they
2995 // want, and bot subscriptions normally just clutter things up since bots
2996 // usually do not read email.
2997 if ($user->getIsSystemAgent()) {
2998 return $xactions;
3001 $xaction = newv(get_class(head($xactions)), array());
3002 $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
3003 $xaction->setNewValue(array('+' => array($actor_phid)));
3005 array_unshift($xactions, $xaction);
3007 return $xactions;
3010 protected function shouldImplyCC(
3011 PhabricatorLiskDAO $object,
3012 PhabricatorApplicationTransaction $xaction) {
3014 return $xaction->isCommentTransaction();
3018 /* -( Sending Mail )------------------------------------------------------- */
3022 * @task mail
3024 protected function shouldSendMail(
3025 PhabricatorLiskDAO $object,
3026 array $xactions) {
3027 return false;
3032 * @task mail
3034 private function buildMail(
3035 PhabricatorLiskDAO $object,
3036 array $xactions) {
3038 $email_to = $this->mailToPHIDs;
3039 $email_cc = $this->mailCCPHIDs;
3040 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
3042 $unexpandable = $this->mailUnexpandablePHIDs;
3043 if (!is_array($unexpandable)) {
3044 $unexpandable = array();
3047 $messages = $this->buildMailWithRecipients(
3048 $object,
3049 $xactions,
3050 $email_to,
3051 $email_cc,
3052 $unexpandable);
3054 $this->runHeraldMailRules($messages);
3056 return $messages;
3059 private function buildMailWithRecipients(
3060 PhabricatorLiskDAO $object,
3061 array $xactions,
3062 array $email_to,
3063 array $email_cc,
3064 array $unexpandable) {
3066 $targets = $this->buildReplyHandler($object)
3067 ->setUnexpandablePHIDs($unexpandable)
3068 ->getMailTargets($email_to, $email_cc);
3070 // Set this explicitly before we start swapping out the effective actor.
3071 $this->setActingAsPHID($this->getActingAsPHID());
3073 $xaction_phids = mpull($xactions, 'getPHID');
3075 $messages = array();
3076 foreach ($targets as $target) {
3077 $original_actor = $this->getActor();
3079 $viewer = $target->getViewer();
3080 $this->setActor($viewer);
3081 $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
3083 $caught = null;
3084 $mail = null;
3085 try {
3086 // Reload the transactions for the current viewer.
3087 if ($xaction_phids) {
3088 $query = PhabricatorApplicationTransactionQuery::newQueryForObject(
3089 $object);
3091 $mail_xactions = $query
3092 ->setViewer($viewer)
3093 ->withObjectPHIDs(array($object->getPHID()))
3094 ->withPHIDs($xaction_phids)
3095 ->execute();
3097 // Sort the mail transactions in the input order.
3098 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3099 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3100 $mail_xactions = array_values($mail_xactions);
3101 } else {
3102 $mail_xactions = array();
3105 // Reload handles for the current viewer. This covers older code which
3106 // emits a list of handle PHIDs upfront.
3107 $this->loadHandles($mail_xactions);
3109 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3111 if ($mail) {
3112 if ($this->mustEncrypt) {
3113 $mail
3114 ->setMustEncrypt(true)
3115 ->setMustEncryptReasons($this->mustEncrypt);
3118 } catch (Exception $ex) {
3119 $caught = $ex;
3122 $this->setActor($original_actor);
3123 unset($locale);
3125 if ($caught) {
3126 throw $ex;
3129 if ($mail) {
3130 $messages[] = $mail;
3134 return $messages;
3137 protected function getTransactionsForMail(
3138 PhabricatorLiskDAO $object,
3139 array $xactions) {
3140 return $xactions;
3143 private function buildMailForTarget(
3144 PhabricatorLiskDAO $object,
3145 array $xactions,
3146 PhabricatorMailTarget $target) {
3148 // Check if any of the transactions are visible for this viewer. If we
3149 // don't have any visible transactions, don't send the mail.
3151 $any_visible = false;
3152 foreach ($xactions as $xaction) {
3153 if (!$xaction->shouldHideForMail($xactions)) {
3154 $any_visible = true;
3155 break;
3159 if (!$any_visible) {
3160 return null;
3163 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3165 $mail = $this->buildMailTemplate($object);
3166 $body = $this->buildMailBody($object, $mail_xactions);
3168 $mail_tags = $this->getMailTags($object, $mail_xactions);
3169 $action = $this->getMailAction($object, $mail_xactions);
3170 $stamps = $this->generateMailStamps($object, $this->mailStamps);
3172 if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
3173 $this->addEmailPreferenceSectionToMailBody(
3174 $body,
3175 $object,
3176 $mail_xactions);
3179 $muted_phids = $this->mailMutedPHIDs;
3180 if (!is_array($muted_phids)) {
3181 $muted_phids = array();
3184 $mail
3185 ->setSensitiveContent(false)
3186 ->setFrom($this->getActingAsPHID())
3187 ->setSubjectPrefix($this->getMailSubjectPrefix())
3188 ->setVarySubjectPrefix('['.$action.']')
3189 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3190 ->setRelatedPHID($object->getPHID())
3191 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3192 ->setMutedPHIDs($muted_phids)
3193 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
3194 ->setMailTags($mail_tags)
3195 ->setIsBulk(true)
3196 ->setBody($body->render())
3197 ->setHTMLBody($body->renderHTML());
3199 foreach ($body->getAttachments() as $attachment) {
3200 $mail->addAttachment($attachment);
3203 if ($this->heraldHeader) {
3204 $mail->addHeader('X-Herald-Rules', $this->heraldHeader);
3207 if ($object instanceof PhabricatorProjectInterface) {
3208 $this->addMailProjectMetadata($object, $mail);
3211 if ($this->getParentMessageID()) {
3212 $mail->setParentMessageID($this->getParentMessageID());
3215 // If we have stamps, attach the raw dictionary version (not the actual
3216 // objects) to the mail so that debugging tools can see what we used to
3217 // render the final list.
3218 if ($this->mailStamps) {
3219 $mail->setMailStampMetadata($this->mailStamps);
3222 // If we have rendered stamps, attach them to the mail.
3223 if ($stamps) {
3224 $mail->setMailStamps($stamps);
3227 return $target->willSendMail($mail);
3230 private function addMailProjectMetadata(
3231 PhabricatorLiskDAO $object,
3232 PhabricatorMetaMTAMail $template) {
3234 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3235 $object->getPHID(),
3236 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3238 if (!$project_phids) {
3239 return;
3242 // TODO: This viewer isn't quite right. It would be slightly better to use
3243 // the mail recipient, but that's not very easy given the way rendering
3244 // works today.
3246 $handles = id(new PhabricatorHandleQuery())
3247 ->setViewer($this->requireActor())
3248 ->withPHIDs($project_phids)
3249 ->execute();
3251 $project_tags = array();
3252 foreach ($handles as $handle) {
3253 if (!$handle->isComplete()) {
3254 continue;
3256 $project_tags[] = '<'.$handle->getObjectName().'>';
3259 if (!$project_tags) {
3260 return;
3263 $project_tags = implode(', ', $project_tags);
3264 $template->addHeader('X-Phabricator-Projects', $project_tags);
3268 protected function getMailThreadID(PhabricatorLiskDAO $object) {
3269 return $object->getPHID();
3274 * @task mail
3276 protected function getStrongestAction(
3277 PhabricatorLiskDAO $object,
3278 array $xactions) {
3279 return head(msortv($xactions, 'newActionStrengthSortVector'));
3284 * @task mail
3286 protected function buildReplyHandler(PhabricatorLiskDAO $object) {
3287 throw new Exception(pht('Capability not supported.'));
3291 * @task mail
3293 protected function getMailSubjectPrefix() {
3294 throw new Exception(pht('Capability not supported.'));
3299 * @task mail
3301 protected function getMailTags(
3302 PhabricatorLiskDAO $object,
3303 array $xactions) {
3304 $tags = array();
3306 foreach ($xactions as $xaction) {
3307 $tags[] = $xaction->getMailTags();
3310 return array_mergev($tags);
3314 * @task mail
3316 public function getMailTagsMap() {
3317 // TODO: We should move shared mail tags, like "comment", here.
3318 return array();
3323 * @task mail
3325 protected function getMailAction(
3326 PhabricatorLiskDAO $object,
3327 array $xactions) {
3328 return $this->getStrongestAction($object, $xactions)->getActionName();
3333 * @task mail
3335 protected function buildMailTemplate(PhabricatorLiskDAO $object) {
3336 throw new Exception(pht('Capability not supported.'));
3341 * @task mail
3343 protected function getMailTo(PhabricatorLiskDAO $object) {
3344 throw new Exception(pht('Capability not supported.'));
3348 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
3349 return array();
3354 * @task mail
3356 protected function getMailCC(PhabricatorLiskDAO $object) {
3357 $phids = array();
3358 $has_support = false;
3360 if ($object instanceof PhabricatorSubscribableInterface) {
3361 $phid = $object->getPHID();
3362 $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
3363 $has_support = true;
3366 if ($object instanceof PhabricatorProjectInterface) {
3367 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3368 $object->getPHID(),
3369 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3371 if ($project_phids) {
3372 $projects = id(new PhabricatorProjectQuery())
3373 ->setViewer(PhabricatorUser::getOmnipotentUser())
3374 ->withPHIDs($project_phids)
3375 ->needWatchers(true)
3376 ->execute();
3378 $watcher_phids = array();
3379 foreach ($projects as $project) {
3380 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3381 $watcher_phids[$phid] = $phid;
3385 if ($watcher_phids) {
3386 // We need to do a visibility check for all the watchers, as
3387 // watching a project is not a guarantee that you can see objects
3388 // associated with it.
3389 $users = id(new PhabricatorPeopleQuery())
3390 ->setViewer($this->requireActor())
3391 ->withPHIDs($watcher_phids)
3392 ->execute();
3394 $watchers = array();
3395 foreach ($users as $user) {
3396 $can_see = PhabricatorPolicyFilter::hasCapability(
3397 $user,
3398 $object,
3399 PhabricatorPolicyCapability::CAN_VIEW);
3400 if ($can_see) {
3401 $watchers[] = $user->getPHID();
3404 $phids[] = $watchers;
3408 $has_support = true;
3411 if (!$has_support) {
3412 throw new Exception(
3413 pht('The object being edited does not implement any standard '.
3414 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3415 'CCs to be generated automatically. Override the "getMailCC()" '.
3416 'method and generate CCs explicitly.'));
3419 return array_mergev($phids);
3424 * @task mail
3426 protected function buildMailBody(
3427 PhabricatorLiskDAO $object,
3428 array $xactions) {
3430 $body = id(new PhabricatorMetaMTAMailBody())
3431 ->setViewer($this->requireActor())
3432 ->setContextObject($object);
3434 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3435 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3437 $this->addHeadersAndCommentsToMailBody(
3438 $body,
3439 $xactions,
3440 $button_label,
3441 $button_uri);
3443 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3445 return $body;
3448 protected function getObjectLinkButtonLabelForMail(
3449 PhabricatorLiskDAO $object) {
3450 return null;
3453 protected function getObjectLinkButtonURIForMail(
3454 PhabricatorLiskDAO $object) {
3456 // Most objects define a "getURI()" method which does what we want, but
3457 // this isn't formally part of an interface at time of writing. Try to
3458 // call the method, expecting an exception if it does not exist.
3460 try {
3461 $uri = $object->getURI();
3462 return PhabricatorEnv::getProductionURI($uri);
3463 } catch (Exception $ex) {
3464 return null;
3469 * @task mail
3471 protected function addEmailPreferenceSectionToMailBody(
3472 PhabricatorMetaMTAMailBody $body,
3473 PhabricatorLiskDAO $object,
3474 array $xactions) {
3476 $href = PhabricatorEnv::getProductionURI(
3477 '/settings/panel/emailpreferences/');
3478 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3483 * @task mail
3485 protected function addHeadersAndCommentsToMailBody(
3486 PhabricatorMetaMTAMailBody $body,
3487 array $xactions,
3488 $object_label = null,
3489 $object_uri = null) {
3491 // First, remove transactions which shouldn't be rendered in mail.
3492 foreach ($xactions as $key => $xaction) {
3493 if ($xaction->shouldHideForMail($xactions)) {
3494 unset($xactions[$key]);
3498 $headers = array();
3499 $headers_html = array();
3500 $comments = array();
3501 $details = array();
3503 $seen_comment = false;
3504 foreach ($xactions as $xaction) {
3506 // Most mail has zero or one comments. In these cases, we render the
3507 // "alice added a comment." transaction in the header, like a normal
3508 // transaction.
3510 // Some mail, like Differential undraft mail or "!history" mail, may
3511 // have two or more comments. In these cases, we'll put the first
3512 // "alice added a comment." transaction in the header normally, but
3513 // move the other transactions down so they provide context above the
3514 // actual comment.
3516 $comment = $this->getBodyForTextMail($xaction);
3517 if ($comment !== null) {
3518 $is_comment = true;
3519 $comments[] = array(
3520 'xaction' => $xaction,
3521 'comment' => $comment,
3522 'initial' => !$seen_comment,
3524 } else {
3525 $is_comment = false;
3528 if (!$is_comment || !$seen_comment) {
3529 $header = $this->getTitleForTextMail($xaction);
3530 if ($header !== null) {
3531 $headers[] = $header;
3534 $header_html = $this->getTitleForHTMLMail($xaction);
3535 if ($header_html !== null) {
3536 $headers_html[] = $header_html;
3540 if ($xaction->hasChangeDetailsForMail()) {
3541 $details[] = $xaction;
3544 if ($is_comment) {
3545 $seen_comment = true;
3549 $headers_text = implode("\n", $headers);
3550 $body->addRawPlaintextSection($headers_text);
3552 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3554 $header_button = null;
3555 if ($object_label !== null && $object_uri !== null) {
3556 $button_style = array(
3557 'text-decoration: none;',
3558 'padding: 4px 8px;',
3559 'margin: 0 8px 8px;',
3560 'float: right;',
3561 'color: #464C5C;',
3562 'font-weight: bold;',
3563 'border-radius: 3px;',
3564 'background-color: #F7F7F9;',
3565 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3566 'display: inline-block;',
3567 'border: 1px solid rgba(71,87,120,.2);',
3570 $header_button = phutil_tag(
3571 'a',
3572 array(
3573 'style' => implode(' ', $button_style),
3574 'href' => $object_uri,
3576 $object_label);
3579 $xactions_style = array();
3581 $header_action = phutil_tag(
3582 'td',
3583 array(),
3584 $header_button);
3586 $header_action = phutil_tag(
3587 'td',
3588 array(
3589 'style' => implode(' ', $xactions_style),
3591 array(
3592 $headers_html,
3593 // Add an extra newline to prevent the "View Object" button from
3594 // running into the transaction text in Mail.app text snippet
3595 // previews.
3596 "\n",
3599 $headers_html = phutil_tag(
3600 'table',
3601 array(),
3602 phutil_tag('tr', array(), array($header_action, $header_button)));
3604 $body->addRawHTMLSection($headers_html);
3606 foreach ($comments as $spec) {
3607 $xaction = $spec['xaction'];
3608 $comment = $spec['comment'];
3609 $is_initial = $spec['initial'];
3611 // If this is not the first comment in the mail, add the header showing
3612 // who wrote the comment immediately above the comment.
3613 if (!$is_initial) {
3614 $header = $this->getTitleForTextMail($xaction);
3615 if ($header !== null) {
3616 $body->addRawPlaintextSection($header);
3619 $header_html = $this->getTitleForHTMLMail($xaction);
3620 if ($header_html !== null) {
3621 $body->addRawHTMLSection($header_html);
3625 $body->addRemarkupSection(null, $comment);
3628 foreach ($details as $xaction) {
3629 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
3630 if ($details !== null) {
3631 $label = $this->getMailDiffSectionHeader($xaction);
3632 $body->addHTMLSection($label, $details);
3638 private function getMailDiffSectionHeader($xaction) {
3639 $type = $xaction->getTransactionType();
3641 $xtype = $this->getModularTransactionType($type);
3642 if ($xtype) {
3643 return $xtype->getMailDiffSectionHeader();
3646 return pht('EDIT DETAILS');
3650 * @task mail
3652 protected function addCustomFieldsToMailBody(
3653 PhabricatorMetaMTAMailBody $body,
3654 PhabricatorLiskDAO $object,
3655 array $xactions) {
3657 if ($object instanceof PhabricatorCustomFieldInterface) {
3658 $field_list = PhabricatorCustomField::getObjectFields(
3659 $object,
3660 PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
3661 $field_list->setViewer($this->getActor());
3662 $field_list->readFieldsFromStorage($object);
3664 foreach ($field_list->getFields() as $field) {
3665 $field->updateTransactionMailBody(
3666 $body,
3667 $this,
3668 $xactions);
3675 * @task mail
3677 private function runHeraldMailRules(array $messages) {
3678 foreach ($messages as $message) {
3679 $engine = new HeraldEngine();
3680 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
3681 ->setObject($message);
3683 $rules = $engine->loadRulesForAdapter($adapter);
3684 $effects = $engine->applyRules($rules, $adapter);
3685 $engine->applyEffects($effects, $adapter, $rules);
3690 /* -( Publishing Feed Stories )-------------------------------------------- */
3694 * @task feed
3696 protected function shouldPublishFeedStory(
3697 PhabricatorLiskDAO $object,
3698 array $xactions) {
3699 return false;
3704 * @task feed
3706 protected function getFeedStoryType() {
3707 return 'PhabricatorApplicationTransactionFeedStory';
3712 * @task feed
3714 protected function getFeedRelatedPHIDs(
3715 PhabricatorLiskDAO $object,
3716 array $xactions) {
3718 $phids = array(
3719 $object->getPHID(),
3720 $this->getActingAsPHID(),
3723 if ($object instanceof PhabricatorProjectInterface) {
3724 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3725 $object->getPHID(),
3726 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3727 foreach ($project_phids as $project_phid) {
3728 $phids[] = $project_phid;
3732 return $phids;
3737 * @task feed
3739 protected function getFeedNotifyPHIDs(
3740 PhabricatorLiskDAO $object,
3741 array $xactions) {
3743 // If some transactions are forcing notification delivery, add the forced
3744 // recipients to the notify list.
3745 $force_list = array();
3746 foreach ($xactions as $xaction) {
3747 $force_phids = $xaction->getForceNotifyPHIDs();
3749 if (!$force_phids) {
3750 continue;
3753 foreach ($force_phids as $force_phid) {
3754 $force_list[] = $force_phid;
3758 $to_list = $this->getMailTo($object);
3759 $cc_list = $this->getMailCC($object);
3761 $full_list = array_merge($force_list, $to_list, $cc_list);
3762 $full_list = array_fuse($full_list);
3764 return array_keys($full_list);
3769 * @task feed
3771 protected function getFeedStoryData(
3772 PhabricatorLiskDAO $object,
3773 array $xactions) {
3775 $xactions = msortv($xactions, 'newActionStrengthSortVector');
3777 return array(
3778 'objectPHID' => $object->getPHID(),
3779 'transactionPHIDs' => mpull($xactions, 'getPHID'),
3785 * @task feed
3787 protected function publishFeedStory(
3788 PhabricatorLiskDAO $object,
3789 array $xactions,
3790 array $mailed_phids) {
3792 // Remove transactions which don't publish feed stories or notifications.
3793 // These never show up anywhere, so we don't need to do anything with them.
3794 foreach ($xactions as $key => $xaction) {
3795 if (!$xaction->shouldHideForFeed()) {
3796 continue;
3799 if (!$xaction->shouldHideForNotifications()) {
3800 continue;
3803 unset($xactions[$key]);
3806 if (!$xactions) {
3807 return;
3810 $related_phids = $this->feedRelatedPHIDs;
3811 $subscribed_phids = $this->feedNotifyPHIDs;
3813 // Remove muted users from the subscription list so they don't get
3814 // notifications, either.
3815 $muted_phids = $this->mailMutedPHIDs;
3816 if (!is_array($muted_phids)) {
3817 $muted_phids = array();
3819 $subscribed_phids = array_fuse($subscribed_phids);
3820 foreach ($muted_phids as $muted_phid) {
3821 unset($subscribed_phids[$muted_phid]);
3823 $subscribed_phids = array_values($subscribed_phids);
3825 $story_type = $this->getFeedStoryType();
3826 $story_data = $this->getFeedStoryData($object, $xactions);
3828 $unexpandable_phids = $this->mailUnexpandablePHIDs;
3829 if (!is_array($unexpandable_phids)) {
3830 $unexpandable_phids = array();
3833 id(new PhabricatorFeedStoryPublisher())
3834 ->setStoryType($story_type)
3835 ->setStoryData($story_data)
3836 ->setStoryTime(time())
3837 ->setStoryAuthorPHID($this->getActingAsPHID())
3838 ->setRelatedPHIDs($related_phids)
3839 ->setPrimaryObjectPHID($object->getPHID())
3840 ->setSubscribedPHIDs($subscribed_phids)
3841 ->setUnexpandablePHIDs($unexpandable_phids)
3842 ->setMailRecipientPHIDs($mailed_phids)
3843 ->setMailTags($this->getMailTags($object, $xactions))
3844 ->publish();
3848 /* -( Search Index )------------------------------------------------------- */
3852 * @task search
3854 protected function supportsSearch() {
3855 return false;
3859 /* -( Herald Integration )-------------------------------------------------- */
3862 protected function shouldApplyHeraldRules(
3863 PhabricatorLiskDAO $object,
3864 array $xactions) {
3865 return false;
3868 protected function buildHeraldAdapter(
3869 PhabricatorLiskDAO $object,
3870 array $xactions) {
3871 throw new Exception(pht('No herald adapter specified.'));
3874 private function setHeraldAdapter(HeraldAdapter $adapter) {
3875 $this->heraldAdapter = $adapter;
3876 return $this;
3879 protected function getHeraldAdapter() {
3880 return $this->heraldAdapter;
3883 private function setHeraldTranscript(HeraldTranscript $transcript) {
3884 $this->heraldTranscript = $transcript;
3885 return $this;
3888 protected function getHeraldTranscript() {
3889 return $this->heraldTranscript;
3892 private function applyHeraldRules(
3893 PhabricatorLiskDAO $object,
3894 array $xactions) {
3896 $adapter = $this->buildHeraldAdapter($object, $xactions)
3897 ->setContentSource($this->getContentSource())
3898 ->setIsNewObject($this->getIsNewObject())
3899 ->setActingAsPHID($this->getActingAsPHID())
3900 ->setAppliedTransactions($xactions);
3902 if ($this->getApplicationEmail()) {
3903 $adapter->setApplicationEmail($this->getApplicationEmail());
3906 // If this editor is operating in silent mode, tell Herald that we aren't
3907 // going to send any mail. This allows it to skip "the first time this
3908 // rule matches, send me an email" rules which would otherwise match even
3909 // though we aren't going to send any mail.
3910 if ($this->getIsSilent()) {
3911 $adapter->setForbiddenAction(
3912 HeraldMailableState::STATECONST,
3913 HeraldCoreStateReasons::REASON_SILENT);
3916 $xscript = HeraldEngine::loadAndApplyRules($adapter);
3918 $this->setHeraldAdapter($adapter);
3919 $this->setHeraldTranscript($xscript);
3921 if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
3922 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
3924 HarbormasterBuildable::applyBuildPlans(
3925 $buildable_phid,
3926 $adapter->getHarbormasterContainerPHID(),
3927 $adapter->getQueuedHarbormasterBuildRequests());
3929 // Whether we queued any builds or not, any automatic buildable for this
3930 // object is now done preparing builds and can transition into a
3931 // completed status.
3932 $buildables = id(new HarbormasterBuildableQuery())
3933 ->setViewer(PhabricatorUser::getOmnipotentUser())
3934 ->withManualBuildables(false)
3935 ->withBuildablePHIDs(array($buildable_phid))
3936 ->execute();
3937 foreach ($buildables as $buildable) {
3938 // If this buildable has already moved beyond preparation, we don't
3939 // need to nudge it again.
3940 if (!$buildable->isPreparing()) {
3941 continue;
3943 $buildable->sendMessage(
3944 $this->getActor(),
3945 HarbormasterMessageType::BUILDABLE_BUILD,
3946 true);
3950 $this->mustEncrypt = $adapter->getMustEncryptReasons();
3952 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
3953 foreach ($this->subEditors as $sub_editor) {
3954 $sub_editor->mustEncrypt = $this->mustEncrypt;
3957 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
3958 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
3960 $queue_xactions = $adapter->getQueuedTransactions();
3962 return array_merge(
3963 array_values($apply_xactions),
3964 array_values($queue_xactions));
3967 protected function didApplyHeraldRules(
3968 PhabricatorLiskDAO $object,
3969 HeraldAdapter $adapter,
3970 HeraldTranscript $transcript) {
3971 return array();
3975 /* -( Custom Fields )------------------------------------------------------ */
3979 * @task customfield
3981 private function getCustomFieldForTransaction(
3982 PhabricatorLiskDAO $object,
3983 PhabricatorApplicationTransaction $xaction) {
3985 $field_key = $xaction->getMetadataValue('customfield:key');
3986 if (!$field_key) {
3987 throw new Exception(
3988 pht(
3989 "Custom field transaction has no '%s'!",
3990 'customfield:key'));
3993 $field = PhabricatorCustomField::getObjectField(
3994 $object,
3995 PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
3996 $field_key);
3998 if (!$field) {
3999 throw new Exception(
4000 pht(
4001 "Custom field transaction has invalid '%s'; field '%s' ".
4002 "is disabled or does not exist.",
4003 'customfield:key',
4004 $field_key));
4007 if (!$field->shouldAppearInApplicationTransactions()) {
4008 throw new Exception(
4009 pht(
4010 "Custom field transaction '%s' does not implement ".
4011 "integration for %s.",
4012 $field_key,
4013 'ApplicationTransactions'));
4016 $field->setViewer($this->getActor());
4018 return $field;
4022 /* -( Files )-------------------------------------------------------------- */
4026 * Extract the PHIDs of any files which these transactions attach.
4028 * @task files
4030 private function extractFilePHIDs(
4031 PhabricatorLiskDAO $object,
4032 array $xactions) {
4034 $changes = $this->getRemarkupChanges($xactions);
4035 $blocks = mpull($changes, 'getNewValue');
4037 $phids = array();
4038 if ($blocks) {
4039 $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
4040 $this->getActor(),
4041 $blocks);
4044 foreach ($xactions as $xaction) {
4045 $type = $xaction->getTransactionType();
4047 $xtype = $this->getModularTransactionType($type);
4048 if ($xtype) {
4049 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4050 } else {
4051 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4052 $object,
4053 $xaction);
4057 $phids = array_unique(array_filter(array_mergev($phids)));
4058 if (!$phids) {
4059 return array();
4062 // Only let a user attach files they can actually see, since this would
4063 // otherwise let you access any file by attaching it to an object you have
4064 // view permission on.
4066 $files = id(new PhabricatorFileQuery())
4067 ->setViewer($this->getActor())
4068 ->withPHIDs($phids)
4069 ->execute();
4071 return mpull($files, 'getPHID');
4075 * @task files
4077 protected function extractFilePHIDsFromCustomTransaction(
4078 PhabricatorLiskDAO $object,
4079 PhabricatorApplicationTransaction $xaction) {
4080 return array();
4085 * @task files
4087 private function attachFiles(
4088 PhabricatorLiskDAO $object,
4089 array $file_phids) {
4091 if (!$file_phids) {
4092 return;
4095 $editor = new PhabricatorEdgeEditor();
4097 $src = $object->getPHID();
4098 $type = PhabricatorObjectHasFileEdgeType::EDGECONST;
4099 foreach ($file_phids as $dst) {
4100 $editor->addEdge($src, $type, $dst);
4103 $editor->save();
4106 private function applyInverseEdgeTransactions(
4107 PhabricatorLiskDAO $object,
4108 PhabricatorApplicationTransaction $xaction,
4109 $inverse_type) {
4111 $old = $xaction->getOldValue();
4112 $new = $xaction->getNewValue();
4114 $add = array_keys(array_diff_key($new, $old));
4115 $rem = array_keys(array_diff_key($old, $new));
4117 $add = array_fuse($add);
4118 $rem = array_fuse($rem);
4119 $all = $add + $rem;
4121 $nodes = id(new PhabricatorObjectQuery())
4122 ->setViewer($this->requireActor())
4123 ->withPHIDs($all)
4124 ->execute();
4126 $object_phid = $object->getPHID();
4128 foreach ($nodes as $node) {
4129 if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
4130 continue;
4133 if ($node instanceof PhabricatorUser) {
4134 // TODO: At least for now, don't record inverse edge transactions
4135 // for users (for example, "alincoln joined project X"): Feed fills
4136 // this role instead.
4137 continue;
4140 $node_phid = $node->getPHID();
4141 $editor = $node->getApplicationTransactionEditor();
4142 $template = $node->getApplicationTransactionTemplate();
4144 // See T13082. We have to build these transactions with synthetic values
4145 // because we've already applied the actual edit to the edge database
4146 // table. If we try to apply this transaction naturally, it will no-op
4147 // itself because it doesn't have any effect.
4149 $edge_query = id(new PhabricatorEdgeQuery())
4150 ->withSourcePHIDs(array($node_phid))
4151 ->withEdgeTypes(array($inverse_type));
4153 $edge_query->execute();
4155 $edge_phids = $edge_query->getDestinationPHIDs();
4156 $edge_phids = array_fuse($edge_phids);
4158 $new_phids = $edge_phids;
4159 $old_phids = $edge_phids;
4161 if (isset($add[$node_phid])) {
4162 unset($old_phids[$object_phid]);
4163 } else {
4164 $old_phids[$object_phid] = $object_phid;
4167 $template
4168 ->setTransactionType($xaction->getTransactionType())
4169 ->setMetadataValue('edge:type', $inverse_type)
4170 ->setOldValue($old_phids)
4171 ->setNewValue($new_phids);
4173 $editor = $this->newSubEditor($editor)
4174 ->setContinueOnNoEffect(true)
4175 ->setContinueOnMissingFields(true)
4176 ->setIsInverseEdgeEditor(true);
4178 $editor->applyTransactions($node, array($template));
4183 /* -( Workers )------------------------------------------------------------ */
4187 * Load any object state which is required to publish transactions.
4189 * This hook is invoked in the main process before we compute data related
4190 * to publishing transactions (like email "To" and "CC" lists), and again in
4191 * the worker before publishing occurs.
4193 * @return object Publishable object.
4194 * @task workers
4196 protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
4197 return $object;
4202 * Convert the editor state to a serializable dictionary which can be passed
4203 * to a worker.
4205 * This data will be loaded with @{method:loadWorkerState} in the worker.
4207 * @return dict<string, wild> Serializable editor state.
4208 * @task workers
4210 private function getWorkerState() {
4211 $state = array();
4212 foreach ($this->getAutomaticStateProperties() as $property) {
4213 $state[$property] = $this->$property;
4216 $custom_state = $this->getCustomWorkerState();
4217 $custom_encoding = $this->getCustomWorkerStateEncoding();
4219 $state += array(
4220 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4221 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4222 'custom.encoding' => $custom_encoding,
4225 return $state;
4230 * Hook; return custom properties which need to be passed to workers.
4232 * @return dict<string, wild> Custom properties.
4233 * @task workers
4235 protected function getCustomWorkerState() {
4236 return array();
4241 * Hook; return storage encoding for custom properties which need to be
4242 * passed to workers.
4244 * This primarily allows binary data to be passed to workers and survive
4245 * JSON encoding.
4247 * @return dict<string, string> Property encodings.
4248 * @task workers
4250 protected function getCustomWorkerStateEncoding() {
4251 return array();
4256 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4258 * This method is used to load state when running worker operations.
4260 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4261 * @return this
4262 * @task workers
4264 final public function loadWorkerState(array $state) {
4265 foreach ($this->getAutomaticStateProperties() as $property) {
4266 $this->$property = idx($state, $property);
4269 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4270 $this->setExcludeMailRecipientPHIDs($exclude);
4272 $custom_state = idx($state, 'custom', array());
4273 $custom_encodings = idx($state, 'custom.encoding', array());
4274 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4276 $this->loadCustomWorkerState($custom);
4278 return $this;
4283 * Hook; set custom properties on the editor from data emitted by
4284 * @{method:getCustomWorkerState}.
4286 * @param dict<string, wild> Custom state,
4287 * from @{method:getCustomWorkerState}.
4288 * @return this
4289 * @task workers
4291 protected function loadCustomWorkerState(array $state) {
4292 return $this;
4297 * Get a list of object properties which should be automatically sent to
4298 * workers in the state data.
4300 * These properties will be automatically stored and loaded by the editor in
4301 * the worker.
4303 * @return list<string> List of properties.
4304 * @task workers
4306 private function getAutomaticStateProperties() {
4307 return array(
4308 'parentMessageID',
4309 'isNewObject',
4310 'heraldEmailPHIDs',
4311 'heraldForcedEmailPHIDs',
4312 'heraldHeader',
4313 'mailToPHIDs',
4314 'mailCCPHIDs',
4315 'feedNotifyPHIDs',
4316 'feedRelatedPHIDs',
4317 'feedShouldPublish',
4318 'mailShouldSend',
4319 'mustEncrypt',
4320 'mailStamps',
4321 'mailUnexpandablePHIDs',
4322 'mailMutedPHIDs',
4323 'webhookMap',
4324 'silent',
4325 'sendHistory',
4330 * Apply encodings prior to storage.
4332 * See @{method:getCustomWorkerStateEncoding}.
4334 * @param map<string, wild> Map of values to encode.
4335 * @param map<string, string> Map of encodings to apply.
4336 * @return map<string, wild> Map of encoded values.
4337 * @task workers
4339 private function encodeStateForStorage(
4340 array $state,
4341 array $encodings) {
4343 foreach ($state as $key => $value) {
4344 $encoding = idx($encodings, $key);
4345 switch ($encoding) {
4346 case self::STORAGE_ENCODING_BINARY:
4347 // The mechanics of this encoding (serialize + base64) are a little
4348 // awkward, but it allows us encode arrays and still be JSON-safe
4349 // with binary data.
4351 $value = @serialize($value);
4352 if ($value === false) {
4353 throw new Exception(
4354 pht(
4355 'Failed to serialize() value for key "%s".',
4356 $key));
4359 $value = base64_encode($value);
4360 if ($value === false) {
4361 throw new Exception(
4362 pht(
4363 'Failed to base64 encode value for key "%s".',
4364 $key));
4366 break;
4368 $state[$key] = $value;
4371 return $state;
4376 * Undo storage encoding applied when storing state.
4378 * See @{method:getCustomWorkerStateEncoding}.
4380 * @param map<string, wild> Map of encoded values.
4381 * @param map<string, string> Map of encodings.
4382 * @return map<string, wild> Map of decoded values.
4383 * @task workers
4385 private function decodeStateFromStorage(
4386 array $state,
4387 array $encodings) {
4389 foreach ($state as $key => $value) {
4390 $encoding = idx($encodings, $key);
4391 switch ($encoding) {
4392 case self::STORAGE_ENCODING_BINARY:
4393 $value = base64_decode($value);
4394 if ($value === false) {
4395 throw new Exception(
4396 pht(
4397 'Failed to base64_decode() value for key "%s".',
4398 $key));
4401 $value = unserialize($value);
4402 break;
4404 $state[$key] = $value;
4407 return $state;
4412 * Remove conflicts from a list of projects.
4414 * Objects aren't allowed to be tagged with multiple milestones in the same
4415 * group, nor projects such that one tag is the ancestor of any other tag.
4416 * If the list of PHIDs include mutually exclusive projects, remove the
4417 * conflicting projects.
4419 * @param list<phid> List of project PHIDs.
4420 * @return list<phid> List with conflicts removed.
4422 private function applyProjectConflictRules(array $phids) {
4423 if (!$phids) {
4424 return array();
4427 // Overall, the last project in the list wins in cases of conflict (so when
4428 // you add something, the thing you just added sticks and removes older
4429 // values).
4431 // Beyond that, there are two basic cases:
4433 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4434 // If multiple projects are milestones of the same parent, we only keep the
4435 // last one.
4437 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4438 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4439 // remove "A > B" and keep "A".
4441 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4442 // if one project is an ancestor of another. It's OK to have something
4443 // tagged with multiple projects which share a common ancestor, so long as
4444 // they are not mutual ancestors.
4446 $viewer = PhabricatorUser::getOmnipotentUser();
4448 $projects = id(new PhabricatorProjectQuery())
4449 ->setViewer($viewer)
4450 ->withPHIDs(array_keys($phids))
4451 ->execute();
4452 $projects = mpull($projects, null, 'getPHID');
4454 // We're going to build a map from each project with milestones to the last
4455 // milestone in the list. This last milestone is the milestone we'll keep.
4456 $milestone_map = array();
4458 // We're going to build a set of the projects which have no descendants
4459 // later in the list. This allows us to apply both ancestor rules.
4460 $ancestor_map = array();
4462 foreach ($phids as $phid => $ignored) {
4463 $project = idx($projects, $phid);
4464 if (!$project) {
4465 continue;
4468 // This is the last milestone we've seen, so set it as the selection for
4469 // the project's parent. This might be setting a new value or overwriting
4470 // an earlier value.
4471 if ($project->isMilestone()) {
4472 $parent_phid = $project->getParentProjectPHID();
4473 $milestone_map[$parent_phid] = $phid;
4476 // Since this is the last item in the list we've examined so far, add it
4477 // to the set of projects with no later descendants.
4478 $ancestor_map[$phid] = $phid;
4480 // Remove any ancestors from the set, since this is a later descendant.
4481 foreach ($project->getAncestorProjects() as $ancestor) {
4482 $ancestor_phid = $ancestor->getPHID();
4483 unset($ancestor_map[$ancestor_phid]);
4487 // Now that we've built the maps, we can throw away all the projects which
4488 // have conflicts.
4489 foreach ($phids as $phid => $ignored) {
4490 $project = idx($projects, $phid);
4492 if (!$project) {
4493 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4494 // but leaving it untouched is less likely to cause collateral damage.
4495 continue;
4498 // If this was a milestone, check if it was the last milestone from its
4499 // group in the list. If not, remove it from the list.
4500 if ($project->isMilestone()) {
4501 $parent_phid = $project->getParentProjectPHID();
4502 if ($milestone_map[$parent_phid] !== $phid) {
4503 unset($phids[$phid]);
4504 continue;
4508 // If a later project in the list is a subproject of this one, it will
4509 // have removed ancestors from the map. If this project does not point
4510 // at itself in the ancestor map, it should be discarded in favor of a
4511 // subproject that comes later.
4512 if (idx($ancestor_map, $phid) !== $phid) {
4513 unset($phids[$phid]);
4514 continue;
4517 // If a later project in the list is an ancestor of this one, it will
4518 // have added itself to the map. If any ancestor of this project points
4519 // at itself in the map, this project should be discarded in favor of
4520 // that later ancestor.
4521 foreach ($project->getAncestorProjects() as $ancestor) {
4522 $ancestor_phid = $ancestor->getPHID();
4523 if (isset($ancestor_map[$ancestor_phid])) {
4524 unset($phids[$phid]);
4525 continue 2;
4530 return $phids;
4534 * When the view policy for an object is changed, scramble the secret keys
4535 * for attached files to invalidate existing URIs.
4537 private function scrambleFileSecrets($object) {
4538 // If this is a newly created object, we don't need to scramble anything
4539 // since it couldn't have been previously published.
4540 if ($this->getIsNewObject()) {
4541 return;
4544 // If the object is a file itself, scramble it.
4545 if ($object instanceof PhabricatorFile) {
4546 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4547 $object->scrambleSecret();
4548 $object->save();
4552 $phid = $object->getPHID();
4554 $attached_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
4555 $phid,
4556 PhabricatorObjectHasFileEdgeType::EDGECONST);
4557 if (!$attached_phids) {
4558 return;
4561 $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
4563 $files = id(new PhabricatorFileQuery())
4564 ->setViewer($omnipotent_viewer)
4565 ->withPHIDs($attached_phids)
4566 ->execute();
4567 foreach ($files as $file) {
4568 $view_policy = $file->getViewPolicy();
4569 if ($this->shouldScramblePolicy($view_policy)) {
4570 $file->scrambleSecret();
4571 $file->save();
4578 * Check if a policy is strong enough to justify scrambling. Objects which
4579 * are set to very open policies don't need to scramble their files, and
4580 * files with very open policies don't need to be scrambled when associated
4581 * objects change.
4583 private function shouldScramblePolicy($policy) {
4584 switch ($policy) {
4585 case PhabricatorPolicies::POLICY_PUBLIC:
4586 case PhabricatorPolicies::POLICY_USER:
4587 return false;
4590 return true;
4593 private function updateWorkboardColumns($object, $const, $old, $new) {
4594 // If an object is removed from a project, remove it from any proxy
4595 // columns for that project. This allows a task which is moved up from a
4596 // milestone to the parent to move back into the "Backlog" column on the
4597 // parent workboard.
4599 if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
4600 return;
4603 // TODO: This should likely be some future WorkboardInterface.
4604 $appears_on_workboards = ($object instanceof ManiphestTask);
4605 if (!$appears_on_workboards) {
4606 return;
4609 $removed_phids = array_keys(array_diff_key($old, $new));
4610 if (!$removed_phids) {
4611 return;
4614 // Find any proxy columns for the removed projects.
4615 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4616 ->setViewer(PhabricatorUser::getOmnipotentUser())
4617 ->withProxyPHIDs($removed_phids)
4618 ->execute();
4619 if (!$proxy_columns) {
4620 return array();
4623 $proxy_phids = mpull($proxy_columns, 'getPHID');
4625 $position_table = new PhabricatorProjectColumnPosition();
4626 $conn_w = $position_table->establishConnection('w');
4628 queryfx(
4629 $conn_w,
4630 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4631 $position_table->getTableName(),
4632 $object->getPHID(),
4633 $proxy_phids);
4636 private function getModularTransactionTypes() {
4637 if ($this->modularTypes === null) {
4638 $template = $this->object->getApplicationTransactionTemplate();
4639 if ($template instanceof PhabricatorModularTransaction) {
4640 $xtypes = $template->newModularTransactionTypes();
4641 foreach ($xtypes as $key => $xtype) {
4642 $xtype = clone $xtype;
4643 $xtype->setEditor($this);
4644 $xtypes[$key] = $xtype;
4646 } else {
4647 $xtypes = array();
4650 $this->modularTypes = $xtypes;
4653 return $this->modularTypes;
4656 private function getModularTransactionType($type) {
4657 $types = $this->getModularTransactionTypes();
4658 return idx($types, $type);
4661 public function getCreateObjectTitle($author, $object) {
4662 return pht('%s created this object.', $author);
4665 public function getCreateObjectTitleForFeed($author, $object) {
4666 return pht('%s created an object: %s.', $author, $object);
4669 /* -( Queue )-------------------------------------------------------------- */
4671 protected function queueTransaction(
4672 PhabricatorApplicationTransaction $xaction) {
4673 $this->transactionQueue[] = $xaction;
4674 return $this;
4677 private function flushTransactionQueue($object) {
4678 if (!$this->transactionQueue) {
4679 return;
4682 $xactions = $this->transactionQueue;
4683 $this->transactionQueue = array();
4685 $editor = $this->newEditorCopy();
4687 return $editor->applyTransactions($object, $xactions);
4690 final protected function newSubEditor(
4691 PhabricatorApplicationTransactionEditor $template = null) {
4692 $editor = $this->newEditorCopy($template);
4694 $editor->parentEditor = $this;
4695 $this->subEditors[] = $editor;
4697 return $editor;
4700 private function newEditorCopy(
4701 PhabricatorApplicationTransactionEditor $template = null) {
4702 if ($template === null) {
4703 $template = newv(get_class($this), array());
4706 $editor = id(clone $template)
4707 ->setActor($this->getActor())
4708 ->setContentSource($this->getContentSource())
4709 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
4710 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
4711 ->setParentMessageID($this->getParentMessageID())
4712 ->setIsSilent($this->getIsSilent());
4714 if ($this->actingAsPHID !== null) {
4715 $editor->setActingAsPHID($this->actingAsPHID);
4718 $editor->mustEncrypt = $this->mustEncrypt;
4719 $editor->transactionGroupID = $this->getTransactionGroupID();
4721 return $editor;
4725 /* -( Stamps )------------------------------------------------------------- */
4728 public function newMailStampTemplates($object) {
4729 $actor = $this->getActor();
4731 $templates = array();
4733 $extensions = $this->newMailExtensions($object);
4734 foreach ($extensions as $extension) {
4735 $stamps = $extension->newMailStampTemplates($object);
4736 foreach ($stamps as $stamp) {
4737 $key = $stamp->getKey();
4738 if (isset($templates[$key])) {
4739 throw new Exception(
4740 pht(
4741 'Mail extension ("%s") defines a stamp template with the '.
4742 'same key ("%s") as another template. Each stamp template '.
4743 'must have a unique key.',
4744 get_class($extension),
4745 $key));
4748 $stamp->setViewer($actor);
4750 $templates[$key] = $stamp;
4754 return $templates;
4757 final public function getMailStamp($key) {
4758 if (!isset($this->stampTemplates)) {
4759 throw new PhutilInvalidStateException('newMailStampTemplates');
4762 if (!isset($this->stampTemplates[$key])) {
4763 throw new Exception(
4764 pht(
4765 'Editor ("%s") has no mail stamp template with provided key ("%s").',
4766 get_class($this),
4767 $key));
4770 return $this->stampTemplates[$key];
4773 private function newMailStamps($object, array $xactions) {
4774 $actor = $this->getActor();
4776 $this->stampTemplates = $this->newMailStampTemplates($object);
4778 $extensions = $this->newMailExtensions($object);
4779 $stamps = array();
4780 foreach ($extensions as $extension) {
4781 $extension->newMailStamps($object, $xactions);
4784 return $this->stampTemplates;
4787 private function newMailExtensions($object) {
4788 $actor = $this->getActor();
4790 $all_extensions = PhabricatorMailEngineExtension::getAllExtensions();
4792 $extensions = array();
4793 foreach ($all_extensions as $key => $template) {
4794 $extension = id(clone $template)
4795 ->setViewer($actor)
4796 ->setEditor($this);
4798 if ($extension->supportsObject($object)) {
4799 $extensions[$key] = $extension;
4803 return $extensions;
4806 protected function newAuxiliaryMail($object, array $xactions) {
4807 return array();
4810 private function generateMailStamps($object, $data) {
4811 if (!$data || !is_array($data)) {
4812 return null;
4815 $templates = $this->newMailStampTemplates($object);
4816 foreach ($data as $spec) {
4817 if (!is_array($spec)) {
4818 continue;
4821 $key = idx($spec, 'key');
4822 if (!isset($templates[$key])) {
4823 continue;
4826 $type = idx($spec, 'type');
4827 if ($templates[$key]->getStampType() !== $type) {
4828 continue;
4831 $value = idx($spec, 'value');
4832 $templates[$key]->setValueFromDictionary($value);
4835 $results = array();
4836 foreach ($templates as $template) {
4837 $value = $template->getValueForRendering();
4839 $rendered = $template->renderStamps($value);
4840 if ($rendered === null) {
4841 continue;
4844 $rendered = (array)$rendered;
4845 foreach ($rendered as $stamp) {
4846 $results[] = $stamp;
4850 natcasesort($results);
4852 return $results;
4855 public function getRemovedRecipientPHIDs() {
4856 return $this->mailRemovedPHIDs;
4859 private function buildOldRecipientLists($object, $xactions) {
4860 // See T4776. Before we start making any changes, build a list of the old
4861 // recipients. If a change removes a user from the recipient list for an
4862 // object we still want to notify the user about that change. This allows
4863 // them to respond if they didn't want to be removed.
4865 if (!$this->shouldSendMail($object, $xactions)) {
4866 return;
4869 $this->oldTo = $this->getMailTo($object);
4870 $this->oldCC = $this->getMailCC($object);
4872 return $this;
4875 private function applyOldRecipientLists() {
4876 $actor_phid = $this->getActingAsPHID();
4878 // If you took yourself off the recipient list (for example, by
4879 // unsubscribing or resigning) assume that you know what you did and
4880 // don't need to be notified.
4882 // If you just moved from "To" to "Cc" (or vice versa), you're still a
4883 // recipient so we don't need to add you back in.
4885 $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs);
4887 foreach ($this->oldTo as $phid) {
4888 if ($phid === $actor_phid) {
4889 continue;
4892 if (isset($map[$phid])) {
4893 continue;
4896 $this->mailToPHIDs[] = $phid;
4897 $this->mailRemovedPHIDs[] = $phid;
4900 foreach ($this->oldCC as $phid) {
4901 if ($phid === $actor_phid) {
4902 continue;
4905 if (isset($map[$phid])) {
4906 continue;
4909 $this->mailCCPHIDs[] = $phid;
4910 $this->mailRemovedPHIDs[] = $phid;
4913 return $this;
4916 private function queueWebhooks($object, array $xactions) {
4917 $hook_viewer = PhabricatorUser::getOmnipotentUser();
4919 $webhook_map = $this->webhookMap;
4920 if (!is_array($webhook_map)) {
4921 $webhook_map = array();
4924 // Add any "Firehose" hooks to the list of hooks we're going to call.
4925 $firehose_hooks = id(new HeraldWebhookQuery())
4926 ->setViewer($hook_viewer)
4927 ->withStatuses(
4928 array(
4929 HeraldWebhook::HOOKSTATUS_FIREHOSE,
4931 ->execute();
4932 foreach ($firehose_hooks as $firehose_hook) {
4933 // This is "the hook itself is the reason this hook is being called",
4934 // since we're including it because it's configured as a firehose
4935 // hook.
4936 $hook_phid = $firehose_hook->getPHID();
4937 $webhook_map[$hook_phid][] = $hook_phid;
4940 if (!$webhook_map) {
4941 return;
4944 // NOTE: We're going to queue calls to disabled webhooks, they'll just
4945 // immediately fail in the worker queue. This makes the behavior more
4946 // visible.
4948 $call_hooks = id(new HeraldWebhookQuery())
4949 ->setViewer($hook_viewer)
4950 ->withPHIDs(array_keys($webhook_map))
4951 ->execute();
4953 foreach ($call_hooks as $call_hook) {
4954 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
4956 $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook)
4957 ->setObjectPHID($object->getPHID())
4958 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
4959 ->setTriggerPHIDs($trigger_phids)
4960 ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER)
4961 ->setIsSilentAction((bool)$this->getIsSilent())
4962 ->setIsSecureAction((bool)$this->getMustEncrypt())
4963 ->save();
4965 $request->queueCall();
4969 private function hasWarnings($object, $xaction) {
4970 // TODO: For the moment, this is a very un-modular hack to support
4971 // a small number of warnings related to draft revisions. See PHI433.
4973 if (!($object instanceof DifferentialRevision)) {
4974 return false;
4977 $type = $xaction->getTransactionType();
4979 // TODO: This doesn't warn for inlines in Audit, even though they have
4980 // the same overall workflow.
4981 if ($type === DifferentialTransaction::TYPE_INLINE) {
4982 return (bool)$xaction->getComment()->getAttribute('editing', false);
4985 if (!$object->isDraft()) {
4986 return false;
4989 if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
4990 return false;
4993 // We're only going to raise a warning if the transaction adds subscribers
4994 // other than the acting user. (This implementation is clumsy because the
4995 // code runs before a lot of normalization occurs.)
4997 $old = $this->getTransactionOldValue($object, $xaction);
4998 $new = $this->getPHIDTransactionNewValue($xaction, $old);
4999 $old = array_fuse($old);
5000 $new = array_fuse($new);
5001 $add = array_diff_key($new, $old);
5003 unset($add[$this->getActingAsPHID()]);
5005 if (!$add) {
5006 return false;
5009 return true;
5012 private function buildHistoryMail(PhabricatorLiskDAO $object) {
5013 $viewer = $this->requireActor();
5014 $recipient_phid = $this->getActingAsPHID();
5016 // Load every transaction so we can build a mail message with a complete
5017 // history for the object.
5018 $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object);
5019 $xactions = $query
5020 ->setViewer($viewer)
5021 ->withObjectPHIDs(array($object->getPHID()))
5022 ->execute();
5023 $xactions = array_reverse($xactions);
5025 $mail_messages = $this->buildMailWithRecipients(
5026 $object,
5027 $xactions,
5028 array($recipient_phid),
5029 array(),
5030 array());
5031 $mail = head($mail_messages);
5033 // Since the user explicitly requested "!history", force delivery of this
5034 // message regardless of their other mail settings.
5035 $mail->setForceDelivery(true);
5037 return $mail;
5040 public function newAutomaticInlineTransactions(
5041 PhabricatorLiskDAO $object,
5042 $transaction_type,
5043 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5045 $actor = $this->getActor();
5047 $inlines = id(clone $query_template)
5048 ->setViewer($actor)
5049 ->withObjectPHIDs(array($object->getPHID()))
5050 ->withPublishableComments(true)
5051 ->needAppliedDrafts(true)
5052 ->needReplyToComments(true)
5053 ->execute();
5054 $inlines = msort($inlines, 'getID');
5056 $xactions = array();
5058 foreach ($inlines as $key => $inline) {
5059 $xactions[] = $object->getApplicationTransactionTemplate()
5060 ->setTransactionType($transaction_type)
5061 ->attachComment($inline);
5064 $state_xaction = $this->newInlineStateTransaction(
5065 $object,
5066 $query_template);
5068 if ($state_xaction) {
5069 $xactions[] = $state_xaction;
5072 return $xactions;
5075 protected function newInlineStateTransaction(
5076 PhabricatorLiskDAO $object,
5077 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5079 $actor_phid = $this->getActingAsPHID();
5080 $author_phid = $object->getAuthorPHID();
5081 $actor_is_author = ($actor_phid == $author_phid);
5083 $state_map = PhabricatorTransactions::getInlineStateMap();
5085 $inline_query = id(clone $query_template)
5086 ->setViewer($this->getActor())
5087 ->withObjectPHIDs(array($object->getPHID()))
5088 ->withFixedStates(array_keys($state_map))
5089 ->withPublishableComments(true);
5091 if ($actor_is_author) {
5092 $inline_query->withPublishedComments(true);
5095 $inlines = $inline_query->execute();
5097 if (!$inlines) {
5098 return null;
5101 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5102 $new_value = array();
5103 foreach ($old_value as $key => $state) {
5104 $new_value[$key] = $state_map[$state];
5107 // See PHI995. Copy some information about the inlines into the transaction
5108 // so we can tailor rendering behavior. In particular, we don't want to
5109 // render transactions about users marking their own inlines as "Done".
5111 $inline_details = array();
5112 foreach ($inlines as $inline) {
5113 $inline_details[$inline->getPHID()] = array(
5114 'authorPHID' => $inline->getAuthorPHID(),
5118 return $object->getApplicationTransactionTemplate()
5119 ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE)
5120 ->setIgnoreOnNoEffect(true)
5121 ->setMetadataValue('inline.details', $inline_details)
5122 ->setOldValue($old_value)
5123 ->setNewValue($new_value);
5126 private function requireMFA(PhabricatorLiskDAO $object, array $xactions) {
5127 $actor = $this->getActor();
5129 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5130 if ($actor->isOmnipotent()) {
5131 return;
5134 $editor_class = get_class($this);
5136 $object_phid = $object->getPHID();
5137 if ($object_phid) {
5138 $workflow_key = sprintf(
5139 'editor(%s).phid(%s)',
5140 $editor_class,
5141 $object_phid);
5142 } else {
5143 $workflow_key = sprintf(
5144 'editor(%s).new()',
5145 $editor_class);
5148 $request = $this->getRequest();
5149 if ($request === null) {
5150 $source_type = $this->getContentSource()->getSourceTypeConstant();
5151 $conduit_type = PhabricatorConduitContentSource::SOURCECONST;
5152 $is_conduit = ($source_type === $conduit_type);
5153 if ($is_conduit) {
5154 throw new Exception(
5155 pht(
5156 'This transaction group requires MFA to apply, but you can not '.
5157 'provide an MFA response via Conduit. Edit this object via the '.
5158 'web UI.'));
5159 } else {
5160 throw new Exception(
5161 pht(
5162 'This transaction group requires MFA to apply, but the Editor was '.
5163 'not configured with a Request. This workflow can not perform an '.
5164 'MFA check.'));
5168 $cancel_uri = $this->getCancelURI();
5169 if ($cancel_uri === null) {
5170 throw new Exception(
5171 pht(
5172 'This transaction group requires MFA to apply, but the Editor was '.
5173 'not configured with a Cancel URI. This workflow can not perform '.
5174 'an MFA check.'));
5177 $token = id(new PhabricatorAuthSessionEngine())
5178 ->setWorkflowKey($workflow_key)
5179 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5181 if (!$token->getIsUnchallengedToken()) {
5182 foreach ($xactions as $xaction) {
5183 $xaction->setIsMFATransaction(true);
5188 private function newMFATransactions(
5189 PhabricatorLiskDAO $object,
5190 array $xactions) {
5192 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
5193 if ($has_engine) {
5194 $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
5195 ->setViewer($this->getActor());
5196 $require_mfa = $engine->shouldRequireMFA();
5197 $try_mfa = $engine->shouldTryMFA();
5198 } else {
5199 $require_mfa = false;
5200 $try_mfa = false;
5203 // If the user is mentioning an MFA object on another object or creating
5204 // a relationship like "parent" or "child" to this object, we always
5205 // allow the edit to move forward without requiring MFA.
5206 if ($this->getIsInverseEdgeEditor()) {
5207 return $xactions;
5210 if (!$require_mfa) {
5211 // If the object hasn't already opted into MFA, see if any of the
5212 // transactions want it.
5213 if (!$try_mfa) {
5214 foreach ($xactions as $xaction) {
5215 $type = $xaction->getTransactionType();
5217 $xtype = $this->getModularTransactionType($type);
5218 if ($xtype) {
5219 $xtype = clone $xtype;
5220 $xtype->setStorage($xaction);
5221 if ($xtype->shouldTryMFA($object, $xaction)) {
5222 $try_mfa = true;
5223 break;
5229 if ($try_mfa) {
5230 $this->setShouldRequireMFA(true);
5233 return $xactions;
5236 $type_mfa = PhabricatorTransactions::TYPE_MFA;
5238 $has_mfa = false;
5239 foreach ($xactions as $xaction) {
5240 if ($xaction->getTransactionType() === $type_mfa) {
5241 $has_mfa = true;
5242 break;
5246 if ($has_mfa) {
5247 return $xactions;
5250 $template = $object->getApplicationTransactionTemplate();
5252 $mfa_xaction = id(clone $template)
5253 ->setTransactionType($type_mfa)
5254 ->setNewValue(true);
5256 array_unshift($xactions, $mfa_xaction);
5258 return $xactions;
5261 private function getTitleForTextMail(
5262 PhabricatorApplicationTransaction $xaction) {
5263 $type = $xaction->getTransactionType();
5265 $xtype = $this->getModularTransactionType($type);
5266 if ($xtype) {
5267 $xtype = clone $xtype;
5268 $xtype->setStorage($xaction);
5269 $comment = $xtype->getTitleForTextMail();
5270 if ($comment !== false) {
5271 return $comment;
5275 return $xaction->getTitleForTextMail();
5278 private function getTitleForHTMLMail(
5279 PhabricatorApplicationTransaction $xaction) {
5280 $type = $xaction->getTransactionType();
5282 $xtype = $this->getModularTransactionType($type);
5283 if ($xtype) {
5284 $xtype = clone $xtype;
5285 $xtype->setStorage($xaction);
5286 $comment = $xtype->getTitleForHTMLMail();
5287 if ($comment !== false) {
5288 return $comment;
5292 return $xaction->getTitleForHTMLMail();
5296 private function getBodyForTextMail(
5297 PhabricatorApplicationTransaction $xaction) {
5298 $type = $xaction->getTransactionType();
5300 $xtype = $this->getModularTransactionType($type);
5301 if ($xtype) {
5302 $xtype = clone $xtype;
5303 $xtype->setStorage($xaction);
5304 $comment = $xtype->getBodyForTextMail();
5305 if ($comment !== false) {
5306 return $comment;
5310 return $xaction->getBodyForMail();
5313 private function isLockOverrideTransaction(
5314 PhabricatorApplicationTransaction $xaction) {
5316 // See PHI1209. When an object is locked, certain types of transactions
5317 // can still be applied without requiring a policy check, like subscribing
5318 // or unsubscribing. We don't want these transactions to show the "Lock
5319 // Override" icon in the transaction timeline.
5321 // We could test if a transaction did no direct policy checks, but it may
5322 // have done additional policy checks during validation, so this is not a
5323 // reliable test (and could cause false negatives, where edits which did
5324 // override a lock are not marked properly).
5326 // For now, do this in a narrow way and just check against a hard-coded
5327 // list of non-override transaction situations. Some day, this should
5328 // likely be modularized.
5331 // Inverse edge edits don't interact with locks.
5332 if ($this->getIsInverseEdgeEditor()) {
5333 return false;
5336 // For now, all edits other than subscribes always override locks.
5337 $type = $xaction->getTransactionType();
5338 if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5339 return true;
5342 // Subscribes override locks if they affect any users other than the
5343 // acting user.
5345 $acting_phid = $this->getActingAsPHID();
5347 $old = array_fuse($xaction->getOldValue());
5348 $new = array_fuse($xaction->getNewValue());
5349 $add = array_diff_key($new, $old);
5350 $rem = array_diff_key($old, $new);
5352 $all = $add + $rem;
5353 foreach ($all as $phid) {
5354 if ($phid !== $acting_phid) {
5355 return true;
5359 return false;
5363 /* -( Extensions )--------------------------------------------------------- */
5366 private function validateTransactionsWithExtensions(
5367 PhabricatorLiskDAO $object,
5368 array $xactions) {
5369 $errors = array();
5371 $extensions = $this->getEditorExtensions();
5372 foreach ($extensions as $extension) {
5373 $extension_errors = $extension
5374 ->setObject($object)
5375 ->validateTransactions($object, $xactions);
5377 assert_instances_of(
5378 $extension_errors,
5379 'PhabricatorApplicationTransactionValidationError');
5381 $errors[] = $extension_errors;
5384 return array_mergev($errors);
5387 private function getEditorExtensions() {
5388 if ($this->extensions === null) {
5389 $this->extensions = $this->newEditorExtensions();
5391 return $this->extensions;
5394 private function newEditorExtensions() {
5395 $extensions = PhabricatorEditorExtension::getAllExtensions();
5397 $actor = $this->getActor();
5398 $object = $this->object;
5399 foreach ($extensions as $key => $extension) {
5401 $extension = id(clone $extension)
5402 ->setViewer($actor)
5403 ->setEditor($this)
5404 ->setObject($object);
5406 if (!$extension->supportsObject($this, $object)) {
5407 unset($extensions[$key]);
5408 continue;
5411 $extensions[$key] = $extension;
5414 return $extensions;