Generate file attachment transactions for explicit Remarkup attachments on common...
[phabricator.git] / src / applications / transactions / editor / PhabricatorApplicationTransactionEditor.php
blobb753c0429a2b0b7614921e3eb781b6a8593f30bb
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($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 $new_map = $old_map;
452 foreach ($new as $file_phid => $attachment_mode) {
453 if ($attachment_mode == PhabricatorFileAttachment::MODE_DETACH) {
454 unset($new_map[$file_phid]);
455 continue;
458 $new_map[$file_phid] = $attachment_mode;
461 foreach (array_keys($old_map + $new_map) as $key) {
462 if (isset($old_map[$key]) && isset($new_map[$key])) {
463 if ($old_map[$key] === $new_map[$key]) {
464 unset($old_map[$key]);
465 unset($new_map[$key]);
470 return array($old_map, $new_map);
473 private function getTransactionOldValue(
474 PhabricatorLiskDAO $object,
475 PhabricatorApplicationTransaction $xaction) {
477 $type = $xaction->getTransactionType();
479 $xtype = $this->getModularTransactionType($type);
480 if ($xtype) {
481 $xtype = clone $xtype;
482 $xtype->setStorage($xaction);
483 return $xtype->generateOldValue($object);
486 switch ($type) {
487 case PhabricatorTransactions::TYPE_CREATE:
488 case PhabricatorTransactions::TYPE_HISTORY:
489 return null;
490 case PhabricatorTransactions::TYPE_SUBTYPE:
491 return $object->getEditEngineSubtype();
492 case PhabricatorTransactions::TYPE_MFA:
493 return null;
494 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
495 return array_values($this->subscribers);
496 case PhabricatorTransactions::TYPE_VIEW_POLICY:
497 if ($this->getIsNewObject()) {
498 return null;
500 return $object->getViewPolicy();
501 case PhabricatorTransactions::TYPE_EDIT_POLICY:
502 if ($this->getIsNewObject()) {
503 return null;
505 return $object->getEditPolicy();
506 case PhabricatorTransactions::TYPE_JOIN_POLICY:
507 if ($this->getIsNewObject()) {
508 return null;
510 return $object->getJoinPolicy();
511 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
512 if ($this->getIsNewObject()) {
513 return null;
515 return $object->getInteractPolicy();
516 case PhabricatorTransactions::TYPE_SPACE:
517 if ($this->getIsNewObject()) {
518 return null;
521 $space_phid = $object->getSpacePHID();
522 if ($space_phid === null) {
523 $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
524 if ($default_space) {
525 $space_phid = $default_space->getPHID();
529 return $space_phid;
530 case PhabricatorTransactions::TYPE_EDGE:
531 $edge_type = $xaction->getMetadataValue('edge:type');
532 if (!$edge_type) {
533 throw new Exception(
534 pht(
535 "Edge transaction has no '%s'!",
536 'edge:type'));
539 // See T13082. If this is an inverse edit, the parent editor has
540 // already populated the transaction values correctly.
541 if ($this->getIsInverseEdgeEditor()) {
542 return $xaction->getOldValue();
545 $old_edges = array();
546 if ($object->getPHID()) {
547 $edge_src = $object->getPHID();
549 $old_edges = id(new PhabricatorEdgeQuery())
550 ->withSourcePHIDs(array($edge_src))
551 ->withEdgeTypes(array($edge_type))
552 ->needEdgeData(true)
553 ->execute();
555 $old_edges = $old_edges[$edge_src][$edge_type];
557 return $old_edges;
558 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
559 // NOTE: Custom fields have their old value pre-populated when they are
560 // built by PhabricatorCustomFieldList.
561 return $xaction->getOldValue();
562 case PhabricatorTransactions::TYPE_COMMENT:
563 return null;
564 case PhabricatorTransactions::TYPE_FILE:
565 return null;
566 default:
567 return $this->getCustomTransactionOldValue($object, $xaction);
571 private function getTransactionNewValue(
572 PhabricatorLiskDAO $object,
573 PhabricatorApplicationTransaction $xaction) {
575 $type = $xaction->getTransactionType();
577 $xtype = $this->getModularTransactionType($type);
578 if ($xtype) {
579 $xtype = clone $xtype;
580 $xtype->setStorage($xaction);
581 return $xtype->generateNewValue($object, $xaction->getNewValue());
584 switch ($type) {
585 case PhabricatorTransactions::TYPE_CREATE:
586 return null;
587 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
588 return $this->getPHIDTransactionNewValue($xaction);
589 case PhabricatorTransactions::TYPE_VIEW_POLICY:
590 case PhabricatorTransactions::TYPE_EDIT_POLICY:
591 case PhabricatorTransactions::TYPE_JOIN_POLICY:
592 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
593 case PhabricatorTransactions::TYPE_TOKEN:
594 case PhabricatorTransactions::TYPE_INLINESTATE:
595 case PhabricatorTransactions::TYPE_SUBTYPE:
596 case PhabricatorTransactions::TYPE_HISTORY:
597 case PhabricatorTransactions::TYPE_FILE:
598 return $xaction->getNewValue();
599 case PhabricatorTransactions::TYPE_MFA:
600 return true;
601 case PhabricatorTransactions::TYPE_SPACE:
602 $space_phid = $xaction->getNewValue();
603 if (!strlen($space_phid)) {
604 // If an install has no Spaces or the Spaces controls are not visible
605 // to the viewer, we might end up with the empty string here instead
606 // of a strict `null`, because some controller just used `getStr()`
607 // to read the space PHID from the request.
608 // Just make this work like callers might reasonably expect so we
609 // don't need to handle this specially in every EditController.
610 return $this->getActor()->getDefaultSpacePHID();
611 } else {
612 return $space_phid;
614 case PhabricatorTransactions::TYPE_EDGE:
615 // See T13082. If this is an inverse edit, the parent editor has
616 // already populated appropriate transaction values.
617 if ($this->getIsInverseEdgeEditor()) {
618 return $xaction->getNewValue();
621 $new_value = $this->getEdgeTransactionNewValue($xaction);
623 $edge_type = $xaction->getMetadataValue('edge:type');
624 $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
625 if ($edge_type == $type_project) {
626 $new_value = $this->applyProjectConflictRules($new_value);
629 return $new_value;
630 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
631 $field = $this->getCustomFieldForTransaction($object, $xaction);
632 return $field->getNewValueFromApplicationTransactions($xaction);
633 case PhabricatorTransactions::TYPE_COMMENT:
634 return null;
635 default:
636 return $this->getCustomTransactionNewValue($object, $xaction);
640 protected function getCustomTransactionOldValue(
641 PhabricatorLiskDAO $object,
642 PhabricatorApplicationTransaction $xaction) {
643 throw new Exception(pht('Capability not supported!'));
646 protected function getCustomTransactionNewValue(
647 PhabricatorLiskDAO $object,
648 PhabricatorApplicationTransaction $xaction) {
649 throw new Exception(pht('Capability not supported!'));
652 protected function transactionHasEffect(
653 PhabricatorLiskDAO $object,
654 PhabricatorApplicationTransaction $xaction) {
656 switch ($xaction->getTransactionType()) {
657 case PhabricatorTransactions::TYPE_CREATE:
658 case PhabricatorTransactions::TYPE_HISTORY:
659 return true;
660 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
661 $field = $this->getCustomFieldForTransaction($object, $xaction);
662 return $field->getApplicationTransactionHasEffect($xaction);
663 case PhabricatorTransactions::TYPE_EDGE:
664 // A straight value comparison here doesn't always get the right
665 // result, because newly added edges aren't fully populated. Instead,
666 // compare the changes in a more granular way.
667 $old = $xaction->getOldValue();
668 $new = $xaction->getNewValue();
670 $old_dst = array_keys($old);
671 $new_dst = array_keys($new);
673 // NOTE: For now, we don't consider edge reordering to be a change.
674 // We have very few order-dependent edges and effectively no order
675 // oriented UI. This might change in the future.
676 sort($old_dst);
677 sort($new_dst);
679 if ($old_dst !== $new_dst) {
680 // We've added or removed edges, so this transaction definitely
681 // has an effect.
682 return true;
685 // We haven't added or removed edges, but we might have changed
686 // edge data.
687 foreach ($old as $key => $old_value) {
688 $new_value = $new[$key];
689 if ($old_value['data'] !== $new_value['data']) {
690 return true;
694 return false;
697 $type = $xaction->getTransactionType();
698 $xtype = $this->getModularTransactionType($type);
699 if ($xtype) {
700 return $xtype->getTransactionHasEffect(
701 $object,
702 $xaction->getOldValue(),
703 $xaction->getNewValue());
706 if ($xaction->hasComment()) {
707 return true;
710 return ($xaction->getOldValue() !== $xaction->getNewValue());
713 protected function shouldApplyInitialEffects(
714 PhabricatorLiskDAO $object,
715 array $xactions) {
716 return false;
719 protected function applyInitialEffects(
720 PhabricatorLiskDAO $object,
721 array $xactions) {
722 throw new PhutilMethodNotImplementedException();
725 private function applyInternalEffects(
726 PhabricatorLiskDAO $object,
727 PhabricatorApplicationTransaction $xaction) {
729 $type = $xaction->getTransactionType();
731 $xtype = $this->getModularTransactionType($type);
732 if ($xtype) {
733 $xtype = clone $xtype;
734 $xtype->setStorage($xaction);
735 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
738 switch ($type) {
739 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
740 $field = $this->getCustomFieldForTransaction($object, $xaction);
741 return $field->applyApplicationTransactionInternalEffects($xaction);
742 case PhabricatorTransactions::TYPE_CREATE:
743 case PhabricatorTransactions::TYPE_HISTORY:
744 case PhabricatorTransactions::TYPE_SUBTYPE:
745 case PhabricatorTransactions::TYPE_MFA:
746 case PhabricatorTransactions::TYPE_TOKEN:
747 case PhabricatorTransactions::TYPE_VIEW_POLICY:
748 case PhabricatorTransactions::TYPE_EDIT_POLICY:
749 case PhabricatorTransactions::TYPE_JOIN_POLICY:
750 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
751 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
752 case PhabricatorTransactions::TYPE_INLINESTATE:
753 case PhabricatorTransactions::TYPE_EDGE:
754 case PhabricatorTransactions::TYPE_SPACE:
755 case PhabricatorTransactions::TYPE_COMMENT:
756 case PhabricatorTransactions::TYPE_FILE:
757 return $this->applyBuiltinInternalTransaction($object, $xaction);
760 return $this->applyCustomInternalTransaction($object, $xaction);
763 private function applyExternalEffects(
764 PhabricatorLiskDAO $object,
765 PhabricatorApplicationTransaction $xaction) {
767 $type = $xaction->getTransactionType();
769 $xtype = $this->getModularTransactionType($type);
770 if ($xtype) {
771 $xtype = clone $xtype;
772 $xtype->setStorage($xaction);
773 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
776 switch ($type) {
777 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
778 $subeditor = id(new PhabricatorSubscriptionsEditor())
779 ->setObject($object)
780 ->setActor($this->requireActor());
782 $old_map = array_fuse($xaction->getOldValue());
783 $new_map = array_fuse($xaction->getNewValue());
785 $subeditor->unsubscribe(
786 array_keys(
787 array_diff_key($old_map, $new_map)));
789 $subeditor->subscribeExplicit(
790 array_keys(
791 array_diff_key($new_map, $old_map)));
793 $subeditor->save();
795 // for the rest of these edits, subscribers should include those just
796 // added as well as those just removed.
797 $subscribers = array_unique(array_merge(
798 $this->subscribers,
799 $xaction->getOldValue(),
800 $xaction->getNewValue()));
801 $this->subscribers = $subscribers;
802 return $this->applyBuiltinExternalTransaction($object, $xaction);
804 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
805 $field = $this->getCustomFieldForTransaction($object, $xaction);
806 return $field->applyApplicationTransactionExternalEffects($xaction);
807 case PhabricatorTransactions::TYPE_CREATE:
808 case PhabricatorTransactions::TYPE_HISTORY:
809 case PhabricatorTransactions::TYPE_SUBTYPE:
810 case PhabricatorTransactions::TYPE_MFA:
811 case PhabricatorTransactions::TYPE_EDGE:
812 case PhabricatorTransactions::TYPE_TOKEN:
813 case PhabricatorTransactions::TYPE_VIEW_POLICY:
814 case PhabricatorTransactions::TYPE_EDIT_POLICY:
815 case PhabricatorTransactions::TYPE_JOIN_POLICY:
816 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
817 case PhabricatorTransactions::TYPE_INLINESTATE:
818 case PhabricatorTransactions::TYPE_SPACE:
819 case PhabricatorTransactions::TYPE_COMMENT:
820 case PhabricatorTransactions::TYPE_FILE:
821 return $this->applyBuiltinExternalTransaction($object, $xaction);
824 return $this->applyCustomExternalTransaction($object, $xaction);
827 protected function applyCustomInternalTransaction(
828 PhabricatorLiskDAO $object,
829 PhabricatorApplicationTransaction $xaction) {
830 $type = $xaction->getTransactionType();
831 throw new Exception(
832 pht(
833 "Transaction type '%s' is missing an internal apply implementation!",
834 $type));
837 protected function applyCustomExternalTransaction(
838 PhabricatorLiskDAO $object,
839 PhabricatorApplicationTransaction $xaction) {
840 $type = $xaction->getTransactionType();
841 throw new Exception(
842 pht(
843 "Transaction type '%s' is missing an external apply implementation!",
844 $type));
848 * @{class:PhabricatorTransactions} provides many built-in transactions
849 * which should not require much - if any - code in specific applications.
851 * This method is a hook for the exceedingly-rare cases where you may need
852 * to do **additional** work for built-in transactions. Developers should
853 * extend this method, making sure to return the parent implementation
854 * regardless of handling any transactions.
856 * See also @{method:applyBuiltinExternalTransaction}.
858 protected function applyBuiltinInternalTransaction(
859 PhabricatorLiskDAO $object,
860 PhabricatorApplicationTransaction $xaction) {
862 switch ($xaction->getTransactionType()) {
863 case PhabricatorTransactions::TYPE_VIEW_POLICY:
864 $object->setViewPolicy($xaction->getNewValue());
865 break;
866 case PhabricatorTransactions::TYPE_EDIT_POLICY:
867 $object->setEditPolicy($xaction->getNewValue());
868 break;
869 case PhabricatorTransactions::TYPE_JOIN_POLICY:
870 $object->setJoinPolicy($xaction->getNewValue());
871 break;
872 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
873 $object->setInteractPolicy($xaction->getNewValue());
874 break;
875 case PhabricatorTransactions::TYPE_SPACE:
876 $object->setSpacePHID($xaction->getNewValue());
877 break;
878 case PhabricatorTransactions::TYPE_SUBTYPE:
879 $object->setEditEngineSubtype($xaction->getNewValue());
880 break;
885 * See @{method::applyBuiltinInternalTransaction}.
887 protected function applyBuiltinExternalTransaction(
888 PhabricatorLiskDAO $object,
889 PhabricatorApplicationTransaction $xaction) {
891 switch ($xaction->getTransactionType()) {
892 case PhabricatorTransactions::TYPE_EDGE:
893 if ($this->getIsInverseEdgeEditor()) {
894 // If we're writing an inverse edge transaction, don't actually
895 // do anything. The initiating editor on the other side of the
896 // transaction will take care of the edge writes.
897 break;
900 $old = $xaction->getOldValue();
901 $new = $xaction->getNewValue();
902 $src = $object->getPHID();
903 $const = $xaction->getMetadataValue('edge:type');
905 foreach ($new as $dst_phid => $edge) {
906 $new[$dst_phid]['src'] = $src;
909 $editor = new PhabricatorEdgeEditor();
911 foreach ($old as $dst_phid => $edge) {
912 if (!empty($new[$dst_phid])) {
913 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
914 continue;
917 $editor->removeEdge($src, $const, $dst_phid);
920 foreach ($new as $dst_phid => $edge) {
921 if (!empty($old[$dst_phid])) {
922 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
923 continue;
927 $data = array(
928 'data' => $edge['data'],
931 $editor->addEdge($src, $const, $dst_phid, $data);
934 $editor->save();
936 $this->updateWorkboardColumns($object, $const, $old, $new);
937 break;
938 case PhabricatorTransactions::TYPE_VIEW_POLICY:
939 case PhabricatorTransactions::TYPE_SPACE:
940 $this->scrambleFileSecrets($object);
941 break;
942 case PhabricatorTransactions::TYPE_HISTORY:
943 $this->sendHistory = true;
944 break;
945 case PhabricatorTransactions::TYPE_FILE:
946 $this->applyFileTransaction($object, $xaction);
947 break;
951 private function applyFileTransaction(
952 PhabricatorLiskDAO $object,
953 PhabricatorApplicationTransaction $xaction) {
955 $old_map = $xaction->getOldValue();
956 $new_map = $xaction->getNewValue();
958 $add_phids = array();
959 $rem_phids = array();
961 foreach ($new_map as $phid => $mode) {
962 $add_phids[$phid] = $mode;
965 foreach ($old_map as $phid => $mode) {
966 if (!isset($new_map[$phid])) {
967 $rem_phids[] = $phid;
971 $now = PhabricatorTime::getNow();
972 $object_phid = $object->getPHID();
973 $attacher_phid = $this->getActingAsPHID();
975 $attachment_table = new PhabricatorFileAttachment();
976 $attachment_conn = $attachment_table->establishConnection('w');
978 $add_sql = array();
979 foreach ($add_phids as $add_phid => $add_mode) {
980 $add_sql[] = qsprintf(
981 $attachment_conn,
982 '(%s, %s, %s, %ns, %d, %d)',
983 $object_phid,
984 $add_phid,
985 $add_mode,
986 $attacher_phid,
987 $now,
988 $now);
991 $rem_sql = array();
992 foreach ($rem_phids as $rem_phid) {
993 $rem_sql[] = qsprintf(
994 $attachment_conn,
995 '%s',
996 $rem_phid);
999 foreach (PhabricatorLiskDAO::chunkSQL($add_sql) as $chunk) {
1000 queryfx(
1001 $attachment_conn,
1002 'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
1003 attacherPHID, dateCreated, dateModified)
1004 VALUES %LQ
1005 ON DUPLICATE KEY UPDATE
1006 attachmentMode = VALUES(attachmentMode),
1007 attacherPHID = VALUES(attacherPHID),
1008 dateModified = VALUES(dateModified)',
1009 $attachment_table,
1010 $chunk);
1013 foreach (PhabricatorLiskDAO::chunkSQL($rem_sql) as $chunk) {
1014 queryfx(
1015 $attachment_conn,
1016 'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
1017 $attachment_table,
1018 $object_phid,
1019 $chunk);
1024 * Fill in a transaction's common values, like author and content source.
1026 protected function populateTransaction(
1027 PhabricatorLiskDAO $object,
1028 PhabricatorApplicationTransaction $xaction) {
1030 $actor = $this->getActor();
1032 // TODO: This needs to be more sophisticated once we have meta-policies.
1033 $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
1035 if ($actor->isOmnipotent()) {
1036 $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
1037 } else {
1038 $xaction->setEditPolicy($this->getActingAsPHID());
1041 // If the transaction already has an explicit author PHID, allow it to
1042 // stand. This is used by applications like Owners that hook into the
1043 // post-apply change pipeline.
1044 if (!$xaction->getAuthorPHID()) {
1045 $xaction->setAuthorPHID($this->getActingAsPHID());
1048 $xaction->setContentSource($this->getContentSource());
1049 $xaction->attachViewer($actor);
1050 $xaction->attachObject($object);
1052 if ($object->getPHID()) {
1053 $xaction->setObjectPHID($object->getPHID());
1056 if ($this->getIsSilent()) {
1057 $xaction->setIsSilentTransaction(true);
1060 return $xaction;
1063 protected function didApplyInternalEffects(
1064 PhabricatorLiskDAO $object,
1065 array $xactions) {
1066 return $xactions;
1069 protected function applyFinalEffects(
1070 PhabricatorLiskDAO $object,
1071 array $xactions) {
1072 return $xactions;
1075 final protected function didCommitTransactions(
1076 PhabricatorLiskDAO $object,
1077 array $xactions) {
1079 foreach ($xactions as $xaction) {
1080 $type = $xaction->getTransactionType();
1082 // See T13082. When we're writing edges that imply corresponding inverse
1083 // transactions, apply those inverse transactions now. We have to wait
1084 // until the object we're editing (with this editor) has committed its
1085 // transactions to do this. If we don't, the inverse editor may race,
1086 // build a mail before we actually commit this object, and render "alice
1087 // added an edge: Unknown Object".
1089 if ($type === PhabricatorTransactions::TYPE_EDGE) {
1090 // Don't do anything if we're already an inverse edge editor.
1091 if ($this->getIsInverseEdgeEditor()) {
1092 continue;
1095 $edge_const = $xaction->getMetadataValue('edge:type');
1096 $edge_type = PhabricatorEdgeType::getByConstant($edge_const);
1097 if ($edge_type->shouldWriteInverseTransactions()) {
1098 $this->applyInverseEdgeTransactions(
1099 $object,
1100 $xaction,
1101 $edge_type->getInverseEdgeConstant());
1103 continue;
1106 $xtype = $this->getModularTransactionType($type);
1107 if (!$xtype) {
1108 continue;
1111 $xtype = clone $xtype;
1112 $xtype->setStorage($xaction);
1113 $xtype->didCommitTransaction($object, $xaction->getNewValue());
1117 public function setContentSource(PhabricatorContentSource $content_source) {
1118 $this->contentSource = $content_source;
1119 return $this;
1122 public function setContentSourceFromRequest(AphrontRequest $request) {
1123 $this->setRequest($request);
1124 return $this->setContentSource(
1125 PhabricatorContentSource::newFromRequest($request));
1128 public function getContentSource() {
1129 return $this->contentSource;
1132 public function setRequest(AphrontRequest $request) {
1133 $this->request = $request;
1134 return $this;
1137 public function getRequest() {
1138 return $this->request;
1141 public function setCancelURI($cancel_uri) {
1142 $this->cancelURI = $cancel_uri;
1143 return $this;
1146 public function getCancelURI() {
1147 return $this->cancelURI;
1150 protected function getTransactionGroupID() {
1151 if ($this->transactionGroupID === null) {
1152 $this->transactionGroupID = Filesystem::readRandomCharacters(32);
1155 return $this->transactionGroupID;
1158 final public function applyTransactions(
1159 PhabricatorLiskDAO $object,
1160 array $xactions) {
1162 $is_new = ($object->getID() === null);
1163 $this->isNewObject = $is_new;
1165 $is_preview = $this->getIsPreview();
1166 $read_locking = false;
1167 $transaction_open = false;
1169 // If we're attempting to apply transactions, lock and reload the object
1170 // before we go anywhere. If we don't do this at the very beginning, we
1171 // may be looking at an older version of the object when we populate and
1172 // filter the transactions. See PHI1165 for an example.
1174 if (!$is_preview) {
1175 if (!$is_new) {
1176 $this->buildOldRecipientLists($object, $xactions);
1178 $object->openTransaction();
1179 $transaction_open = true;
1181 $object->beginReadLocking();
1182 $read_locking = true;
1184 $object->reload();
1188 try {
1189 $this->object = $object;
1190 $this->xactions = $xactions;
1192 $this->validateEditParameters($object, $xactions);
1193 $xactions = $this->newMFATransactions($object, $xactions);
1195 $actor = $this->requireActor();
1197 // NOTE: Some transaction expansion requires that the edited object be
1198 // attached.
1199 foreach ($xactions as $xaction) {
1200 $xaction->attachObject($object);
1201 $xaction->attachViewer($actor);
1204 $xactions = $this->expandTransactions($object, $xactions);
1205 $xactions = $this->expandSupportTransactions($object, $xactions);
1206 $xactions = $this->combineTransactions($xactions);
1208 foreach ($xactions as $xaction) {
1209 $xaction = $this->populateTransaction($object, $xaction);
1212 if (!$is_preview) {
1213 $errors = array();
1214 $type_map = mgroup($xactions, 'getTransactionType');
1215 foreach ($this->getTransactionTypes() as $type) {
1216 $type_xactions = idx($type_map, $type, array());
1217 $errors[] = $this->validateTransaction(
1218 $object,
1219 $type,
1220 $type_xactions);
1223 $errors[] = $this->validateAllTransactions($object, $xactions);
1224 $errors[] = $this->validateTransactionsWithExtensions(
1225 $object,
1226 $xactions);
1227 $errors = array_mergev($errors);
1229 $continue_on_missing = $this->getContinueOnMissingFields();
1230 foreach ($errors as $key => $error) {
1231 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1232 unset($errors[$key]);
1236 if ($errors) {
1237 throw new PhabricatorApplicationTransactionValidationException(
1238 $errors);
1241 if ($this->raiseWarnings) {
1242 $warnings = array();
1243 foreach ($xactions as $xaction) {
1244 if ($this->hasWarnings($object, $xaction)) {
1245 $warnings[] = $xaction;
1248 if ($warnings) {
1249 throw new PhabricatorApplicationTransactionWarningException(
1250 $warnings);
1255 foreach ($xactions as $xaction) {
1256 $this->adjustTransactionValues($object, $xaction);
1259 // Now that we've merged and combined transactions, check for required
1260 // capabilities. Note that we're doing this before filtering
1261 // transactions: if you try to apply an edit which you do not have
1262 // permission to apply, we want to give you a permissions error even
1263 // if the edit would have no effect.
1264 $this->applyCapabilityChecks($object, $xactions);
1266 $xactions = $this->filterTransactions($object, $xactions);
1268 if (!$is_preview) {
1269 $this->hasRequiredMFA = true;
1270 if ($this->getShouldRequireMFA()) {
1271 $this->requireMFA($object, $xactions);
1274 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1275 if (!$transaction_open) {
1276 $object->openTransaction();
1277 $transaction_open = true;
1282 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1283 $this->applyInitialEffects($object, $xactions);
1286 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1287 // figure this out instead.
1288 $mark_as_create = false;
1289 $create_type = PhabricatorTransactions::TYPE_CREATE;
1290 foreach ($xactions as $xaction) {
1291 if ($xaction->getTransactionType() == $create_type) {
1292 $mark_as_create = true;
1296 if ($mark_as_create) {
1297 foreach ($xactions as $xaction) {
1298 $xaction->setIsCreateTransaction(true);
1302 $xactions = $this->sortTransactions($xactions);
1304 if ($is_preview) {
1305 $this->loadHandles($xactions);
1306 return $xactions;
1309 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1310 ->setActor($actor)
1311 ->setActingAsPHID($this->getActingAsPHID())
1312 ->setContentSource($this->getContentSource())
1313 ->setIsNewComment(true);
1315 if (!$transaction_open) {
1316 $object->openTransaction();
1317 $transaction_open = true;
1320 // We can technically test any object for CAN_INTERACT, but we can
1321 // run into some issues in doing so (for example, in project unit tests).
1322 // For now, only test for CAN_INTERACT if the object is explicitly a
1323 // lockable object.
1325 $was_locked = false;
1326 if ($object instanceof PhabricatorEditEngineLockableInterface) {
1327 $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object);
1330 foreach ($xactions as $xaction) {
1331 $this->applyInternalEffects($object, $xaction);
1334 $xactions = $this->didApplyInternalEffects($object, $xactions);
1336 try {
1337 $object->save();
1338 } catch (AphrontDuplicateKeyQueryException $ex) {
1339 // This callback has an opportunity to throw a better exception,
1340 // so execution may end here.
1341 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1343 throw $ex;
1346 $group_id = $this->getTransactionGroupID();
1348 foreach ($xactions as $xaction) {
1349 if ($was_locked) {
1350 $is_override = $this->isLockOverrideTransaction($xaction);
1351 if ($is_override) {
1352 $xaction->setIsLockOverrideTransaction(true);
1356 $xaction->setObjectPHID($object->getPHID());
1357 $xaction->setTransactionGroupID($group_id);
1359 if ($xaction->getComment()) {
1360 $xaction->setPHID($xaction->generatePHID());
1361 $comment_editor->applyEdit($xaction, $xaction->getComment());
1362 } else {
1364 // TODO: This is a transitional hack to let us migrate edge
1365 // transactions to a more efficient storage format. For now, we're
1366 // going to write a new slim format to the database but keep the old
1367 // bulky format on the objects so we don't have to upgrade all the
1368 // edit logic to the new format yet. See T13051.
1370 $edge_type = PhabricatorTransactions::TYPE_EDGE;
1371 if ($xaction->getTransactionType() == $edge_type) {
1372 $bulky_old = $xaction->getOldValue();
1373 $bulky_new = $xaction->getNewValue();
1375 $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
1376 $slim_old = $record->getModernOldEdgeTransactionData();
1377 $slim_new = $record->getModernNewEdgeTransactionData();
1379 $xaction->setOldValue($slim_old);
1380 $xaction->setNewValue($slim_new);
1381 $xaction->save();
1383 $xaction->setOldValue($bulky_old);
1384 $xaction->setNewValue($bulky_new);
1385 } else {
1386 $xaction->save();
1391 foreach ($xactions as $xaction) {
1392 $this->applyExternalEffects($object, $xaction);
1395 $xactions = $this->applyFinalEffects($object, $xactions);
1397 if ($read_locking) {
1398 $object->endReadLocking();
1399 $read_locking = false;
1402 if ($transaction_open) {
1403 $object->saveTransaction();
1404 $transaction_open = false;
1407 $this->didCommitTransactions($object, $xactions);
1409 } catch (Exception $ex) {
1410 if ($read_locking) {
1411 $object->endReadLocking();
1412 $read_locking = false;
1415 if ($transaction_open) {
1416 $object->killTransaction();
1417 $transaction_open = false;
1420 throw $ex;
1423 // If we need to perform cache engine updates, execute them now.
1424 id(new PhabricatorCacheEngine())
1425 ->updateObject($object);
1427 // Now that we've completely applied the core transaction set, try to apply
1428 // Herald rules. Herald rules are allowed to either take direct actions on
1429 // the database (like writing flags), or take indirect actions (like saving
1430 // some targets for CC when we generate mail a little later), or return
1431 // transactions which we'll apply normally using another Editor.
1433 // First, check if *this* is a sub-editor which is itself applying Herald
1434 // rules: if it is, stop working and return so we don't descend into
1435 // madness.
1437 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1438 // using a Herald editor to apply resulting transactions) and then send out
1439 // mail, notifications, and feed updates about everything.
1441 if ($this->getIsHeraldEditor()) {
1442 // We are the Herald editor, so stop work here and return the updated
1443 // transactions.
1444 return $xactions;
1445 } else if ($this->getIsInverseEdgeEditor()) {
1446 // Do not run Herald if we're just recording that this object was
1447 // mentioned elsewhere. This tends to create Herald side effects which
1448 // feel arbitrary, and can really slow down edits which mention a large
1449 // number of other objects. See T13114.
1450 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1451 // We are not the Herald editor, so try to apply Herald rules.
1452 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1454 if ($herald_xactions) {
1455 $xscript_id = $this->getHeraldTranscript()->getID();
1456 foreach ($herald_xactions as $herald_xaction) {
1457 // Don't set a transcript ID if this is a transaction from another
1458 // application or source, like Owners.
1459 if ($herald_xaction->getAuthorPHID()) {
1460 continue;
1463 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1466 // NOTE: We're acting as the omnipotent user because rules deal with
1467 // their own policy issues. We use a synthetic author PHID (the
1468 // Herald application) as the author of record, so that transactions
1469 // will render in a reasonable way ("Herald assigned this task ...").
1470 $herald_actor = PhabricatorUser::getOmnipotentUser();
1471 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1473 // TODO: It would be nice to give transactions a more specific source
1474 // which points at the rule which generated them. You can figure this
1475 // out from transcripts, but it would be cleaner if you didn't have to.
1477 $herald_source = PhabricatorContentSource::newForSource(
1478 PhabricatorHeraldContentSource::SOURCECONST);
1480 $herald_editor = $this->newEditorCopy()
1481 ->setContinueOnNoEffect(true)
1482 ->setContinueOnMissingFields(true)
1483 ->setIsHeraldEditor(true)
1484 ->setActor($herald_actor)
1485 ->setActingAsPHID($herald_phid)
1486 ->setContentSource($herald_source);
1488 $herald_xactions = $herald_editor->applyTransactions(
1489 $object,
1490 $herald_xactions);
1492 // Merge the new transactions into the transaction list: we want to
1493 // send email and publish feed stories about them, too.
1494 $xactions = array_merge($xactions, $herald_xactions);
1497 // If Herald did not generate transactions, we may still need to handle
1498 // "Send an Email" rules.
1499 $adapter = $this->getHeraldAdapter();
1500 $this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
1501 $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
1502 $this->webhookMap = $adapter->getWebhookMap();
1505 $xactions = $this->didApplyTransactions($object, $xactions);
1507 if ($object instanceof PhabricatorCustomFieldInterface) {
1508 // Maybe this makes more sense to move into the search index itself? For
1509 // now I'm putting it here since I think we might end up with things that
1510 // need it to be up to date once the next page loads, but if we don't go
1511 // there we could move it into search once search moves to the daemons.
1513 // It now happens in the search indexer as well, but the search indexer is
1514 // always daemonized, so the logic above still potentially holds. We could
1515 // possibly get rid of this. The major motivation for putting it in the
1516 // indexer was to enable reindexing to work.
1518 $fields = PhabricatorCustomField::getObjectFields(
1519 $object,
1520 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1521 $fields->readFieldsFromStorage($object);
1522 $fields->rebuildIndexes($object);
1525 $herald_xscript = $this->getHeraldTranscript();
1526 if ($herald_xscript) {
1527 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1528 $herald_header = HeraldTranscript::saveXHeraldRulesHeader(
1529 $object->getPHID(),
1530 $herald_header);
1531 } else {
1532 $herald_header = HeraldTranscript::loadXHeraldRulesHeader(
1533 $object->getPHID());
1535 $this->heraldHeader = $herald_header;
1537 // See PHI1134. If we're a subeditor, we don't publish information about
1538 // the edit yet. Our parent editor still needs to finish applying
1539 // transactions and execute Herald, which may change the information we
1540 // publish.
1542 // For example, Herald actions may change the parent object's title or
1543 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1544 // email.
1546 // Once the parent finishes work, it will queue its own publish step and
1547 // then queue publish steps for its children.
1549 $this->publishableObject = $object;
1550 $this->publishableTransactions = $xactions;
1551 if (!$this->parentEditor) {
1552 $this->queuePublishing();
1555 return $xactions;
1558 private function queuePublishing() {
1559 $object = $this->publishableObject;
1560 $xactions = $this->publishableTransactions;
1562 if (!$object) {
1563 throw new Exception(
1564 pht(
1565 'Editor method "queuePublishing()" was called, but no publishable '.
1566 'object is present. This Editor is not ready to publish.'));
1569 // We're going to compute some of the data we'll use to publish these
1570 // transactions here, before queueing a worker.
1572 // Primarily, this is more correct: we want to publish the object as it
1573 // exists right now. The worker may not execute for some time, and we want
1574 // to use the current To/CC list, not respect any changes which may occur
1575 // between now and when the worker executes.
1577 // As a secondary benefit, this tends to reduce the amount of state that
1578 // Editors need to pass into workers.
1579 $object = $this->willPublish($object, $xactions);
1581 if (!$this->getIsSilent()) {
1582 if ($this->shouldSendMail($object, $xactions)) {
1583 $this->mailShouldSend = true;
1584 $this->mailToPHIDs = $this->getMailTo($object);
1585 $this->mailCCPHIDs = $this->getMailCC($object);
1586 $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object);
1588 // Add any recipients who were previously on the notification list
1589 // but were removed by this change.
1590 $this->applyOldRecipientLists();
1592 if ($object instanceof PhabricatorSubscribableInterface) {
1593 $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs(
1594 $object->getPHID(),
1595 PhabricatorMutedByEdgeType::EDGECONST);
1596 } else {
1597 $this->mailMutedPHIDs = array();
1600 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1601 $stamps = $this->newMailStamps($object, $xactions);
1602 foreach ($stamps as $stamp) {
1603 $this->mailStamps[] = $stamp->toDictionary();
1607 if ($this->shouldPublishFeedStory($object, $xactions)) {
1608 $this->feedShouldPublish = true;
1609 $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs(
1610 $object,
1611 $xactions);
1612 $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs(
1613 $object,
1614 $xactions);
1618 PhabricatorWorker::scheduleTask(
1619 'PhabricatorApplicationTransactionPublishWorker',
1620 array(
1621 'objectPHID' => $object->getPHID(),
1622 'actorPHID' => $this->getActingAsPHID(),
1623 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1624 'state' => $this->getWorkerState(),
1626 array(
1627 'objectPHID' => $object->getPHID(),
1628 'priority' => PhabricatorWorker::PRIORITY_ALERTS,
1631 foreach ($this->subEditors as $sub_editor) {
1632 $sub_editor->queuePublishing();
1635 $this->flushTransactionQueue($object);
1638 protected function didCatchDuplicateKeyException(
1639 PhabricatorLiskDAO $object,
1640 array $xactions,
1641 Exception $ex) {
1642 return;
1645 public function publishTransactions(
1646 PhabricatorLiskDAO $object,
1647 array $xactions) {
1649 $this->object = $object;
1650 $this->xactions = $xactions;
1652 // Hook for edges or other properties that may need (re-)loading
1653 $object = $this->willPublish($object, $xactions);
1655 // The object might have changed, so reassign it.
1656 $this->object = $object;
1658 $messages = array();
1659 if ($this->mailShouldSend) {
1660 $messages = $this->buildMail($object, $xactions);
1663 if ($this->supportsSearch()) {
1664 PhabricatorSearchWorker::queueDocumentForIndexing(
1665 $object->getPHID(),
1666 array(
1667 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1671 if ($this->feedShouldPublish) {
1672 $mailed = array();
1673 foreach ($messages as $mail) {
1674 foreach ($mail->buildRecipientList() as $phid) {
1675 $mailed[$phid] = $phid;
1679 $this->publishFeedStory($object, $xactions, $mailed);
1682 if ($this->sendHistory) {
1683 $history_mail = $this->buildHistoryMail($object);
1684 if ($history_mail) {
1685 $messages[] = $history_mail;
1689 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1690 $messages[] = $message;
1693 // NOTE: This actually sends the mail. We do this last to reduce the chance
1694 // that we send some mail, hit an exception, then send the mail again when
1695 // retrying.
1696 foreach ($messages as $mail) {
1697 $mail->save();
1700 $this->queueWebhooks($object, $xactions);
1702 return $xactions;
1705 protected function didApplyTransactions($object, array $xactions) {
1706 // Hook for subclasses.
1707 return $xactions;
1710 private function loadHandles(array $xactions) {
1711 $phids = array();
1712 foreach ($xactions as $key => $xaction) {
1713 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1715 $handles = array();
1716 $merged = array_mergev($phids);
1717 if ($merged) {
1718 $handles = id(new PhabricatorHandleQuery())
1719 ->setViewer($this->requireActor())
1720 ->withPHIDs($merged)
1721 ->execute();
1723 foreach ($xactions as $key => $xaction) {
1724 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1728 private function loadSubscribers(PhabricatorLiskDAO $object) {
1729 if ($object->getPHID() &&
1730 ($object instanceof PhabricatorSubscribableInterface)) {
1731 $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
1732 $object->getPHID());
1733 $this->subscribers = array_fuse($subs);
1734 } else {
1735 $this->subscribers = array();
1739 private function validateEditParameters(
1740 PhabricatorLiskDAO $object,
1741 array $xactions) {
1743 if (!$this->getContentSource()) {
1744 throw new PhutilInvalidStateException('setContentSource');
1747 // Do a bunch of sanity checks that the incoming transactions are fresh.
1748 // They should be unsaved and have only "transactionType" and "newValue"
1749 // set.
1751 $types = array_fill_keys($this->getTransactionTypes(), true);
1753 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1754 foreach ($xactions as $xaction) {
1755 if ($xaction->getPHID() || $xaction->getID()) {
1756 throw new PhabricatorApplicationTransactionStructureException(
1757 $xaction,
1758 pht('You can not apply transactions which already have IDs/PHIDs!'));
1761 if ($xaction->getObjectPHID()) {
1762 throw new PhabricatorApplicationTransactionStructureException(
1763 $xaction,
1764 pht(
1765 'You can not apply transactions which already have %s!',
1766 'objectPHIDs'));
1769 if ($xaction->getCommentPHID()) {
1770 throw new PhabricatorApplicationTransactionStructureException(
1771 $xaction,
1772 pht(
1773 'You can not apply transactions which already have %s!',
1774 'commentPHIDs'));
1777 if ($xaction->getCommentVersion() !== 0) {
1778 throw new PhabricatorApplicationTransactionStructureException(
1779 $xaction,
1780 pht(
1781 'You can not apply transactions which already have '.
1782 'commentVersions!'));
1785 $expect_value = !$xaction->shouldGenerateOldValue();
1786 $has_value = $xaction->hasOldValue();
1788 // See T13082. In the narrow case of applying inverse edge edits, we
1789 // expect the old value to be populated.
1790 if ($this->getIsInverseEdgeEditor()) {
1791 $expect_value = true;
1794 if ($expect_value && !$has_value) {
1795 throw new PhabricatorApplicationTransactionStructureException(
1796 $xaction,
1797 pht(
1798 'This transaction is supposed to have an %s set, but it does not!',
1799 'oldValue'));
1802 if ($has_value && !$expect_value) {
1803 throw new PhabricatorApplicationTransactionStructureException(
1804 $xaction,
1805 pht(
1806 'This transaction should generate its %s automatically, '.
1807 'but has already had one set!',
1808 'oldValue'));
1811 $type = $xaction->getTransactionType();
1812 if (empty($types[$type])) {
1813 throw new PhabricatorApplicationTransactionStructureException(
1814 $xaction,
1815 pht(
1816 'Transaction has type "%s", but that transaction type is not '.
1817 'supported by this editor (%s).',
1818 $type,
1819 get_class($this)));
1824 private function applyCapabilityChecks(
1825 PhabricatorLiskDAO $object,
1826 array $xactions) {
1827 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1829 $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
1831 if ($this->getIsNewObject()) {
1832 // If we're creating a new object, we don't need any special capabilities
1833 // on the object. The actor has already made it through creation checks,
1834 // and objects which haven't been created yet often can not be
1835 // meaningfully tested for capabilities anyway.
1836 $required_capabilities = array();
1837 } else {
1838 if (!$xactions && !$this->xactions) {
1839 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1840 $required_capabilities = array($can_edit);
1841 } else {
1842 $required_capabilities = array();
1844 foreach ($xactions as $xaction) {
1845 $type = $xaction->getTransactionType();
1847 $xtype = $this->getModularTransactionType($type);
1848 if (!$xtype) {
1849 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1850 } else {
1851 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1854 // For convenience, we allow flexibility in the return types because
1855 // it's very unusual that a transaction actually requires multiple
1856 // capability checks.
1857 if ($capabilities === null) {
1858 $capabilities = array();
1859 } else {
1860 $capabilities = (array)$capabilities;
1863 foreach ($capabilities as $capability) {
1864 $required_capabilities[$capability] = $capability;
1870 $required_capabilities = array_fuse($required_capabilities);
1871 $actor = $this->getActor();
1873 if ($required_capabilities) {
1874 id(new PhabricatorPolicyFilter())
1875 ->setViewer($actor)
1876 ->requireCapabilities($required_capabilities)
1877 ->raisePolicyExceptions(true)
1878 ->apply(array($object));
1882 private function getLegacyRequiredCapabilities(
1883 PhabricatorApplicationTransaction $xaction) {
1885 $type = $xaction->getTransactionType();
1886 switch ($type) {
1887 case PhabricatorTransactions::TYPE_COMMENT:
1888 // TODO: Comments technically require CAN_INTERACT, but this is
1889 // currently somewhat special and handled through EditEngine. For now,
1890 // don't enforce it here.
1891 return null;
1892 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
1893 // Anyone can subscribe to or unsubscribe from anything they can view,
1894 // with no other permissions.
1896 $old = array_fuse($xaction->getOldValue());
1897 $new = array_fuse($xaction->getNewValue());
1899 // To remove users other than yourself, you must be able to edit the
1900 // object.
1901 $rem = array_diff_key($old, $new);
1902 foreach ($rem as $phid) {
1903 if ($phid !== $this->getActingAsPHID()) {
1904 return PhabricatorPolicyCapability::CAN_EDIT;
1908 // To add users other than yourself, you must be able to interact.
1909 // This allows "@mentioning" users to work as long as you can comment
1910 // on objects.
1912 // If you can edit, we return that policy instead so that you can
1913 // override a soft lock and still make edits.
1915 // TODO: This is a little bit hacky. We really want to be able to say
1916 // "this requires either interact or edit", but there's currently no
1917 // way to specify this kind of requirement.
1919 $can_edit = PhabricatorPolicyFilter::hasCapability(
1920 $this->getActor(),
1921 $this->object,
1922 PhabricatorPolicyCapability::CAN_EDIT);
1924 $add = array_diff_key($new, $old);
1925 foreach ($add as $phid) {
1926 if ($phid !== $this->getActingAsPHID()) {
1927 if ($can_edit) {
1928 return PhabricatorPolicyCapability::CAN_EDIT;
1929 } else {
1930 return PhabricatorPolicyCapability::CAN_INTERACT;
1935 return null;
1936 case PhabricatorTransactions::TYPE_TOKEN:
1937 // TODO: This technically requires CAN_INTERACT, like comments.
1938 return null;
1939 case PhabricatorTransactions::TYPE_HISTORY:
1940 // This is a special magic transaction which sends you history via
1941 // email and is only partially supported in the upstream. You don't
1942 // need any capabilities to apply it.
1943 return null;
1944 case PhabricatorTransactions::TYPE_MFA:
1945 // Signing a transaction group with MFA does not require permissions
1946 // on its own.
1947 return null;
1948 case PhabricatorTransactions::TYPE_FILE:
1949 return null;
1950 case PhabricatorTransactions::TYPE_EDGE:
1951 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1952 default:
1953 // For other older (non-modular) transactions, always require exactly
1954 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1955 // capabilities must move to ModularTransactions.
1956 return PhabricatorPolicyCapability::CAN_EDIT;
1960 private function getLegacyRequiredEdgeCapabilities(
1961 PhabricatorApplicationTransaction $xaction) {
1963 // You don't need to have edit permission on an object to mention it or
1964 // otherwise add a relationship pointing toward it.
1965 if ($this->getIsInverseEdgeEditor()) {
1966 return null;
1969 $edge_type = $xaction->getMetadataValue('edge:type');
1970 switch ($edge_type) {
1971 case PhabricatorMutedByEdgeType::EDGECONST:
1972 // At time of writing, you can only write this edge for yourself, so
1973 // you don't need permissions. If you can eventually mute an object
1974 // for other users, this would need to be revisited.
1975 return null;
1976 case PhabricatorProjectSilencedEdgeType::EDGECONST:
1977 // At time of writing, you can only write this edge for yourself, so
1978 // you don't need permissions. If you can eventually silence project
1979 // for other users, this would need to be revisited.
1980 return null;
1981 case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
1982 return null;
1983 case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST:
1984 $old = $xaction->getOldValue();
1985 $new = $xaction->getNewValue();
1987 $add = array_keys(array_diff_key($new, $old));
1988 $rem = array_keys(array_diff_key($old, $new));
1990 $actor_phid = $this->requireActor()->getPHID();
1992 $is_join = (($add === array($actor_phid)) && !$rem);
1993 $is_leave = (($rem === array($actor_phid)) && !$add);
1995 if ($is_join) {
1996 // You need CAN_JOIN to join a project.
1997 return PhabricatorPolicyCapability::CAN_JOIN;
2000 if ($is_leave) {
2001 $object = $this->object;
2002 // You usually don't need any capabilities to leave a project...
2003 if ($object->getIsMembershipLocked()) {
2004 // ...you must be able to edit to leave locked projects, though.
2005 return PhabricatorPolicyCapability::CAN_EDIT;
2006 } else {
2007 return null;
2011 // You need CAN_EDIT to change members other than yourself.
2012 return PhabricatorPolicyCapability::CAN_EDIT;
2013 case PhabricatorObjectHasWatcherEdgeType::EDGECONST:
2014 // See PHI1024. Watching a project does not require CAN_EDIT.
2015 return null;
2016 default:
2017 return PhabricatorPolicyCapability::CAN_EDIT;
2022 private function buildSubscribeTransaction(
2023 PhabricatorLiskDAO $object,
2024 array $xactions,
2025 array $changes) {
2027 if (!($object instanceof PhabricatorSubscribableInterface)) {
2028 return null;
2031 if ($this->shouldEnableMentions($object, $xactions)) {
2032 // Identify newly mentioned users. We ignore users who were previously
2033 // mentioned so that we don't re-subscribe users after an edit of text
2034 // which mentions them.
2035 $old_texts = mpull($changes, 'getOldValue');
2036 $new_texts = mpull($changes, 'getNewValue');
2038 $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
2039 $this->getActor(),
2040 $old_texts);
2042 $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
2043 $this->getActor(),
2044 $new_texts);
2046 $phids = array_diff($new_phids, $old_phids);
2047 } else {
2048 $phids = array();
2051 $this->mentionedPHIDs = $phids;
2053 if ($object->getPHID()) {
2054 // Don't try to subscribe already-subscribed mentions: we want to generate
2055 // a dialog about an action having no effect if the user explicitly adds
2056 // existing CCs, but not if they merely mention existing subscribers.
2057 $phids = array_diff($phids, $this->subscribers);
2060 if ($phids) {
2061 $users = id(new PhabricatorPeopleQuery())
2062 ->setViewer($this->getActor())
2063 ->withPHIDs($phids)
2064 ->execute();
2065 $users = mpull($users, null, 'getPHID');
2067 foreach ($phids as $key => $phid) {
2068 $user = idx($users, $phid);
2070 // Don't subscribe invalid users.
2071 if (!$user) {
2072 unset($phids[$key]);
2073 continue;
2076 // Don't subscribe bots that get mentioned. If users truly intend
2077 // to subscribe them, they can add them explicitly, but it's generally
2078 // not useful to subscribe bots to objects.
2079 if ($user->getIsSystemAgent()) {
2080 unset($phids[$key]);
2081 continue;
2084 // Do not subscribe mentioned users who do not have permission to see
2085 // the object.
2086 if ($object instanceof PhabricatorPolicyInterface) {
2087 $can_view = PhabricatorPolicyFilter::hasCapability(
2088 $user,
2089 $object,
2090 PhabricatorPolicyCapability::CAN_VIEW);
2091 if (!$can_view) {
2092 unset($phids[$key]);
2093 continue;
2097 // Don't subscribe users who are already automatically subscribed.
2098 if ($object->isAutomaticallySubscribed($phid)) {
2099 unset($phids[$key]);
2100 continue;
2104 $phids = array_values($phids);
2107 if (!$phids) {
2108 return null;
2111 $xaction = $object->getApplicationTransactionTemplate()
2112 ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
2113 ->setNewValue(array('+' => $phids));
2115 return $xaction;
2118 protected function mergeTransactions(
2119 PhabricatorApplicationTransaction $u,
2120 PhabricatorApplicationTransaction $v) {
2122 $type = $u->getTransactionType();
2124 $xtype = $this->getModularTransactionType($type);
2125 if ($xtype) {
2126 $object = $this->object;
2127 return $xtype->mergeTransactions($object, $u, $v);
2130 switch ($type) {
2131 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
2132 return $this->mergePHIDOrEdgeTransactions($u, $v);
2133 case PhabricatorTransactions::TYPE_EDGE:
2134 $u_type = $u->getMetadataValue('edge:type');
2135 $v_type = $v->getMetadataValue('edge:type');
2136 if ($u_type == $v_type) {
2137 return $this->mergePHIDOrEdgeTransactions($u, $v);
2139 return null;
2142 // By default, do not merge the transactions.
2143 return null;
2147 * Optionally expand transactions which imply other effects. For example,
2148 * resigning from a revision in Differential implies removing yourself as
2149 * a reviewer.
2151 protected function expandTransactions(
2152 PhabricatorLiskDAO $object,
2153 array $xactions) {
2155 $results = array();
2156 foreach ($xactions as $xaction) {
2157 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2158 $results[] = $expanded;
2162 return $results;
2165 protected function expandTransaction(
2166 PhabricatorLiskDAO $object,
2167 PhabricatorApplicationTransaction $xaction) {
2168 return array($xaction);
2172 public function getExpandedSupportTransactions(
2173 PhabricatorLiskDAO $object,
2174 PhabricatorApplicationTransaction $xaction) {
2176 $xactions = array($xaction);
2177 $xactions = $this->expandSupportTransactions(
2178 $object,
2179 $xactions);
2181 if (count($xactions) == 1) {
2182 return array();
2185 foreach ($xactions as $index => $cxaction) {
2186 if ($cxaction === $xaction) {
2187 unset($xactions[$index]);
2188 break;
2192 return $xactions;
2195 private function expandSupportTransactions(
2196 PhabricatorLiskDAO $object,
2197 array $xactions) {
2198 $this->loadSubscribers($object);
2200 $xactions = $this->applyImplicitCC($object, $xactions);
2202 $changes = $this->getRemarkupChanges($xactions);
2204 $subscribe_xaction = $this->buildSubscribeTransaction(
2205 $object,
2206 $xactions,
2207 $changes);
2208 if ($subscribe_xaction) {
2209 $xactions[] = $subscribe_xaction;
2212 // TODO: For now, this is just a placeholder.
2213 $engine = PhabricatorMarkupEngine::getEngine('extract');
2214 $engine->setConfig('viewer', $this->requireActor());
2216 $block_xactions = $this->expandRemarkupBlockTransactions(
2217 $object,
2218 $xactions,
2219 $changes,
2220 $engine);
2222 foreach ($block_xactions as $xaction) {
2223 $xactions[] = $xaction;
2226 $file_xaction = $this->newFileTransaction(
2227 $object,
2228 $xactions,
2229 $changes);
2230 if ($file_xaction) {
2231 $xactions[] = $file_xaction;
2234 return $xactions;
2238 private function newFileTransaction(
2239 PhabricatorLiskDAO $object,
2240 array $xactions,
2241 array $remarkup_changes) {
2243 assert_instances_of(
2244 $remarkup_changes,
2245 'PhabricatorTransactionRemarkupChange');
2247 $new_map = array();
2249 foreach ($remarkup_changes as $remarkup_change) {
2250 $metadata = $remarkup_change->getMetadata();
2252 $attached_phids = idx($metadata, 'attachedFilePHIDs');
2253 foreach ($attached_phids as $file_phid) {
2254 $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
2258 $file_phids = $this->extractFilePHIDs($object, $xactions);
2259 foreach ($file_phids as $file_phid) {
2260 $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
2263 if (!$new_map) {
2264 return null;
2267 $xaction = $object->getApplicationTransactionTemplate()
2268 ->setTransactionType(PhabricatorTransactions::TYPE_FILE)
2269 ->setNewValue($new_map);
2271 return $xaction;
2275 private function getRemarkupChanges(array $xactions) {
2276 $changes = array();
2278 foreach ($xactions as $key => $xaction) {
2279 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2280 $changes[] = $change;
2284 return $changes;
2287 private function getRemarkupChangesFromTransaction(
2288 PhabricatorApplicationTransaction $transaction) {
2289 return $transaction->getRemarkupChanges();
2292 private function expandRemarkupBlockTransactions(
2293 PhabricatorLiskDAO $object,
2294 array $xactions,
2295 array $changes,
2296 PhutilMarkupEngine $engine) {
2298 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2299 $object,
2300 $xactions,
2301 $changes,
2302 $engine);
2304 $mentioned_phids = array();
2305 if ($this->shouldEnableMentions($object, $xactions)) {
2306 foreach ($changes as $change) {
2307 // Here, we don't care about processing only new mentions after an edit
2308 // because there is no way for an object to ever "unmention" itself on
2309 // another object, so we can ignore the old value.
2310 $engine->markupText($change->getNewValue());
2312 $mentioned_phids += $engine->getTextMetadata(
2313 PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
2314 array());
2318 if (!$mentioned_phids) {
2319 return $block_xactions;
2322 $mentioned_objects = id(new PhabricatorObjectQuery())
2323 ->setViewer($this->getActor())
2324 ->withPHIDs($mentioned_phids)
2325 ->execute();
2327 $unmentionable_map = $this->getUnmentionablePHIDMap();
2329 $mentionable_phids = array();
2330 if ($this->shouldEnableMentions($object, $xactions)) {
2331 foreach ($mentioned_objects as $mentioned_object) {
2332 if ($mentioned_object instanceof PhabricatorMentionableInterface) {
2333 $mentioned_phid = $mentioned_object->getPHID();
2334 if (isset($unmentionable_map[$mentioned_phid])) {
2335 continue;
2337 // don't let objects mention themselves
2338 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2339 continue;
2341 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2346 if ($mentionable_phids) {
2347 $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
2348 $block_xactions[] = newv(get_class(head($xactions)), array())
2349 ->setIgnoreOnNoEffect(true)
2350 ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
2351 ->setMetadataValue('edge:type', $edge_type)
2352 ->setNewValue(array('+' => $mentionable_phids));
2355 return $block_xactions;
2358 protected function expandCustomRemarkupBlockTransactions(
2359 PhabricatorLiskDAO $object,
2360 array $xactions,
2361 array $changes,
2362 PhutilMarkupEngine $engine) {
2363 return array();
2368 * Attempt to combine similar transactions into a smaller number of total
2369 * transactions. For example, two transactions which edit the title of an
2370 * object can be merged into a single edit.
2372 private function combineTransactions(array $xactions) {
2373 $stray_comments = array();
2375 $result = array();
2376 $types = array();
2377 foreach ($xactions as $key => $xaction) {
2378 $type = $xaction->getTransactionType();
2379 if (isset($types[$type])) {
2380 foreach ($types[$type] as $other_key) {
2381 $other_xaction = $result[$other_key];
2383 // Don't merge transactions with different authors. For example,
2384 // don't merge Herald transactions and owners transactions.
2385 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2386 continue;
2389 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2390 if ($merged) {
2391 $result[$other_key] = $merged;
2393 if ($xaction->getComment() &&
2394 ($xaction->getComment() !== $merged->getComment())) {
2395 $stray_comments[] = $xaction->getComment();
2398 if ($result[$other_key]->getComment() &&
2399 ($result[$other_key]->getComment() !== $merged->getComment())) {
2400 $stray_comments[] = $result[$other_key]->getComment();
2403 // Move on to the next transaction.
2404 continue 2;
2408 $result[$key] = $xaction;
2409 $types[$type][] = $key;
2412 // If we merged any comments away, restore them.
2413 foreach ($stray_comments as $comment) {
2414 $xaction = newv(get_class(head($result)), array());
2415 $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
2416 $xaction->setComment($comment);
2417 $result[] = $xaction;
2420 return array_values($result);
2423 public function mergePHIDOrEdgeTransactions(
2424 PhabricatorApplicationTransaction $u,
2425 PhabricatorApplicationTransaction $v) {
2427 $result = $u->getNewValue();
2428 foreach ($v->getNewValue() as $key => $value) {
2429 if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
2430 if (empty($result[$key])) {
2431 $result[$key] = $value;
2432 } else {
2433 // We're merging two lists of edge adds, sets, or removes. Merge
2434 // them by merging individual PHIDs within them.
2435 $merged = $result[$key];
2437 foreach ($value as $dst => $v_spec) {
2438 if (empty($merged[$dst])) {
2439 $merged[$dst] = $v_spec;
2440 } else {
2441 // Two transactions are trying to perform the same operation on
2442 // the same edge. Normalize the edge data and then merge it. This
2443 // allows transactions to specify how data merges execute in a
2444 // precise way.
2446 $u_spec = $merged[$dst];
2448 if (!is_array($u_spec)) {
2449 $u_spec = array('dst' => $u_spec);
2451 if (!is_array($v_spec)) {
2452 $v_spec = array('dst' => $v_spec);
2455 $ux_data = idx($u_spec, 'data', array());
2456 $vx_data = idx($v_spec, 'data', array());
2458 $merged_data = $this->mergeEdgeData(
2459 $u->getMetadataValue('edge:type'),
2460 $ux_data,
2461 $vx_data);
2463 $u_spec['data'] = $merged_data;
2464 $merged[$dst] = $u_spec;
2468 $result[$key] = $merged;
2470 } else {
2471 $result[$key] = array_merge($value, idx($result, $key, array()));
2474 $u->setNewValue($result);
2476 // When combining an "ignore" transaction with a normal transaction, make
2477 // sure we don't propagate the "ignore" flag.
2478 if (!$v->getIgnoreOnNoEffect()) {
2479 $u->setIgnoreOnNoEffect(false);
2482 return $u;
2485 protected function mergeEdgeData($type, array $u, array $v) {
2486 return $v + $u;
2489 protected function getPHIDTransactionNewValue(
2490 PhabricatorApplicationTransaction $xaction,
2491 $old = null) {
2493 if ($old !== null) {
2494 $old = array_fuse($old);
2495 } else {
2496 $old = array_fuse($xaction->getOldValue());
2499 return $this->getPHIDList($old, $xaction->getNewValue());
2502 public function getPHIDList(array $old, array $new) {
2503 $new_add = idx($new, '+', array());
2504 unset($new['+']);
2505 $new_rem = idx($new, '-', array());
2506 unset($new['-']);
2507 $new_set = idx($new, '=', null);
2508 if ($new_set !== null) {
2509 $new_set = array_fuse($new_set);
2511 unset($new['=']);
2513 if ($new) {
2514 throw new Exception(
2515 pht(
2516 "Invalid '%s' value for PHID transaction. Value should contain only ".
2517 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2518 'new',
2519 '+',
2520 '-',
2521 '='));
2524 $result = array();
2526 foreach ($old as $phid) {
2527 if ($new_set !== null && empty($new_set[$phid])) {
2528 continue;
2530 $result[$phid] = $phid;
2533 if ($new_set !== null) {
2534 foreach ($new_set as $phid) {
2535 $result[$phid] = $phid;
2539 foreach ($new_add as $phid) {
2540 $result[$phid] = $phid;
2543 foreach ($new_rem as $phid) {
2544 unset($result[$phid]);
2547 return array_values($result);
2550 protected function getEdgeTransactionNewValue(
2551 PhabricatorApplicationTransaction $xaction) {
2553 $new = $xaction->getNewValue();
2554 $new_add = idx($new, '+', array());
2555 unset($new['+']);
2556 $new_rem = idx($new, '-', array());
2557 unset($new['-']);
2558 $new_set = idx($new, '=', null);
2559 unset($new['=']);
2561 if ($new) {
2562 throw new Exception(
2563 pht(
2564 "Invalid '%s' value for Edge transaction. Value should contain only ".
2565 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2566 'new',
2567 '+',
2568 '-',
2569 '='));
2572 $old = $xaction->getOldValue();
2574 $lists = array($new_set, $new_add, $new_rem);
2575 foreach ($lists as $list) {
2576 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2579 $result = array();
2580 foreach ($old as $dst_phid => $edge) {
2581 if ($new_set !== null && empty($new_set[$dst_phid])) {
2582 continue;
2584 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2585 $xaction,
2586 $edge,
2587 $dst_phid);
2590 if ($new_set !== null) {
2591 foreach ($new_set as $dst_phid => $edge) {
2592 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2593 $xaction,
2594 $edge,
2595 $dst_phid);
2599 foreach ($new_add as $dst_phid => $edge) {
2600 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2601 $xaction,
2602 $edge,
2603 $dst_phid);
2606 foreach ($new_rem as $dst_phid => $edge) {
2607 unset($result[$dst_phid]);
2610 return $result;
2613 private function checkEdgeList($list, $edge_type) {
2614 if (!$list) {
2615 return;
2617 foreach ($list as $key => $item) {
2618 if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
2619 throw new Exception(
2620 pht(
2621 'Edge transactions must have destination PHIDs as in edge '.
2622 'lists (found key "%s" on transaction of type "%s").',
2623 $key,
2624 $edge_type));
2626 if (!is_array($item) && $item !== $key) {
2627 throw new Exception(
2628 pht(
2629 'Edge transactions must have PHIDs or edge specs as values '.
2630 '(found value "%s" on transaction of type "%s").',
2631 $item,
2632 $edge_type));
2637 private function normalizeEdgeTransactionValue(
2638 PhabricatorApplicationTransaction $xaction,
2639 $edge,
2640 $dst_phid) {
2642 if (!is_array($edge)) {
2643 if ($edge != $dst_phid) {
2644 throw new Exception(
2645 pht(
2646 'Transaction edge data must either be the edge PHID or an edge '.
2647 'specification dictionary.'));
2649 $edge = array();
2650 } else {
2651 foreach ($edge as $key => $value) {
2652 switch ($key) {
2653 case 'src':
2654 case 'dst':
2655 case 'type':
2656 case 'data':
2657 case 'dateCreated':
2658 case 'dateModified':
2659 case 'seq':
2660 case 'dataID':
2661 break;
2662 default:
2663 throw new Exception(
2664 pht(
2665 'Transaction edge specification contains unexpected key "%s".',
2666 $key));
2671 $edge['dst'] = $dst_phid;
2673 $edge_type = $xaction->getMetadataValue('edge:type');
2674 if (empty($edge['type'])) {
2675 $edge['type'] = $edge_type;
2676 } else {
2677 if ($edge['type'] != $edge_type) {
2678 $this_type = $edge['type'];
2679 throw new Exception(
2680 pht(
2681 "Edge transaction includes edge of type '%s', but ".
2682 "transaction is of type '%s'. Each edge transaction ".
2683 "must alter edges of only one type.",
2684 $this_type,
2685 $edge_type));
2689 if (!isset($edge['data'])) {
2690 $edge['data'] = array();
2693 return $edge;
2696 protected function sortTransactions(array $xactions) {
2697 $head = array();
2698 $tail = array();
2700 // Move bare comments to the end, so the actions precede them.
2701 foreach ($xactions as $xaction) {
2702 $type = $xaction->getTransactionType();
2703 if ($type == PhabricatorTransactions::TYPE_COMMENT) {
2704 $tail[] = $xaction;
2705 } else {
2706 $head[] = $xaction;
2710 return array_values(array_merge($head, $tail));
2714 protected function filterTransactions(
2715 PhabricatorLiskDAO $object,
2716 array $xactions) {
2718 $type_comment = PhabricatorTransactions::TYPE_COMMENT;
2719 $type_mfa = PhabricatorTransactions::TYPE_MFA;
2721 $no_effect = array();
2722 $has_comment = false;
2723 $any_effect = false;
2725 $meta_xactions = array();
2726 foreach ($xactions as $key => $xaction) {
2727 if ($xaction->getTransactionType() === $type_mfa) {
2728 $meta_xactions[$key] = $xaction;
2729 continue;
2732 if ($this->transactionHasEffect($object, $xaction)) {
2733 if ($xaction->getTransactionType() != $type_comment) {
2734 $any_effect = true;
2736 } else if ($xaction->getIgnoreOnNoEffect()) {
2737 unset($xactions[$key]);
2738 } else {
2739 $no_effect[$key] = $xaction;
2742 if ($xaction->hasComment()) {
2743 $has_comment = true;
2747 // If every transaction is a meta-transaction applying to the transaction
2748 // group, these transactions are junk.
2749 if (count($meta_xactions) == count($xactions)) {
2750 $no_effect = $xactions;
2751 $any_effect = false;
2754 if (!$no_effect) {
2755 return $xactions;
2758 // If none of the transactions have an effect, the meta-transactions also
2759 // have no effect. Add them to the "no effect" list so we get a full set
2760 // of errors for everything.
2761 if (!$any_effect && !$has_comment) {
2762 $no_effect += $meta_xactions;
2765 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2766 throw new PhabricatorApplicationTransactionNoEffectException(
2767 $no_effect,
2768 $any_effect,
2769 $has_comment);
2772 if (!$any_effect && !$has_comment) {
2773 // If we only have empty comment transactions, just drop them all.
2774 return array();
2777 foreach ($no_effect as $key => $xaction) {
2778 if ($xaction->hasComment()) {
2779 $xaction->setTransactionType($type_comment);
2780 $xaction->setOldValue(null);
2781 $xaction->setNewValue(null);
2782 } else {
2783 unset($xactions[$key]);
2787 return $xactions;
2792 * Hook for validating transactions. This callback will be invoked for each
2793 * available transaction type, even if an edit does not apply any transactions
2794 * of that type. This allows you to raise exceptions when required fields are
2795 * missing, by detecting that the object has no field value and there is no
2796 * transaction which sets one.
2798 * @param PhabricatorLiskDAO Object being edited.
2799 * @param string Transaction type to validate.
2800 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2801 * which may be empty if the edit does not apply any transactions of the
2802 * given type.
2803 * @return list<PhabricatorApplicationTransactionValidationError> List of
2804 * validation errors.
2806 protected function validateTransaction(
2807 PhabricatorLiskDAO $object,
2808 $type,
2809 array $xactions) {
2811 $errors = array();
2813 $xtype = $this->getModularTransactionType($type);
2814 if ($xtype) {
2815 $errors[] = $xtype->validateTransactions($object, $xactions);
2818 switch ($type) {
2819 case PhabricatorTransactions::TYPE_VIEW_POLICY:
2820 $errors[] = $this->validatePolicyTransaction(
2821 $object,
2822 $xactions,
2823 $type,
2824 PhabricatorPolicyCapability::CAN_VIEW);
2825 break;
2826 case PhabricatorTransactions::TYPE_EDIT_POLICY:
2827 $errors[] = $this->validatePolicyTransaction(
2828 $object,
2829 $xactions,
2830 $type,
2831 PhabricatorPolicyCapability::CAN_EDIT);
2832 break;
2833 case PhabricatorTransactions::TYPE_SPACE:
2834 $errors[] = $this->validateSpaceTransactions(
2835 $object,
2836 $xactions,
2837 $type);
2838 break;
2839 case PhabricatorTransactions::TYPE_SUBTYPE:
2840 $errors[] = $this->validateSubtypeTransactions(
2841 $object,
2842 $xactions,
2843 $type);
2844 break;
2845 case PhabricatorTransactions::TYPE_MFA:
2846 $errors[] = $this->validateMFATransactions(
2847 $object,
2848 $xactions,
2849 $type);
2850 break;
2851 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
2852 $groups = array();
2853 foreach ($xactions as $xaction) {
2854 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2857 $field_list = PhabricatorCustomField::getObjectFields(
2858 $object,
2859 PhabricatorCustomField::ROLE_EDIT);
2860 $field_list->setViewer($this->getActor());
2862 $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
2863 foreach ($field_list->getFields() as $field) {
2864 if (!$field->shouldEnableForRole($role_xactions)) {
2865 continue;
2867 $errors[] = $field->validateApplicationTransactions(
2868 $this,
2869 $type,
2870 idx($groups, $field->getFieldKey(), array()));
2872 break;
2873 case PhabricatorTransactions::TYPE_FILE:
2874 $errors[] = $this->validateFileTransactions(
2875 $object,
2876 $xactions,
2877 $type);
2878 break;
2881 return array_mergev($errors);
2884 private function validateFileTransactions(
2885 PhabricatorLiskDAO $object,
2886 array $xactions,
2887 $transaction_type) {
2889 $errors = array();
2891 $mode_map = PhabricatorFileAttachment::getModeList();
2892 $mode_map = array_fuse($mode_map);
2894 $file_phids = array();
2895 foreach ($xactions as $xaction) {
2896 $new = $xaction->getNewValue();
2898 if (!is_array($new)) {
2899 $errors[] = new PhabricatorApplicationTransactionValidationError(
2900 $transaction_type,
2901 pht('Invalid'),
2902 pht(
2903 'File attachment transaction must have a map of files to '.
2904 'attachment modes, found "%s".',
2905 phutil_describe_type($new)),
2906 $xaction);
2907 continue;
2910 foreach ($new as $file_phid => $attachment_mode) {
2911 $file_phids[$file_phid] = $file_phid;
2913 if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
2914 continue;
2917 if (!is_string($attachment_mode)) {
2918 $errors[] = new PhabricatorApplicationTransactionValidationError(
2919 $transaction_type,
2920 pht('Invalid'),
2921 pht(
2922 'File attachment mode (for file "%s") is invalid. Expected '.
2923 'a string, found "%s".',
2924 $file_phid,
2925 phutil_describe_type($attachment_mode)),
2926 $xaction);
2927 } else {
2928 $errors[] = new PhabricatorApplicationTransactionValidationError(
2929 $transaction_type,
2930 pht('Invalid'),
2931 pht(
2932 'File attachment mode "%s" (for file "%s") is invalid. Valid '.
2933 'modes are: %s.',
2934 $attachment_mode,
2935 $file_phid,
2936 pht_list($mode_map)),
2937 $xaction);
2942 if ($file_phids) {
2943 $file_map = id(new PhabricatorFileQuery())
2944 ->setViewer($this->getActor())
2945 ->withPHIDs($file_phids)
2946 ->execute();
2947 $file_map = mpull($file_map, null, 'getPHID');
2948 } else {
2949 $file_map = array();
2952 foreach ($xactions as $xaction) {
2953 $new = $xaction->getNewValue();
2955 if (!is_array($new)) {
2956 continue;
2959 foreach ($new as $file_phid => $attachment_mode) {
2960 if (isset($file_map[$file_phid])) {
2961 continue;
2964 $errors[] = new PhabricatorApplicationTransactionValidationError(
2965 $transaction_type,
2966 pht('Invalid'),
2967 pht(
2968 'File "%s" is invalid: it could not be loaded, or you do not '.
2969 'have permission to view it. You must be able to see a file to '.
2970 'attach it to an object.',
2971 $file_phid),
2972 $xaction);
2976 return $errors;
2980 public function validatePolicyTransaction(
2981 PhabricatorLiskDAO $object,
2982 array $xactions,
2983 $transaction_type,
2984 $capability) {
2986 $actor = $this->requireActor();
2987 $errors = array();
2988 // Note $this->xactions is necessary; $xactions is $this->xactions of
2989 // $transaction_type
2990 $policy_object = $this->adjustObjectForPolicyChecks(
2991 $object,
2992 $this->xactions);
2994 // Make sure the user isn't editing away their ability to $capability this
2995 // object.
2996 foreach ($xactions as $xaction) {
2997 try {
2998 PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
2999 $actor,
3000 $policy_object,
3001 $capability,
3002 $xaction->getNewValue());
3003 } catch (PhabricatorPolicyException $ex) {
3004 $errors[] = new PhabricatorApplicationTransactionValidationError(
3005 $transaction_type,
3006 pht('Invalid'),
3007 pht(
3008 'You can not select this %s policy, because you would no longer '.
3009 'be able to %s the object.',
3010 $capability,
3011 $capability),
3012 $xaction);
3016 if ($this->getIsNewObject()) {
3017 if (!$xactions) {
3018 $has_capability = PhabricatorPolicyFilter::hasCapability(
3019 $actor,
3020 $policy_object,
3021 $capability);
3022 if (!$has_capability) {
3023 $errors[] = new PhabricatorApplicationTransactionValidationError(
3024 $transaction_type,
3025 pht('Invalid'),
3026 pht(
3027 'The selected %s policy excludes you. Choose a %s policy '.
3028 'which allows you to %s the object.',
3029 $capability,
3030 $capability,
3031 $capability));
3036 return $errors;
3040 private function validateSpaceTransactions(
3041 PhabricatorLiskDAO $object,
3042 array $xactions,
3043 $transaction_type) {
3044 $errors = array();
3046 $actor = $this->getActor();
3048 $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
3049 $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
3050 $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
3051 $actor);
3052 foreach ($xactions as $xaction) {
3053 $space_phid = $xaction->getNewValue();
3055 if ($space_phid === null) {
3056 if (!$has_spaces) {
3057 // The install doesn't have any spaces, so this is fine.
3058 continue;
3061 // The install has some spaces, so every object needs to be put
3062 // in a valid space.
3063 $errors[] = new PhabricatorApplicationTransactionValidationError(
3064 $transaction_type,
3065 pht('Invalid'),
3066 pht('You must choose a space for this object.'),
3067 $xaction);
3068 continue;
3071 // If the PHID isn't `null`, it needs to be a valid space that the
3072 // viewer can see.
3073 if (empty($actor_spaces[$space_phid])) {
3074 $errors[] = new PhabricatorApplicationTransactionValidationError(
3075 $transaction_type,
3076 pht('Invalid'),
3077 pht(
3078 'You can not shift this object in the selected space, because '.
3079 'the space does not exist or you do not have access to it.'),
3080 $xaction);
3081 } else if (empty($active_spaces[$space_phid])) {
3083 // It's OK to edit objects in an archived space, so just move on if
3084 // we aren't adjusting the value.
3085 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
3086 if ($space_phid == $old_space_phid) {
3087 continue;
3090 $errors[] = new PhabricatorApplicationTransactionValidationError(
3091 $transaction_type,
3092 pht('Archived'),
3093 pht(
3094 'You can not shift this object into the selected space, because '.
3095 'the space is archived. Objects can not be created inside (or '.
3096 'moved into) archived spaces.'),
3097 $xaction);
3101 return $errors;
3104 private function validateSubtypeTransactions(
3105 PhabricatorLiskDAO $object,
3106 array $xactions,
3107 $transaction_type) {
3108 $errors = array();
3110 $map = $object->newEditEngineSubtypeMap();
3111 $old = $object->getEditEngineSubtype();
3112 foreach ($xactions as $xaction) {
3113 $new = $xaction->getNewValue();
3115 if ($old == $new) {
3116 continue;
3119 if (!$map->isValidSubtype($new)) {
3120 $errors[] = new PhabricatorApplicationTransactionValidationError(
3121 $transaction_type,
3122 pht('Invalid'),
3123 pht(
3124 'The subtype "%s" is not a valid subtype.',
3125 $new),
3126 $xaction);
3127 continue;
3131 return $errors;
3134 private function validateMFATransactions(
3135 PhabricatorLiskDAO $object,
3136 array $xactions,
3137 $transaction_type) {
3138 $errors = array();
3140 $factors = id(new PhabricatorAuthFactorConfigQuery())
3141 ->setViewer($this->getActor())
3142 ->withUserPHIDs(array($this->getActingAsPHID()))
3143 ->withFactorProviderStatuses(
3144 array(
3145 PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
3146 PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
3148 ->execute();
3150 foreach ($xactions as $xaction) {
3151 if (!$factors) {
3152 $errors[] = new PhabricatorApplicationTransactionValidationError(
3153 $transaction_type,
3154 pht('No MFA'),
3155 pht(
3156 'You do not have any MFA factors attached to your account, so '.
3157 'you can not sign this transaction group with MFA. Add MFA to '.
3158 'your account in Settings.'),
3159 $xaction);
3163 if ($xactions) {
3164 $this->setShouldRequireMFA(true);
3167 return $errors;
3170 protected function adjustObjectForPolicyChecks(
3171 PhabricatorLiskDAO $object,
3172 array $xactions) {
3174 $copy = clone $object;
3176 foreach ($xactions as $xaction) {
3177 switch ($xaction->getTransactionType()) {
3178 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
3179 $clone_xaction = clone $xaction;
3180 $clone_xaction->setOldValue(array_values($this->subscribers));
3181 $clone_xaction->setNewValue(
3182 $this->getPHIDTransactionNewValue(
3183 $clone_xaction));
3185 PhabricatorPolicyRule::passTransactionHintToRule(
3186 $copy,
3187 new PhabricatorSubscriptionsSubscribersPolicyRule(),
3188 array_fuse($clone_xaction->getNewValue()));
3190 break;
3191 case PhabricatorTransactions::TYPE_SPACE:
3192 $space_phid = $this->getTransactionNewValue($object, $xaction);
3193 $copy->setSpacePHID($space_phid);
3194 break;
3198 return $copy;
3201 protected function validateAllTransactions(
3202 PhabricatorLiskDAO $object,
3203 array $xactions) {
3204 return array();
3208 * Check for a missing text field.
3210 * A text field is missing if the object has no value and there are no
3211 * transactions which set a value, or if the transactions remove the value.
3212 * This method is intended to make implementing @{method:validateTransaction}
3213 * more convenient:
3215 * $missing = $this->validateIsEmptyTextField(
3216 * $object->getName(),
3217 * $xactions);
3219 * This will return `true` if the net effect of the object and transactions
3220 * is an empty field.
3222 * @param wild Current field value.
3223 * @param list<PhabricatorApplicationTransaction> Transactions editing the
3224 * field.
3225 * @return bool True if the field will be an empty text field after edits.
3227 protected function validateIsEmptyTextField($field_value, array $xactions) {
3228 if (($field_value !== null && strlen($field_value)) && empty($xactions)) {
3229 return false;
3232 if ($xactions && strlen(last($xactions)->getNewValue())) {
3233 return false;
3236 return true;
3240 /* -( Implicit CCs )------------------------------------------------------- */
3244 * When a user interacts with an object, we might want to add them to CC.
3246 final public function applyImplicitCC(
3247 PhabricatorLiskDAO $object,
3248 array $xactions) {
3250 if (!($object instanceof PhabricatorSubscribableInterface)) {
3251 // If the object isn't subscribable, we can't CC them.
3252 return $xactions;
3255 $actor_phid = $this->getActingAsPHID();
3257 $type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
3258 if (phid_get_type($actor_phid) != $type_user) {
3259 // Transactions by application actors like Herald, Harbormaster and
3260 // Diffusion should not CC the applications.
3261 return $xactions;
3264 if ($object->isAutomaticallySubscribed($actor_phid)) {
3265 // If they're auto-subscribed, don't CC them.
3266 return $xactions;
3269 $should_cc = false;
3270 foreach ($xactions as $xaction) {
3271 if ($this->shouldImplyCC($object, $xaction)) {
3272 $should_cc = true;
3273 break;
3277 if (!$should_cc) {
3278 // Only some types of actions imply a CC (like adding a comment).
3279 return $xactions;
3282 if ($object->getPHID()) {
3283 if (isset($this->subscribers[$actor_phid])) {
3284 // If the user is already subscribed, don't implicitly CC them.
3285 return $xactions;
3288 $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
3289 $object->getPHID(),
3290 PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
3291 $unsub = array_fuse($unsub);
3292 if (isset($unsub[$actor_phid])) {
3293 // If the user has previously unsubscribed from this object explicitly,
3294 // don't implicitly CC them.
3295 return $xactions;
3299 $actor = $this->getActor();
3301 $user = id(new PhabricatorPeopleQuery())
3302 ->setViewer($actor)
3303 ->withPHIDs(array($actor_phid))
3304 ->executeOne();
3305 if (!$user) {
3306 return $xactions;
3309 // When a bot acts (usually via the API), don't automatically subscribe
3310 // them as a side effect. They can always subscribe explicitly if they
3311 // want, and bot subscriptions normally just clutter things up since bots
3312 // usually do not read email.
3313 if ($user->getIsSystemAgent()) {
3314 return $xactions;
3317 $xaction = newv(get_class(head($xactions)), array());
3318 $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
3319 $xaction->setNewValue(array('+' => array($actor_phid)));
3321 array_unshift($xactions, $xaction);
3323 return $xactions;
3326 protected function shouldImplyCC(
3327 PhabricatorLiskDAO $object,
3328 PhabricatorApplicationTransaction $xaction) {
3330 return $xaction->isCommentTransaction();
3334 /* -( Sending Mail )------------------------------------------------------- */
3338 * @task mail
3340 protected function shouldSendMail(
3341 PhabricatorLiskDAO $object,
3342 array $xactions) {
3343 return false;
3348 * @task mail
3350 private function buildMail(
3351 PhabricatorLiskDAO $object,
3352 array $xactions) {
3354 $email_to = $this->mailToPHIDs;
3355 $email_cc = $this->mailCCPHIDs;
3356 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
3358 $unexpandable = $this->mailUnexpandablePHIDs;
3359 if (!is_array($unexpandable)) {
3360 $unexpandable = array();
3363 $messages = $this->buildMailWithRecipients(
3364 $object,
3365 $xactions,
3366 $email_to,
3367 $email_cc,
3368 $unexpandable);
3370 $this->runHeraldMailRules($messages);
3372 return $messages;
3375 private function buildMailWithRecipients(
3376 PhabricatorLiskDAO $object,
3377 array $xactions,
3378 array $email_to,
3379 array $email_cc,
3380 array $unexpandable) {
3382 $targets = $this->buildReplyHandler($object)
3383 ->setUnexpandablePHIDs($unexpandable)
3384 ->getMailTargets($email_to, $email_cc);
3386 // Set this explicitly before we start swapping out the effective actor.
3387 $this->setActingAsPHID($this->getActingAsPHID());
3389 $xaction_phids = mpull($xactions, 'getPHID');
3391 $messages = array();
3392 foreach ($targets as $target) {
3393 $original_actor = $this->getActor();
3395 $viewer = $target->getViewer();
3396 $this->setActor($viewer);
3397 $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
3399 $caught = null;
3400 $mail = null;
3401 try {
3402 // Reload the transactions for the current viewer.
3403 if ($xaction_phids) {
3404 $query = PhabricatorApplicationTransactionQuery::newQueryForObject(
3405 $object);
3407 $mail_xactions = $query
3408 ->setViewer($viewer)
3409 ->withObjectPHIDs(array($object->getPHID()))
3410 ->withPHIDs($xaction_phids)
3411 ->execute();
3413 // Sort the mail transactions in the input order.
3414 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3415 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3416 $mail_xactions = array_values($mail_xactions);
3417 } else {
3418 $mail_xactions = array();
3421 // Reload handles for the current viewer. This covers older code which
3422 // emits a list of handle PHIDs upfront.
3423 $this->loadHandles($mail_xactions);
3425 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3427 if ($mail) {
3428 if ($this->mustEncrypt) {
3429 $mail
3430 ->setMustEncrypt(true)
3431 ->setMustEncryptReasons($this->mustEncrypt);
3434 } catch (Exception $ex) {
3435 $caught = $ex;
3438 $this->setActor($original_actor);
3439 unset($locale);
3441 if ($caught) {
3442 throw $ex;
3445 if ($mail) {
3446 $messages[] = $mail;
3450 return $messages;
3453 protected function getTransactionsForMail(
3454 PhabricatorLiskDAO $object,
3455 array $xactions) {
3456 return $xactions;
3459 private function buildMailForTarget(
3460 PhabricatorLiskDAO $object,
3461 array $xactions,
3462 PhabricatorMailTarget $target) {
3464 // Check if any of the transactions are visible for this viewer. If we
3465 // don't have any visible transactions, don't send the mail.
3467 $any_visible = false;
3468 foreach ($xactions as $xaction) {
3469 if (!$xaction->shouldHideForMail($xactions)) {
3470 $any_visible = true;
3471 break;
3475 if (!$any_visible) {
3476 return null;
3479 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3481 $mail = $this->buildMailTemplate($object);
3482 $body = $this->buildMailBody($object, $mail_xactions);
3484 $mail_tags = $this->getMailTags($object, $mail_xactions);
3485 $action = $this->getMailAction($object, $mail_xactions);
3486 $stamps = $this->generateMailStamps($object, $this->mailStamps);
3488 if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
3489 $this->addEmailPreferenceSectionToMailBody(
3490 $body,
3491 $object,
3492 $mail_xactions);
3495 $muted_phids = $this->mailMutedPHIDs;
3496 if (!is_array($muted_phids)) {
3497 $muted_phids = array();
3500 $mail
3501 ->setSensitiveContent(false)
3502 ->setFrom($this->getActingAsPHID())
3503 ->setSubjectPrefix($this->getMailSubjectPrefix())
3504 ->setVarySubjectPrefix('['.$action.']')
3505 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3506 ->setRelatedPHID($object->getPHID())
3507 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3508 ->setMutedPHIDs($muted_phids)
3509 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
3510 ->setMailTags($mail_tags)
3511 ->setIsBulk(true)
3512 ->setBody($body->render())
3513 ->setHTMLBody($body->renderHTML());
3515 foreach ($body->getAttachments() as $attachment) {
3516 $mail->addAttachment($attachment);
3519 if ($this->heraldHeader) {
3520 $mail->addHeader('X-Herald-Rules', $this->heraldHeader);
3523 if ($object instanceof PhabricatorProjectInterface) {
3524 $this->addMailProjectMetadata($object, $mail);
3527 if ($this->getParentMessageID()) {
3528 $mail->setParentMessageID($this->getParentMessageID());
3531 // If we have stamps, attach the raw dictionary version (not the actual
3532 // objects) to the mail so that debugging tools can see what we used to
3533 // render the final list.
3534 if ($this->mailStamps) {
3535 $mail->setMailStampMetadata($this->mailStamps);
3538 // If we have rendered stamps, attach them to the mail.
3539 if ($stamps) {
3540 $mail->setMailStamps($stamps);
3543 return $target->willSendMail($mail);
3546 private function addMailProjectMetadata(
3547 PhabricatorLiskDAO $object,
3548 PhabricatorMetaMTAMail $template) {
3550 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3551 $object->getPHID(),
3552 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3554 if (!$project_phids) {
3555 return;
3558 // TODO: This viewer isn't quite right. It would be slightly better to use
3559 // the mail recipient, but that's not very easy given the way rendering
3560 // works today.
3562 $handles = id(new PhabricatorHandleQuery())
3563 ->setViewer($this->requireActor())
3564 ->withPHIDs($project_phids)
3565 ->execute();
3567 $project_tags = array();
3568 foreach ($handles as $handle) {
3569 if (!$handle->isComplete()) {
3570 continue;
3572 $project_tags[] = '<'.$handle->getObjectName().'>';
3575 if (!$project_tags) {
3576 return;
3579 $project_tags = implode(', ', $project_tags);
3580 $template->addHeader('X-Phabricator-Projects', $project_tags);
3584 protected function getMailThreadID(PhabricatorLiskDAO $object) {
3585 return $object->getPHID();
3590 * @task mail
3592 protected function getStrongestAction(
3593 PhabricatorLiskDAO $object,
3594 array $xactions) {
3595 return head(msortv($xactions, 'newActionStrengthSortVector'));
3600 * @task mail
3602 protected function buildReplyHandler(PhabricatorLiskDAO $object) {
3603 throw new Exception(pht('Capability not supported.'));
3607 * @task mail
3609 protected function getMailSubjectPrefix() {
3610 throw new Exception(pht('Capability not supported.'));
3615 * @task mail
3617 protected function getMailTags(
3618 PhabricatorLiskDAO $object,
3619 array $xactions) {
3620 $tags = array();
3622 foreach ($xactions as $xaction) {
3623 $tags[] = $xaction->getMailTags();
3626 return array_mergev($tags);
3630 * @task mail
3632 public function getMailTagsMap() {
3633 // TODO: We should move shared mail tags, like "comment", here.
3634 return array();
3639 * @task mail
3641 protected function getMailAction(
3642 PhabricatorLiskDAO $object,
3643 array $xactions) {
3644 return $this->getStrongestAction($object, $xactions)->getActionName();
3649 * @task mail
3651 protected function buildMailTemplate(PhabricatorLiskDAO $object) {
3652 throw new Exception(pht('Capability not supported.'));
3657 * @task mail
3659 protected function getMailTo(PhabricatorLiskDAO $object) {
3660 throw new Exception(pht('Capability not supported.'));
3664 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
3665 return array();
3670 * @task mail
3672 protected function getMailCC(PhabricatorLiskDAO $object) {
3673 $phids = array();
3674 $has_support = false;
3676 if ($object instanceof PhabricatorSubscribableInterface) {
3677 $phid = $object->getPHID();
3678 $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
3679 $has_support = true;
3682 if ($object instanceof PhabricatorProjectInterface) {
3683 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3684 $object->getPHID(),
3685 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3687 if ($project_phids) {
3688 $projects = id(new PhabricatorProjectQuery())
3689 ->setViewer(PhabricatorUser::getOmnipotentUser())
3690 ->withPHIDs($project_phids)
3691 ->needWatchers(true)
3692 ->execute();
3694 $watcher_phids = array();
3695 foreach ($projects as $project) {
3696 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3697 $watcher_phids[$phid] = $phid;
3701 if ($watcher_phids) {
3702 // We need to do a visibility check for all the watchers, as
3703 // watching a project is not a guarantee that you can see objects
3704 // associated with it.
3705 $users = id(new PhabricatorPeopleQuery())
3706 ->setViewer($this->requireActor())
3707 ->withPHIDs($watcher_phids)
3708 ->execute();
3710 $watchers = array();
3711 foreach ($users as $user) {
3712 $can_see = PhabricatorPolicyFilter::hasCapability(
3713 $user,
3714 $object,
3715 PhabricatorPolicyCapability::CAN_VIEW);
3716 if ($can_see) {
3717 $watchers[] = $user->getPHID();
3720 $phids[] = $watchers;
3724 $has_support = true;
3727 if (!$has_support) {
3728 throw new Exception(
3729 pht('The object being edited does not implement any standard '.
3730 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3731 'CCs to be generated automatically. Override the "getMailCC()" '.
3732 'method and generate CCs explicitly.'));
3735 return array_mergev($phids);
3740 * @task mail
3742 protected function buildMailBody(
3743 PhabricatorLiskDAO $object,
3744 array $xactions) {
3746 $body = id(new PhabricatorMetaMTAMailBody())
3747 ->setViewer($this->requireActor())
3748 ->setContextObject($object);
3750 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3751 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3753 $this->addHeadersAndCommentsToMailBody(
3754 $body,
3755 $xactions,
3756 $button_label,
3757 $button_uri);
3759 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3761 return $body;
3764 protected function getObjectLinkButtonLabelForMail(
3765 PhabricatorLiskDAO $object) {
3766 return null;
3769 protected function getObjectLinkButtonURIForMail(
3770 PhabricatorLiskDAO $object) {
3772 // Most objects define a "getURI()" method which does what we want, but
3773 // this isn't formally part of an interface at time of writing. Try to
3774 // call the method, expecting an exception if it does not exist.
3776 try {
3777 $uri = $object->getURI();
3778 return PhabricatorEnv::getProductionURI($uri);
3779 } catch (Exception $ex) {
3780 return null;
3785 * @task mail
3787 protected function addEmailPreferenceSectionToMailBody(
3788 PhabricatorMetaMTAMailBody $body,
3789 PhabricatorLiskDAO $object,
3790 array $xactions) {
3792 $href = PhabricatorEnv::getProductionURI(
3793 '/settings/panel/emailpreferences/');
3794 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3799 * @task mail
3801 protected function addHeadersAndCommentsToMailBody(
3802 PhabricatorMetaMTAMailBody $body,
3803 array $xactions,
3804 $object_label = null,
3805 $object_uri = null) {
3807 // First, remove transactions which shouldn't be rendered in mail.
3808 foreach ($xactions as $key => $xaction) {
3809 if ($xaction->shouldHideForMail($xactions)) {
3810 unset($xactions[$key]);
3814 $headers = array();
3815 $headers_html = array();
3816 $comments = array();
3817 $details = array();
3819 $seen_comment = false;
3820 foreach ($xactions as $xaction) {
3822 // Most mail has zero or one comments. In these cases, we render the
3823 // "alice added a comment." transaction in the header, like a normal
3824 // transaction.
3826 // Some mail, like Differential undraft mail or "!history" mail, may
3827 // have two or more comments. In these cases, we'll put the first
3828 // "alice added a comment." transaction in the header normally, but
3829 // move the other transactions down so they provide context above the
3830 // actual comment.
3832 $comment = $this->getBodyForTextMail($xaction);
3833 if ($comment !== null) {
3834 $is_comment = true;
3835 $comments[] = array(
3836 'xaction' => $xaction,
3837 'comment' => $comment,
3838 'initial' => !$seen_comment,
3840 } else {
3841 $is_comment = false;
3844 if (!$is_comment || !$seen_comment) {
3845 $header = $this->getTitleForTextMail($xaction);
3846 if ($header !== null) {
3847 $headers[] = $header;
3850 $header_html = $this->getTitleForHTMLMail($xaction);
3851 if ($header_html !== null) {
3852 $headers_html[] = $header_html;
3856 if ($xaction->hasChangeDetailsForMail()) {
3857 $details[] = $xaction;
3860 if ($is_comment) {
3861 $seen_comment = true;
3865 $headers_text = implode("\n", $headers);
3866 $body->addRawPlaintextSection($headers_text);
3868 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3870 $header_button = null;
3871 if ($object_label !== null && $object_uri !== null) {
3872 $button_style = array(
3873 'text-decoration: none;',
3874 'padding: 4px 8px;',
3875 'margin: 0 8px 8px;',
3876 'float: right;',
3877 'color: #464C5C;',
3878 'font-weight: bold;',
3879 'border-radius: 3px;',
3880 'background-color: #F7F7F9;',
3881 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3882 'display: inline-block;',
3883 'border: 1px solid rgba(71,87,120,.2);',
3886 $header_button = phutil_tag(
3887 'a',
3888 array(
3889 'style' => implode(' ', $button_style),
3890 'href' => $object_uri,
3892 $object_label);
3895 $xactions_style = array();
3897 $header_action = phutil_tag(
3898 'td',
3899 array(),
3900 $header_button);
3902 $header_action = phutil_tag(
3903 'td',
3904 array(
3905 'style' => implode(' ', $xactions_style),
3907 array(
3908 $headers_html,
3909 // Add an extra newline to prevent the "View Object" button from
3910 // running into the transaction text in Mail.app text snippet
3911 // previews.
3912 "\n",
3915 $headers_html = phutil_tag(
3916 'table',
3917 array(),
3918 phutil_tag('tr', array(), array($header_action, $header_button)));
3920 $body->addRawHTMLSection($headers_html);
3922 foreach ($comments as $spec) {
3923 $xaction = $spec['xaction'];
3924 $comment = $spec['comment'];
3925 $is_initial = $spec['initial'];
3927 // If this is not the first comment in the mail, add the header showing
3928 // who wrote the comment immediately above the comment.
3929 if (!$is_initial) {
3930 $header = $this->getTitleForTextMail($xaction);
3931 if ($header !== null) {
3932 $body->addRawPlaintextSection($header);
3935 $header_html = $this->getTitleForHTMLMail($xaction);
3936 if ($header_html !== null) {
3937 $body->addRawHTMLSection($header_html);
3941 $body->addRemarkupSection(null, $comment);
3944 foreach ($details as $xaction) {
3945 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
3946 if ($details !== null) {
3947 $label = $this->getMailDiffSectionHeader($xaction);
3948 $body->addHTMLSection($label, $details);
3954 private function getMailDiffSectionHeader($xaction) {
3955 $type = $xaction->getTransactionType();
3957 $xtype = $this->getModularTransactionType($type);
3958 if ($xtype) {
3959 return $xtype->getMailDiffSectionHeader();
3962 return pht('EDIT DETAILS');
3966 * @task mail
3968 protected function addCustomFieldsToMailBody(
3969 PhabricatorMetaMTAMailBody $body,
3970 PhabricatorLiskDAO $object,
3971 array $xactions) {
3973 if ($object instanceof PhabricatorCustomFieldInterface) {
3974 $field_list = PhabricatorCustomField::getObjectFields(
3975 $object,
3976 PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
3977 $field_list->setViewer($this->getActor());
3978 $field_list->readFieldsFromStorage($object);
3980 foreach ($field_list->getFields() as $field) {
3981 $field->updateTransactionMailBody(
3982 $body,
3983 $this,
3984 $xactions);
3991 * @task mail
3993 private function runHeraldMailRules(array $messages) {
3994 foreach ($messages as $message) {
3995 $engine = new HeraldEngine();
3996 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
3997 ->setObject($message);
3999 $rules = $engine->loadRulesForAdapter($adapter);
4000 $effects = $engine->applyRules($rules, $adapter);
4001 $engine->applyEffects($effects, $adapter, $rules);
4006 /* -( Publishing Feed Stories )-------------------------------------------- */
4010 * @task feed
4012 protected function shouldPublishFeedStory(
4013 PhabricatorLiskDAO $object,
4014 array $xactions) {
4015 return false;
4020 * @task feed
4022 protected function getFeedStoryType() {
4023 return 'PhabricatorApplicationTransactionFeedStory';
4028 * @task feed
4030 protected function getFeedRelatedPHIDs(
4031 PhabricatorLiskDAO $object,
4032 array $xactions) {
4034 $phids = array(
4035 $object->getPHID(),
4036 $this->getActingAsPHID(),
4039 if ($object instanceof PhabricatorProjectInterface) {
4040 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
4041 $object->getPHID(),
4042 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
4043 foreach ($project_phids as $project_phid) {
4044 $phids[] = $project_phid;
4048 return $phids;
4053 * @task feed
4055 protected function getFeedNotifyPHIDs(
4056 PhabricatorLiskDAO $object,
4057 array $xactions) {
4059 // If some transactions are forcing notification delivery, add the forced
4060 // recipients to the notify list.
4061 $force_list = array();
4062 foreach ($xactions as $xaction) {
4063 $force_phids = $xaction->getForceNotifyPHIDs();
4065 if (!$force_phids) {
4066 continue;
4069 foreach ($force_phids as $force_phid) {
4070 $force_list[] = $force_phid;
4074 $to_list = $this->getMailTo($object);
4075 $cc_list = $this->getMailCC($object);
4077 $full_list = array_merge($force_list, $to_list, $cc_list);
4078 $full_list = array_fuse($full_list);
4080 return array_keys($full_list);
4085 * @task feed
4087 protected function getFeedStoryData(
4088 PhabricatorLiskDAO $object,
4089 array $xactions) {
4091 $xactions = msortv($xactions, 'newActionStrengthSortVector');
4093 return array(
4094 'objectPHID' => $object->getPHID(),
4095 'transactionPHIDs' => mpull($xactions, 'getPHID'),
4101 * @task feed
4103 protected function publishFeedStory(
4104 PhabricatorLiskDAO $object,
4105 array $xactions,
4106 array $mailed_phids) {
4108 // Remove transactions which don't publish feed stories or notifications.
4109 // These never show up anywhere, so we don't need to do anything with them.
4110 foreach ($xactions as $key => $xaction) {
4111 if (!$xaction->shouldHideForFeed()) {
4112 continue;
4115 if (!$xaction->shouldHideForNotifications()) {
4116 continue;
4119 unset($xactions[$key]);
4122 if (!$xactions) {
4123 return;
4126 $related_phids = $this->feedRelatedPHIDs;
4127 $subscribed_phids = $this->feedNotifyPHIDs;
4129 // Remove muted users from the subscription list so they don't get
4130 // notifications, either.
4131 $muted_phids = $this->mailMutedPHIDs;
4132 if (!is_array($muted_phids)) {
4133 $muted_phids = array();
4135 $subscribed_phids = array_fuse($subscribed_phids);
4136 foreach ($muted_phids as $muted_phid) {
4137 unset($subscribed_phids[$muted_phid]);
4139 $subscribed_phids = array_values($subscribed_phids);
4141 $story_type = $this->getFeedStoryType();
4142 $story_data = $this->getFeedStoryData($object, $xactions);
4144 $unexpandable_phids = $this->mailUnexpandablePHIDs;
4145 if (!is_array($unexpandable_phids)) {
4146 $unexpandable_phids = array();
4149 id(new PhabricatorFeedStoryPublisher())
4150 ->setStoryType($story_type)
4151 ->setStoryData($story_data)
4152 ->setStoryTime(time())
4153 ->setStoryAuthorPHID($this->getActingAsPHID())
4154 ->setRelatedPHIDs($related_phids)
4155 ->setPrimaryObjectPHID($object->getPHID())
4156 ->setSubscribedPHIDs($subscribed_phids)
4157 ->setUnexpandablePHIDs($unexpandable_phids)
4158 ->setMailRecipientPHIDs($mailed_phids)
4159 ->setMailTags($this->getMailTags($object, $xactions))
4160 ->publish();
4164 /* -( Search Index )------------------------------------------------------- */
4168 * @task search
4170 protected function supportsSearch() {
4171 return false;
4175 /* -( Herald Integration )-------------------------------------------------- */
4178 protected function shouldApplyHeraldRules(
4179 PhabricatorLiskDAO $object,
4180 array $xactions) {
4181 return false;
4184 protected function buildHeraldAdapter(
4185 PhabricatorLiskDAO $object,
4186 array $xactions) {
4187 throw new Exception(pht('No herald adapter specified.'));
4190 private function setHeraldAdapter(HeraldAdapter $adapter) {
4191 $this->heraldAdapter = $adapter;
4192 return $this;
4195 protected function getHeraldAdapter() {
4196 return $this->heraldAdapter;
4199 private function setHeraldTranscript(HeraldTranscript $transcript) {
4200 $this->heraldTranscript = $transcript;
4201 return $this;
4204 protected function getHeraldTranscript() {
4205 return $this->heraldTranscript;
4208 private function applyHeraldRules(
4209 PhabricatorLiskDAO $object,
4210 array $xactions) {
4212 $adapter = $this->buildHeraldAdapter($object, $xactions)
4213 ->setContentSource($this->getContentSource())
4214 ->setIsNewObject($this->getIsNewObject())
4215 ->setActingAsPHID($this->getActingAsPHID())
4216 ->setAppliedTransactions($xactions);
4218 if ($this->getApplicationEmail()) {
4219 $adapter->setApplicationEmail($this->getApplicationEmail());
4222 // If this editor is operating in silent mode, tell Herald that we aren't
4223 // going to send any mail. This allows it to skip "the first time this
4224 // rule matches, send me an email" rules which would otherwise match even
4225 // though we aren't going to send any mail.
4226 if ($this->getIsSilent()) {
4227 $adapter->setForbiddenAction(
4228 HeraldMailableState::STATECONST,
4229 HeraldCoreStateReasons::REASON_SILENT);
4232 $xscript = HeraldEngine::loadAndApplyRules($adapter);
4234 $this->setHeraldAdapter($adapter);
4235 $this->setHeraldTranscript($xscript);
4237 if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
4238 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
4240 HarbormasterBuildable::applyBuildPlans(
4241 $buildable_phid,
4242 $adapter->getHarbormasterContainerPHID(),
4243 $adapter->getQueuedHarbormasterBuildRequests());
4245 // Whether we queued any builds or not, any automatic buildable for this
4246 // object is now done preparing builds and can transition into a
4247 // completed status.
4248 $buildables = id(new HarbormasterBuildableQuery())
4249 ->setViewer(PhabricatorUser::getOmnipotentUser())
4250 ->withManualBuildables(false)
4251 ->withBuildablePHIDs(array($buildable_phid))
4252 ->execute();
4253 foreach ($buildables as $buildable) {
4254 // If this buildable has already moved beyond preparation, we don't
4255 // need to nudge it again.
4256 if (!$buildable->isPreparing()) {
4257 continue;
4259 $buildable->sendMessage(
4260 $this->getActor(),
4261 HarbormasterMessageType::BUILDABLE_BUILD,
4262 true);
4266 $this->mustEncrypt = $adapter->getMustEncryptReasons();
4268 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
4269 foreach ($this->subEditors as $sub_editor) {
4270 $sub_editor->mustEncrypt = $this->mustEncrypt;
4273 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
4274 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
4276 $queue_xactions = $adapter->getQueuedTransactions();
4278 return array_merge(
4279 array_values($apply_xactions),
4280 array_values($queue_xactions));
4283 protected function didApplyHeraldRules(
4284 PhabricatorLiskDAO $object,
4285 HeraldAdapter $adapter,
4286 HeraldTranscript $transcript) {
4287 return array();
4291 /* -( Custom Fields )------------------------------------------------------ */
4295 * @task customfield
4297 private function getCustomFieldForTransaction(
4298 PhabricatorLiskDAO $object,
4299 PhabricatorApplicationTransaction $xaction) {
4301 $field_key = $xaction->getMetadataValue('customfield:key');
4302 if (!$field_key) {
4303 throw new Exception(
4304 pht(
4305 "Custom field transaction has no '%s'!",
4306 'customfield:key'));
4309 $field = PhabricatorCustomField::getObjectField(
4310 $object,
4311 PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
4312 $field_key);
4314 if (!$field) {
4315 throw new Exception(
4316 pht(
4317 "Custom field transaction has invalid '%s'; field '%s' ".
4318 "is disabled or does not exist.",
4319 'customfield:key',
4320 $field_key));
4323 if (!$field->shouldAppearInApplicationTransactions()) {
4324 throw new Exception(
4325 pht(
4326 "Custom field transaction '%s' does not implement ".
4327 "integration for %s.",
4328 $field_key,
4329 'ApplicationTransactions'));
4332 $field->setViewer($this->getActor());
4334 return $field;
4338 /* -( Files )-------------------------------------------------------------- */
4342 * Extract the PHIDs of any files which these transactions attach.
4344 * @task files
4346 private function extractFilePHIDs(
4347 PhabricatorLiskDAO $object,
4348 array $xactions) {
4350 $phids = array();
4352 foreach ($xactions as $xaction) {
4353 $type = $xaction->getTransactionType();
4355 $xtype = $this->getModularTransactionType($type);
4356 if ($xtype) {
4357 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4358 } else {
4359 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4360 $object,
4361 $xaction);
4365 $phids = array_unique(array_filter(array_mergev($phids)));
4367 return $phids;
4371 * @task files
4373 protected function extractFilePHIDsFromCustomTransaction(
4374 PhabricatorLiskDAO $object,
4375 PhabricatorApplicationTransaction $xaction) {
4376 return array();
4380 private function applyInverseEdgeTransactions(
4381 PhabricatorLiskDAO $object,
4382 PhabricatorApplicationTransaction $xaction,
4383 $inverse_type) {
4385 $old = $xaction->getOldValue();
4386 $new = $xaction->getNewValue();
4388 $add = array_keys(array_diff_key($new, $old));
4389 $rem = array_keys(array_diff_key($old, $new));
4391 $add = array_fuse($add);
4392 $rem = array_fuse($rem);
4393 $all = $add + $rem;
4395 $nodes = id(new PhabricatorObjectQuery())
4396 ->setViewer($this->requireActor())
4397 ->withPHIDs($all)
4398 ->execute();
4400 $object_phid = $object->getPHID();
4402 foreach ($nodes as $node) {
4403 if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
4404 continue;
4407 if ($node instanceof PhabricatorUser) {
4408 // TODO: At least for now, don't record inverse edge transactions
4409 // for users (for example, "alincoln joined project X"): Feed fills
4410 // this role instead.
4411 continue;
4414 $node_phid = $node->getPHID();
4415 $editor = $node->getApplicationTransactionEditor();
4416 $template = $node->getApplicationTransactionTemplate();
4418 // See T13082. We have to build these transactions with synthetic values
4419 // because we've already applied the actual edit to the edge database
4420 // table. If we try to apply this transaction naturally, it will no-op
4421 // itself because it doesn't have any effect.
4423 $edge_query = id(new PhabricatorEdgeQuery())
4424 ->withSourcePHIDs(array($node_phid))
4425 ->withEdgeTypes(array($inverse_type));
4427 $edge_query->execute();
4429 $edge_phids = $edge_query->getDestinationPHIDs();
4430 $edge_phids = array_fuse($edge_phids);
4432 $new_phids = $edge_phids;
4433 $old_phids = $edge_phids;
4435 if (isset($add[$node_phid])) {
4436 unset($old_phids[$object_phid]);
4437 } else {
4438 $old_phids[$object_phid] = $object_phid;
4441 $template
4442 ->setTransactionType($xaction->getTransactionType())
4443 ->setMetadataValue('edge:type', $inverse_type)
4444 ->setOldValue($old_phids)
4445 ->setNewValue($new_phids);
4447 $editor = $this->newSubEditor($editor)
4448 ->setContinueOnNoEffect(true)
4449 ->setContinueOnMissingFields(true)
4450 ->setIsInverseEdgeEditor(true);
4452 $editor->applyTransactions($node, array($template));
4457 /* -( Workers )------------------------------------------------------------ */
4461 * Load any object state which is required to publish transactions.
4463 * This hook is invoked in the main process before we compute data related
4464 * to publishing transactions (like email "To" and "CC" lists), and again in
4465 * the worker before publishing occurs.
4467 * @return object Publishable object.
4468 * @task workers
4470 protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
4471 return $object;
4476 * Convert the editor state to a serializable dictionary which can be passed
4477 * to a worker.
4479 * This data will be loaded with @{method:loadWorkerState} in the worker.
4481 * @return dict<string, wild> Serializable editor state.
4482 * @task workers
4484 private function getWorkerState() {
4485 $state = array();
4486 foreach ($this->getAutomaticStateProperties() as $property) {
4487 $state[$property] = $this->$property;
4490 $custom_state = $this->getCustomWorkerState();
4491 $custom_encoding = $this->getCustomWorkerStateEncoding();
4493 $state += array(
4494 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4495 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4496 'custom.encoding' => $custom_encoding,
4499 return $state;
4504 * Hook; return custom properties which need to be passed to workers.
4506 * @return dict<string, wild> Custom properties.
4507 * @task workers
4509 protected function getCustomWorkerState() {
4510 return array();
4515 * Hook; return storage encoding for custom properties which need to be
4516 * passed to workers.
4518 * This primarily allows binary data to be passed to workers and survive
4519 * JSON encoding.
4521 * @return dict<string, string> Property encodings.
4522 * @task workers
4524 protected function getCustomWorkerStateEncoding() {
4525 return array();
4530 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4532 * This method is used to load state when running worker operations.
4534 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4535 * @return this
4536 * @task workers
4538 final public function loadWorkerState(array $state) {
4539 foreach ($this->getAutomaticStateProperties() as $property) {
4540 $this->$property = idx($state, $property);
4543 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4544 $this->setExcludeMailRecipientPHIDs($exclude);
4546 $custom_state = idx($state, 'custom', array());
4547 $custom_encodings = idx($state, 'custom.encoding', array());
4548 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4550 $this->loadCustomWorkerState($custom);
4552 return $this;
4557 * Hook; set custom properties on the editor from data emitted by
4558 * @{method:getCustomWorkerState}.
4560 * @param dict<string, wild> Custom state,
4561 * from @{method:getCustomWorkerState}.
4562 * @return this
4563 * @task workers
4565 protected function loadCustomWorkerState(array $state) {
4566 return $this;
4571 * Get a list of object properties which should be automatically sent to
4572 * workers in the state data.
4574 * These properties will be automatically stored and loaded by the editor in
4575 * the worker.
4577 * @return list<string> List of properties.
4578 * @task workers
4580 private function getAutomaticStateProperties() {
4581 return array(
4582 'parentMessageID',
4583 'isNewObject',
4584 'heraldEmailPHIDs',
4585 'heraldForcedEmailPHIDs',
4586 'heraldHeader',
4587 'mailToPHIDs',
4588 'mailCCPHIDs',
4589 'feedNotifyPHIDs',
4590 'feedRelatedPHIDs',
4591 'feedShouldPublish',
4592 'mailShouldSend',
4593 'mustEncrypt',
4594 'mailStamps',
4595 'mailUnexpandablePHIDs',
4596 'mailMutedPHIDs',
4597 'webhookMap',
4598 'silent',
4599 'sendHistory',
4604 * Apply encodings prior to storage.
4606 * See @{method:getCustomWorkerStateEncoding}.
4608 * @param map<string, wild> Map of values to encode.
4609 * @param map<string, string> Map of encodings to apply.
4610 * @return map<string, wild> Map of encoded values.
4611 * @task workers
4613 private function encodeStateForStorage(
4614 array $state,
4615 array $encodings) {
4617 foreach ($state as $key => $value) {
4618 $encoding = idx($encodings, $key);
4619 switch ($encoding) {
4620 case self::STORAGE_ENCODING_BINARY:
4621 // The mechanics of this encoding (serialize + base64) are a little
4622 // awkward, but it allows us encode arrays and still be JSON-safe
4623 // with binary data.
4625 $value = @serialize($value);
4626 if ($value === false) {
4627 throw new Exception(
4628 pht(
4629 'Failed to serialize() value for key "%s".',
4630 $key));
4633 $value = base64_encode($value);
4634 if ($value === false) {
4635 throw new Exception(
4636 pht(
4637 'Failed to base64 encode value for key "%s".',
4638 $key));
4640 break;
4642 $state[$key] = $value;
4645 return $state;
4650 * Undo storage encoding applied when storing state.
4652 * See @{method:getCustomWorkerStateEncoding}.
4654 * @param map<string, wild> Map of encoded values.
4655 * @param map<string, string> Map of encodings.
4656 * @return map<string, wild> Map of decoded values.
4657 * @task workers
4659 private function decodeStateFromStorage(
4660 array $state,
4661 array $encodings) {
4663 foreach ($state as $key => $value) {
4664 $encoding = idx($encodings, $key);
4665 switch ($encoding) {
4666 case self::STORAGE_ENCODING_BINARY:
4667 $value = base64_decode($value);
4668 if ($value === false) {
4669 throw new Exception(
4670 pht(
4671 'Failed to base64_decode() value for key "%s".',
4672 $key));
4675 $value = unserialize($value);
4676 break;
4678 $state[$key] = $value;
4681 return $state;
4686 * Remove conflicts from a list of projects.
4688 * Objects aren't allowed to be tagged with multiple milestones in the same
4689 * group, nor projects such that one tag is the ancestor of any other tag.
4690 * If the list of PHIDs include mutually exclusive projects, remove the
4691 * conflicting projects.
4693 * @param list<phid> List of project PHIDs.
4694 * @return list<phid> List with conflicts removed.
4696 private function applyProjectConflictRules(array $phids) {
4697 if (!$phids) {
4698 return array();
4701 // Overall, the last project in the list wins in cases of conflict (so when
4702 // you add something, the thing you just added sticks and removes older
4703 // values).
4705 // Beyond that, there are two basic cases:
4707 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4708 // If multiple projects are milestones of the same parent, we only keep the
4709 // last one.
4711 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4712 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4713 // remove "A > B" and keep "A".
4715 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4716 // if one project is an ancestor of another. It's OK to have something
4717 // tagged with multiple projects which share a common ancestor, so long as
4718 // they are not mutual ancestors.
4720 $viewer = PhabricatorUser::getOmnipotentUser();
4722 $projects = id(new PhabricatorProjectQuery())
4723 ->setViewer($viewer)
4724 ->withPHIDs(array_keys($phids))
4725 ->execute();
4726 $projects = mpull($projects, null, 'getPHID');
4728 // We're going to build a map from each project with milestones to the last
4729 // milestone in the list. This last milestone is the milestone we'll keep.
4730 $milestone_map = array();
4732 // We're going to build a set of the projects which have no descendants
4733 // later in the list. This allows us to apply both ancestor rules.
4734 $ancestor_map = array();
4736 foreach ($phids as $phid => $ignored) {
4737 $project = idx($projects, $phid);
4738 if (!$project) {
4739 continue;
4742 // This is the last milestone we've seen, so set it as the selection for
4743 // the project's parent. This might be setting a new value or overwriting
4744 // an earlier value.
4745 if ($project->isMilestone()) {
4746 $parent_phid = $project->getParentProjectPHID();
4747 $milestone_map[$parent_phid] = $phid;
4750 // Since this is the last item in the list we've examined so far, add it
4751 // to the set of projects with no later descendants.
4752 $ancestor_map[$phid] = $phid;
4754 // Remove any ancestors from the set, since this is a later descendant.
4755 foreach ($project->getAncestorProjects() as $ancestor) {
4756 $ancestor_phid = $ancestor->getPHID();
4757 unset($ancestor_map[$ancestor_phid]);
4761 // Now that we've built the maps, we can throw away all the projects which
4762 // have conflicts.
4763 foreach ($phids as $phid => $ignored) {
4764 $project = idx($projects, $phid);
4766 if (!$project) {
4767 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4768 // but leaving it untouched is less likely to cause collateral damage.
4769 continue;
4772 // If this was a milestone, check if it was the last milestone from its
4773 // group in the list. If not, remove it from the list.
4774 if ($project->isMilestone()) {
4775 $parent_phid = $project->getParentProjectPHID();
4776 if ($milestone_map[$parent_phid] !== $phid) {
4777 unset($phids[$phid]);
4778 continue;
4782 // If a later project in the list is a subproject of this one, it will
4783 // have removed ancestors from the map. If this project does not point
4784 // at itself in the ancestor map, it should be discarded in favor of a
4785 // subproject that comes later.
4786 if (idx($ancestor_map, $phid) !== $phid) {
4787 unset($phids[$phid]);
4788 continue;
4791 // If a later project in the list is an ancestor of this one, it will
4792 // have added itself to the map. If any ancestor of this project points
4793 // at itself in the map, this project should be discarded in favor of
4794 // that later ancestor.
4795 foreach ($project->getAncestorProjects() as $ancestor) {
4796 $ancestor_phid = $ancestor->getPHID();
4797 if (isset($ancestor_map[$ancestor_phid])) {
4798 unset($phids[$phid]);
4799 continue 2;
4804 return $phids;
4808 * When the view policy for an object is changed, scramble the secret keys
4809 * for attached files to invalidate existing URIs.
4811 private function scrambleFileSecrets($object) {
4812 // If this is a newly created object, we don't need to scramble anything
4813 // since it couldn't have been previously published.
4814 if ($this->getIsNewObject()) {
4815 return;
4818 // If the object is a file itself, scramble it.
4819 if ($object instanceof PhabricatorFile) {
4820 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4821 $object->scrambleSecret();
4822 $object->save();
4826 $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
4828 $files = id(new PhabricatorFileQuery())
4829 ->setViewer($omnipotent_viewer)
4830 ->withAttachedObjectPHIDs(array($object->getPHID()))
4831 ->execute();
4832 foreach ($files as $file) {
4833 $view_policy = $file->getViewPolicy();
4834 if ($this->shouldScramblePolicy($view_policy)) {
4835 $file->scrambleSecret();
4836 $file->save();
4843 * Check if a policy is strong enough to justify scrambling. Objects which
4844 * are set to very open policies don't need to scramble their files, and
4845 * files with very open policies don't need to be scrambled when associated
4846 * objects change.
4848 private function shouldScramblePolicy($policy) {
4849 switch ($policy) {
4850 case PhabricatorPolicies::POLICY_PUBLIC:
4851 case PhabricatorPolicies::POLICY_USER:
4852 return false;
4855 return true;
4858 private function updateWorkboardColumns($object, $const, $old, $new) {
4859 // If an object is removed from a project, remove it from any proxy
4860 // columns for that project. This allows a task which is moved up from a
4861 // milestone to the parent to move back into the "Backlog" column on the
4862 // parent workboard.
4864 if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
4865 return;
4868 // TODO: This should likely be some future WorkboardInterface.
4869 $appears_on_workboards = ($object instanceof ManiphestTask);
4870 if (!$appears_on_workboards) {
4871 return;
4874 $removed_phids = array_keys(array_diff_key($old, $new));
4875 if (!$removed_phids) {
4876 return;
4879 // Find any proxy columns for the removed projects.
4880 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4881 ->setViewer(PhabricatorUser::getOmnipotentUser())
4882 ->withProxyPHIDs($removed_phids)
4883 ->execute();
4884 if (!$proxy_columns) {
4885 return array();
4888 $proxy_phids = mpull($proxy_columns, 'getPHID');
4890 $position_table = new PhabricatorProjectColumnPosition();
4891 $conn_w = $position_table->establishConnection('w');
4893 queryfx(
4894 $conn_w,
4895 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4896 $position_table->getTableName(),
4897 $object->getPHID(),
4898 $proxy_phids);
4901 private function getModularTransactionTypes() {
4902 if ($this->modularTypes === null) {
4903 $template = $this->object->getApplicationTransactionTemplate();
4904 if ($template instanceof PhabricatorModularTransaction) {
4905 $xtypes = $template->newModularTransactionTypes();
4906 foreach ($xtypes as $key => $xtype) {
4907 $xtype = clone $xtype;
4908 $xtype->setEditor($this);
4909 $xtypes[$key] = $xtype;
4911 } else {
4912 $xtypes = array();
4915 $this->modularTypes = $xtypes;
4918 return $this->modularTypes;
4921 private function getModularTransactionType($type) {
4922 $types = $this->getModularTransactionTypes();
4923 return idx($types, $type);
4926 public function getCreateObjectTitle($author, $object) {
4927 return pht('%s created this object.', $author);
4930 public function getCreateObjectTitleForFeed($author, $object) {
4931 return pht('%s created an object: %s.', $author, $object);
4934 /* -( Queue )-------------------------------------------------------------- */
4936 protected function queueTransaction(
4937 PhabricatorApplicationTransaction $xaction) {
4938 $this->transactionQueue[] = $xaction;
4939 return $this;
4942 private function flushTransactionQueue($object) {
4943 if (!$this->transactionQueue) {
4944 return;
4947 $xactions = $this->transactionQueue;
4948 $this->transactionQueue = array();
4950 $editor = $this->newEditorCopy();
4952 return $editor->applyTransactions($object, $xactions);
4955 final protected function newSubEditor(
4956 PhabricatorApplicationTransactionEditor $template = null) {
4957 $editor = $this->newEditorCopy($template);
4959 $editor->parentEditor = $this;
4960 $this->subEditors[] = $editor;
4962 return $editor;
4965 private function newEditorCopy(
4966 PhabricatorApplicationTransactionEditor $template = null) {
4967 if ($template === null) {
4968 $template = newv(get_class($this), array());
4971 $editor = id(clone $template)
4972 ->setActor($this->getActor())
4973 ->setContentSource($this->getContentSource())
4974 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
4975 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
4976 ->setParentMessageID($this->getParentMessageID())
4977 ->setIsSilent($this->getIsSilent());
4979 if ($this->actingAsPHID !== null) {
4980 $editor->setActingAsPHID($this->actingAsPHID);
4983 $editor->mustEncrypt = $this->mustEncrypt;
4984 $editor->transactionGroupID = $this->getTransactionGroupID();
4986 return $editor;
4990 /* -( Stamps )------------------------------------------------------------- */
4993 public function newMailStampTemplates($object) {
4994 $actor = $this->getActor();
4996 $templates = array();
4998 $extensions = $this->newMailExtensions($object);
4999 foreach ($extensions as $extension) {
5000 $stamps = $extension->newMailStampTemplates($object);
5001 foreach ($stamps as $stamp) {
5002 $key = $stamp->getKey();
5003 if (isset($templates[$key])) {
5004 throw new Exception(
5005 pht(
5006 'Mail extension ("%s") defines a stamp template with the '.
5007 'same key ("%s") as another template. Each stamp template '.
5008 'must have a unique key.',
5009 get_class($extension),
5010 $key));
5013 $stamp->setViewer($actor);
5015 $templates[$key] = $stamp;
5019 return $templates;
5022 final public function getMailStamp($key) {
5023 if (!isset($this->stampTemplates)) {
5024 throw new PhutilInvalidStateException('newMailStampTemplates');
5027 if (!isset($this->stampTemplates[$key])) {
5028 throw new Exception(
5029 pht(
5030 'Editor ("%s") has no mail stamp template with provided key ("%s").',
5031 get_class($this),
5032 $key));
5035 return $this->stampTemplates[$key];
5038 private function newMailStamps($object, array $xactions) {
5039 $actor = $this->getActor();
5041 $this->stampTemplates = $this->newMailStampTemplates($object);
5043 $extensions = $this->newMailExtensions($object);
5044 $stamps = array();
5045 foreach ($extensions as $extension) {
5046 $extension->newMailStamps($object, $xactions);
5049 return $this->stampTemplates;
5052 private function newMailExtensions($object) {
5053 $actor = $this->getActor();
5055 $all_extensions = PhabricatorMailEngineExtension::getAllExtensions();
5057 $extensions = array();
5058 foreach ($all_extensions as $key => $template) {
5059 $extension = id(clone $template)
5060 ->setViewer($actor)
5061 ->setEditor($this);
5063 if ($extension->supportsObject($object)) {
5064 $extensions[$key] = $extension;
5068 return $extensions;
5071 protected function newAuxiliaryMail($object, array $xactions) {
5072 return array();
5075 private function generateMailStamps($object, $data) {
5076 if (!$data || !is_array($data)) {
5077 return null;
5080 $templates = $this->newMailStampTemplates($object);
5081 foreach ($data as $spec) {
5082 if (!is_array($spec)) {
5083 continue;
5086 $key = idx($spec, 'key');
5087 if (!isset($templates[$key])) {
5088 continue;
5091 $type = idx($spec, 'type');
5092 if ($templates[$key]->getStampType() !== $type) {
5093 continue;
5096 $value = idx($spec, 'value');
5097 $templates[$key]->setValueFromDictionary($value);
5100 $results = array();
5101 foreach ($templates as $template) {
5102 $value = $template->getValueForRendering();
5104 $rendered = $template->renderStamps($value);
5105 if ($rendered === null) {
5106 continue;
5109 $rendered = (array)$rendered;
5110 foreach ($rendered as $stamp) {
5111 $results[] = $stamp;
5115 natcasesort($results);
5117 return $results;
5120 public function getRemovedRecipientPHIDs() {
5121 return $this->mailRemovedPHIDs;
5124 private function buildOldRecipientLists($object, $xactions) {
5125 // See T4776. Before we start making any changes, build a list of the old
5126 // recipients. If a change removes a user from the recipient list for an
5127 // object we still want to notify the user about that change. This allows
5128 // them to respond if they didn't want to be removed.
5130 if (!$this->shouldSendMail($object, $xactions)) {
5131 return;
5134 $this->oldTo = $this->getMailTo($object);
5135 $this->oldCC = $this->getMailCC($object);
5137 return $this;
5140 private function applyOldRecipientLists() {
5141 $actor_phid = $this->getActingAsPHID();
5143 // If you took yourself off the recipient list (for example, by
5144 // unsubscribing or resigning) assume that you know what you did and
5145 // don't need to be notified.
5147 // If you just moved from "To" to "Cc" (or vice versa), you're still a
5148 // recipient so we don't need to add you back in.
5150 $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs);
5152 foreach ($this->oldTo as $phid) {
5153 if ($phid === $actor_phid) {
5154 continue;
5157 if (isset($map[$phid])) {
5158 continue;
5161 $this->mailToPHIDs[] = $phid;
5162 $this->mailRemovedPHIDs[] = $phid;
5165 foreach ($this->oldCC as $phid) {
5166 if ($phid === $actor_phid) {
5167 continue;
5170 if (isset($map[$phid])) {
5171 continue;
5174 $this->mailCCPHIDs[] = $phid;
5175 $this->mailRemovedPHIDs[] = $phid;
5178 return $this;
5181 private function queueWebhooks($object, array $xactions) {
5182 $hook_viewer = PhabricatorUser::getOmnipotentUser();
5184 $webhook_map = $this->webhookMap;
5185 if (!is_array($webhook_map)) {
5186 $webhook_map = array();
5189 // Add any "Firehose" hooks to the list of hooks we're going to call.
5190 $firehose_hooks = id(new HeraldWebhookQuery())
5191 ->setViewer($hook_viewer)
5192 ->withStatuses(
5193 array(
5194 HeraldWebhook::HOOKSTATUS_FIREHOSE,
5196 ->execute();
5197 foreach ($firehose_hooks as $firehose_hook) {
5198 // This is "the hook itself is the reason this hook is being called",
5199 // since we're including it because it's configured as a firehose
5200 // hook.
5201 $hook_phid = $firehose_hook->getPHID();
5202 $webhook_map[$hook_phid][] = $hook_phid;
5205 if (!$webhook_map) {
5206 return;
5209 // NOTE: We're going to queue calls to disabled webhooks, they'll just
5210 // immediately fail in the worker queue. This makes the behavior more
5211 // visible.
5213 $call_hooks = id(new HeraldWebhookQuery())
5214 ->setViewer($hook_viewer)
5215 ->withPHIDs(array_keys($webhook_map))
5216 ->execute();
5218 foreach ($call_hooks as $call_hook) {
5219 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
5221 $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook)
5222 ->setObjectPHID($object->getPHID())
5223 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
5224 ->setTriggerPHIDs($trigger_phids)
5225 ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER)
5226 ->setIsSilentAction((bool)$this->getIsSilent())
5227 ->setIsSecureAction((bool)$this->getMustEncrypt())
5228 ->save();
5230 $request->queueCall();
5234 private function hasWarnings($object, $xaction) {
5235 // TODO: For the moment, this is a very un-modular hack to support
5236 // a small number of warnings related to draft revisions. See PHI433.
5238 if (!($object instanceof DifferentialRevision)) {
5239 return false;
5242 $type = $xaction->getTransactionType();
5244 // TODO: This doesn't warn for inlines in Audit, even though they have
5245 // the same overall workflow.
5246 if ($type === DifferentialTransaction::TYPE_INLINE) {
5247 return (bool)$xaction->getComment()->getAttribute('editing', false);
5250 if (!$object->isDraft()) {
5251 return false;
5254 if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5255 return false;
5258 // We're only going to raise a warning if the transaction adds subscribers
5259 // other than the acting user. (This implementation is clumsy because the
5260 // code runs before a lot of normalization occurs.)
5262 $old = $this->getTransactionOldValue($object, $xaction);
5263 $new = $this->getPHIDTransactionNewValue($xaction, $old);
5264 $old = array_fuse($old);
5265 $new = array_fuse($new);
5266 $add = array_diff_key($new, $old);
5268 unset($add[$this->getActingAsPHID()]);
5270 if (!$add) {
5271 return false;
5274 return true;
5277 private function buildHistoryMail(PhabricatorLiskDAO $object) {
5278 $viewer = $this->requireActor();
5279 $recipient_phid = $this->getActingAsPHID();
5281 // Load every transaction so we can build a mail message with a complete
5282 // history for the object.
5283 $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object);
5284 $xactions = $query
5285 ->setViewer($viewer)
5286 ->withObjectPHIDs(array($object->getPHID()))
5287 ->execute();
5288 $xactions = array_reverse($xactions);
5290 $mail_messages = $this->buildMailWithRecipients(
5291 $object,
5292 $xactions,
5293 array($recipient_phid),
5294 array(),
5295 array());
5296 $mail = head($mail_messages);
5298 // Since the user explicitly requested "!history", force delivery of this
5299 // message regardless of their other mail settings.
5300 $mail->setForceDelivery(true);
5302 return $mail;
5305 public function newAutomaticInlineTransactions(
5306 PhabricatorLiskDAO $object,
5307 $transaction_type,
5308 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5310 $actor = $this->getActor();
5312 $inlines = id(clone $query_template)
5313 ->setViewer($actor)
5314 ->withObjectPHIDs(array($object->getPHID()))
5315 ->withPublishableComments(true)
5316 ->needAppliedDrafts(true)
5317 ->needReplyToComments(true)
5318 ->execute();
5319 $inlines = msort($inlines, 'getID');
5321 $xactions = array();
5323 foreach ($inlines as $key => $inline) {
5324 $xactions[] = $object->getApplicationTransactionTemplate()
5325 ->setTransactionType($transaction_type)
5326 ->attachComment($inline);
5329 $state_xaction = $this->newInlineStateTransaction(
5330 $object,
5331 $query_template);
5333 if ($state_xaction) {
5334 $xactions[] = $state_xaction;
5337 return $xactions;
5340 protected function newInlineStateTransaction(
5341 PhabricatorLiskDAO $object,
5342 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5344 $actor_phid = $this->getActingAsPHID();
5345 $author_phid = $object->getAuthorPHID();
5346 $actor_is_author = ($actor_phid == $author_phid);
5348 $state_map = PhabricatorTransactions::getInlineStateMap();
5350 $inline_query = id(clone $query_template)
5351 ->setViewer($this->getActor())
5352 ->withObjectPHIDs(array($object->getPHID()))
5353 ->withFixedStates(array_keys($state_map))
5354 ->withPublishableComments(true);
5356 if ($actor_is_author) {
5357 $inline_query->withPublishedComments(true);
5360 $inlines = $inline_query->execute();
5362 if (!$inlines) {
5363 return null;
5366 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5367 $new_value = array();
5368 foreach ($old_value as $key => $state) {
5369 $new_value[$key] = $state_map[$state];
5372 // See PHI995. Copy some information about the inlines into the transaction
5373 // so we can tailor rendering behavior. In particular, we don't want to
5374 // render transactions about users marking their own inlines as "Done".
5376 $inline_details = array();
5377 foreach ($inlines as $inline) {
5378 $inline_details[$inline->getPHID()] = array(
5379 'authorPHID' => $inline->getAuthorPHID(),
5383 return $object->getApplicationTransactionTemplate()
5384 ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE)
5385 ->setIgnoreOnNoEffect(true)
5386 ->setMetadataValue('inline.details', $inline_details)
5387 ->setOldValue($old_value)
5388 ->setNewValue($new_value);
5391 private function requireMFA(PhabricatorLiskDAO $object, array $xactions) {
5392 $actor = $this->getActor();
5394 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5395 if ($actor->isOmnipotent()) {
5396 return;
5399 $editor_class = get_class($this);
5401 $object_phid = $object->getPHID();
5402 if ($object_phid) {
5403 $workflow_key = sprintf(
5404 'editor(%s).phid(%s)',
5405 $editor_class,
5406 $object_phid);
5407 } else {
5408 $workflow_key = sprintf(
5409 'editor(%s).new()',
5410 $editor_class);
5413 $request = $this->getRequest();
5414 if ($request === null) {
5415 $source_type = $this->getContentSource()->getSourceTypeConstant();
5416 $conduit_type = PhabricatorConduitContentSource::SOURCECONST;
5417 $is_conduit = ($source_type === $conduit_type);
5418 if ($is_conduit) {
5419 throw new Exception(
5420 pht(
5421 'This transaction group requires MFA to apply, but you can not '.
5422 'provide an MFA response via Conduit. Edit this object via the '.
5423 'web UI.'));
5424 } else {
5425 throw new Exception(
5426 pht(
5427 'This transaction group requires MFA to apply, but the Editor was '.
5428 'not configured with a Request. This workflow can not perform an '.
5429 'MFA check.'));
5433 $cancel_uri = $this->getCancelURI();
5434 if ($cancel_uri === null) {
5435 throw new Exception(
5436 pht(
5437 'This transaction group requires MFA to apply, but the Editor was '.
5438 'not configured with a Cancel URI. This workflow can not perform '.
5439 'an MFA check.'));
5442 $token = id(new PhabricatorAuthSessionEngine())
5443 ->setWorkflowKey($workflow_key)
5444 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5446 if (!$token->getIsUnchallengedToken()) {
5447 foreach ($xactions as $xaction) {
5448 $xaction->setIsMFATransaction(true);
5453 private function newMFATransactions(
5454 PhabricatorLiskDAO $object,
5455 array $xactions) {
5457 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
5458 if ($has_engine) {
5459 $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
5460 ->setViewer($this->getActor());
5461 $require_mfa = $engine->shouldRequireMFA();
5462 $try_mfa = $engine->shouldTryMFA();
5463 } else {
5464 $require_mfa = false;
5465 $try_mfa = false;
5468 // If the user is mentioning an MFA object on another object or creating
5469 // a relationship like "parent" or "child" to this object, we always
5470 // allow the edit to move forward without requiring MFA.
5471 if ($this->getIsInverseEdgeEditor()) {
5472 return $xactions;
5475 if (!$require_mfa) {
5476 // If the object hasn't already opted into MFA, see if any of the
5477 // transactions want it.
5478 if (!$try_mfa) {
5479 foreach ($xactions as $xaction) {
5480 $type = $xaction->getTransactionType();
5482 $xtype = $this->getModularTransactionType($type);
5483 if ($xtype) {
5484 $xtype = clone $xtype;
5485 $xtype->setStorage($xaction);
5486 if ($xtype->shouldTryMFA($object, $xaction)) {
5487 $try_mfa = true;
5488 break;
5494 if ($try_mfa) {
5495 $this->setShouldRequireMFA(true);
5498 return $xactions;
5501 $type_mfa = PhabricatorTransactions::TYPE_MFA;
5503 $has_mfa = false;
5504 foreach ($xactions as $xaction) {
5505 if ($xaction->getTransactionType() === $type_mfa) {
5506 $has_mfa = true;
5507 break;
5511 if ($has_mfa) {
5512 return $xactions;
5515 $template = $object->getApplicationTransactionTemplate();
5517 $mfa_xaction = id(clone $template)
5518 ->setTransactionType($type_mfa)
5519 ->setNewValue(true);
5521 array_unshift($xactions, $mfa_xaction);
5523 return $xactions;
5526 private function getTitleForTextMail(
5527 PhabricatorApplicationTransaction $xaction) {
5528 $type = $xaction->getTransactionType();
5530 $xtype = $this->getModularTransactionType($type);
5531 if ($xtype) {
5532 $xtype = clone $xtype;
5533 $xtype->setStorage($xaction);
5534 $comment = $xtype->getTitleForTextMail();
5535 if ($comment !== false) {
5536 return $comment;
5540 return $xaction->getTitleForTextMail();
5543 private function getTitleForHTMLMail(
5544 PhabricatorApplicationTransaction $xaction) {
5545 $type = $xaction->getTransactionType();
5547 $xtype = $this->getModularTransactionType($type);
5548 if ($xtype) {
5549 $xtype = clone $xtype;
5550 $xtype->setStorage($xaction);
5551 $comment = $xtype->getTitleForHTMLMail();
5552 if ($comment !== false) {
5553 return $comment;
5557 return $xaction->getTitleForHTMLMail();
5561 private function getBodyForTextMail(
5562 PhabricatorApplicationTransaction $xaction) {
5563 $type = $xaction->getTransactionType();
5565 $xtype = $this->getModularTransactionType($type);
5566 if ($xtype) {
5567 $xtype = clone $xtype;
5568 $xtype->setStorage($xaction);
5569 $comment = $xtype->getBodyForTextMail();
5570 if ($comment !== false) {
5571 return $comment;
5575 return $xaction->getBodyForMail();
5578 private function isLockOverrideTransaction(
5579 PhabricatorApplicationTransaction $xaction) {
5581 // See PHI1209. When an object is locked, certain types of transactions
5582 // can still be applied without requiring a policy check, like subscribing
5583 // or unsubscribing. We don't want these transactions to show the "Lock
5584 // Override" icon in the transaction timeline.
5586 // We could test if a transaction did no direct policy checks, but it may
5587 // have done additional policy checks during validation, so this is not a
5588 // reliable test (and could cause false negatives, where edits which did
5589 // override a lock are not marked properly).
5591 // For now, do this in a narrow way and just check against a hard-coded
5592 // list of non-override transaction situations. Some day, this should
5593 // likely be modularized.
5596 // Inverse edge edits don't interact with locks.
5597 if ($this->getIsInverseEdgeEditor()) {
5598 return false;
5601 // For now, all edits other than subscribes always override locks.
5602 $type = $xaction->getTransactionType();
5603 if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5604 return true;
5607 // Subscribes override locks if they affect any users other than the
5608 // acting user.
5610 $acting_phid = $this->getActingAsPHID();
5612 $old = array_fuse($xaction->getOldValue());
5613 $new = array_fuse($xaction->getNewValue());
5614 $add = array_diff_key($new, $old);
5615 $rem = array_diff_key($old, $new);
5617 $all = $add + $rem;
5618 foreach ($all as $phid) {
5619 if ($phid !== $acting_phid) {
5620 return true;
5624 return false;
5628 /* -( Extensions )--------------------------------------------------------- */
5631 private function validateTransactionsWithExtensions(
5632 PhabricatorLiskDAO $object,
5633 array $xactions) {
5634 $errors = array();
5636 $extensions = $this->getEditorExtensions();
5637 foreach ($extensions as $extension) {
5638 $extension_errors = $extension
5639 ->setObject($object)
5640 ->validateTransactions($object, $xactions);
5642 assert_instances_of(
5643 $extension_errors,
5644 'PhabricatorApplicationTransactionValidationError');
5646 $errors[] = $extension_errors;
5649 return array_mergev($errors);
5652 private function getEditorExtensions() {
5653 if ($this->extensions === null) {
5654 $this->extensions = $this->newEditorExtensions();
5656 return $this->extensions;
5659 private function newEditorExtensions() {
5660 $extensions = PhabricatorEditorExtension::getAllExtensions();
5662 $actor = $this->getActor();
5663 $object = $this->object;
5664 foreach ($extensions as $key => $extension) {
5666 $extension = id(clone $extension)
5667 ->setViewer($actor)
5668 ->setEditor($this)
5669 ->setObject($object);
5671 if (!$extension->supportsObject($this, $object)) {
5672 unset($extensions[$key]);
5673 continue;
5676 $extensions[$key] = $extension;
5679 return $extensions;