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