@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3/**
4 *
5 * Publishing and Managing State
6 * ======
7 *
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.
12 *
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.**
16 *
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.
21 *
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:
25 *
26 * - not require state to exist during publishing; or
27 * - pass state to the daemons by implementing @{method:getCustomWorkerState}
28 * and @{method:loadCustomWorkerState}.
29 *
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.
33 *
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
39 */
40abstract class PhabricatorApplicationTransactionEditor
41 extends PhabricatorEditor {
42
43 private $contentSource;
44 private $object;
45 private $xactions;
46
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;
59
60 private $isPreview;
61 private $isHeraldEditor;
62 private $isInverseEdgeEditor;
63 private $actingAsPHID;
64
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();
85
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;
93
94 private $parentEditor;
95 private $subEditors = array();
96 private $publishableObject;
97 private $publishableTransactions;
98
99 const STORAGE_ENCODING_BINARY = 'binary';
100
101 /**
102 * Get the class name for the application this editor is a part of.
103 *
104 * Disabling the application will disable the editor.
105 *
106 * @return class-string<PhabricatorApplication> Editor's application
107 * class name.
108 */
109 abstract public function getEditorApplicationClass();
110
111
112 /**
113 * Get a description of the objects this editor edits, like "Differential
114 * Revisions".
115 *
116 * @return string Human readable description of edited objects.
117 */
118 abstract public function getEditorObjectsDescription();
119
120
121 public function setActingAsPHID($acting_as_phid) {
122 $this->actingAsPHID = $acting_as_phid;
123 return $this;
124 }
125
126 public function getActingAsPHID() {
127 if ($this->actingAsPHID) {
128 return $this->actingAsPHID;
129 }
130 return $this->getActor()->getPHID();
131 }
132
133
134 /**
135 * When the editor tries to apply transactions that have no effect, should
136 * it raise an exception (default) or drop them and continue?
137 *
138 * Generally, you will set this flag for edits coming from "Edit" interfaces,
139 * and leave it cleared for edits coming from "Comment" interfaces, so the
140 * user will get a useful error if they try to submit a comment that does
141 * nothing (e.g., empty comment with a status change that has already been
142 * performed by another user).
143 *
144 * @param bool $continue True to drop transactions without effect and
145 * continue.
146 * @return $this
147 */
148 public function setContinueOnNoEffect($continue) {
149 $this->continueOnNoEffect = $continue;
150 return $this;
151 }
152
153 public function getContinueOnNoEffect() {
154 return $this->continueOnNoEffect;
155 }
156
157
158 /**
159 * When the editor tries to apply transactions which don't populate all of
160 * an object's required fields, should it raise an exception (default) or
161 * drop them and continue?
162 *
163 * For example, if a user adds a new required custom field (like "Severity")
164 * to a task, all existing tasks won't have it populated. When users
165 * manually edit existing tasks, it's usually desirable to have them provide
166 * a severity. However, other operations (like batch editing just the
167 * owner of a task) will fail by default.
168 *
169 * By setting this flag for edit operations which apply to specific fields
170 * (like the priority, batch, and merge editors in Maniphest), these
171 * operations can continue to function even if an object is outdated.
172 *
173 * @param bool $continue_on_missing_fields True to continue when transactions
174 * don't completely satisfy all required fields.
175 * @return $this
176 */
177 public function setContinueOnMissingFields($continue_on_missing_fields) {
178 $this->continueOnMissingFields = $continue_on_missing_fields;
179 return $this;
180 }
181
182 public function getContinueOnMissingFields() {
183 return $this->continueOnMissingFields;
184 }
185
186
187 /**
188 * Not strictly necessary, but reply handlers ideally set this value to
189 * make email threading work better.
190 */
191 public function setParentMessageID($parent_message_id) {
192 $this->parentMessageID = $parent_message_id;
193 return $this;
194 }
195 public function getParentMessageID() {
196 return $this->parentMessageID;
197 }
198
199 public function getIsNewObject() {
200 return $this->isNewObject;
201 }
202
203 public function getMentionedPHIDs() {
204 return $this->mentionedPHIDs;
205 }
206
207 public function setIsPreview($is_preview) {
208 $this->isPreview = $is_preview;
209 return $this;
210 }
211
212 public function getIsPreview() {
213 return $this->isPreview;
214 }
215
216 public function setIsSilent($silent) {
217 $this->silent = $silent;
218 return $this;
219 }
220
221 public function getIsSilent() {
222 return $this->silent;
223 }
224
225 public function getMustEncrypt() {
226 return $this->mustEncrypt;
227 }
228
229 public function getHeraldRuleMonograms() {
230 // Convert the stored "<123>, <456>" string into a list: "H123", "H456".
231 $list = phutil_string_cast($this->heraldHeader);
232 $list = preg_split('/[, ]+/', $list);
233
234 foreach ($list as $key => $item) {
235 $item = trim($item, '<>');
236
237 if (!is_numeric($item)) {
238 unset($list[$key]);
239 continue;
240 }
241
242 $list[$key] = 'H'.$item;
243 }
244
245 return $list;
246 }
247
248 public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
249 $this->isInverseEdgeEditor = $is_inverse_edge_editor;
250 return $this;
251 }
252
253 public function getIsInverseEdgeEditor() {
254 return $this->isInverseEdgeEditor;
255 }
256
257 public function setIsHeraldEditor($is_herald_editor) {
258 $this->isHeraldEditor = $is_herald_editor;
259 return $this;
260 }
261
262 public function getIsHeraldEditor() {
263 return $this->isHeraldEditor;
264 }
265
266 public function addUnmentionablePHIDs(array $phids) {
267 foreach ($phids as $phid) {
268 $this->unmentionablePHIDMap[$phid] = true;
269 }
270 return $this;
271 }
272
273 private function getUnmentionablePHIDMap() {
274 return $this->unmentionablePHIDMap;
275 }
276
277 protected function shouldEnableMentions(
278 PhabricatorLiskDAO $object,
279 array $xactions) {
280 return true;
281 }
282
283 public function setApplicationEmail(
284 PhabricatorMetaMTAApplicationEmail $email) {
285 $this->applicationEmail = $email;
286 return $this;
287 }
288
289 public function getApplicationEmail() {
290 return $this->applicationEmail;
291 }
292
293 public function setRaiseWarnings($raise_warnings) {
294 $this->raiseWarnings = $raise_warnings;
295 return $this;
296 }
297
298 public function getRaiseWarnings() {
299 return $this->raiseWarnings;
300 }
301
302 public function setShouldRequireMFA($should_require_mfa) {
303 if ($this->hasRequiredMFA) {
304 throw new Exception(
305 pht(
306 'Call to setShouldRequireMFA() is too late: this Editor has already '.
307 'checked for MFA requirements.'));
308 }
309
310 $this->shouldRequireMFA = $should_require_mfa;
311 return $this;
312 }
313
314 public function getShouldRequireMFA() {
315 return $this->shouldRequireMFA;
316 }
317
318 public function getTransactionTypesForObject($object) {
319 $old = $this->object;
320 try {
321 $this->object = $object;
322 $result = $this->getTransactionTypes();
323 $this->object = $old;
324 } catch (Exception $ex) {
325 $this->object = $old;
326 throw $ex;
327 }
328 return $result;
329 }
330
331 public function getTransactionTypes() {
332 $types = array();
333
334 $types[] = PhabricatorTransactions::TYPE_CREATE;
335 $types[] = PhabricatorTransactions::TYPE_HISTORY;
336
337 $types[] = PhabricatorTransactions::TYPE_FILE;
338
339 if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) {
340 $types[] = PhabricatorTransactions::TYPE_SUBTYPE;
341 }
342
343 if ($this->object instanceof PhabricatorSubscribableInterface) {
344 $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS;
345 }
346
347 if ($this->object instanceof PhabricatorCustomFieldInterface) {
348 $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD;
349 }
350
351 if ($this->object instanceof PhabricatorTokenReceiverInterface) {
352 $types[] = PhabricatorTransactions::TYPE_TOKEN;
353 }
354
355 if ($this->object instanceof PhabricatorProjectInterface ||
356 $this->object instanceof PhabricatorMentionableInterface) {
357 $types[] = PhabricatorTransactions::TYPE_EDGE;
358 }
359
360 if ($this->object instanceof PhabricatorSpacesInterface) {
361 $types[] = PhabricatorTransactions::TYPE_SPACE;
362 }
363
364 $types[] = PhabricatorTransactions::TYPE_MFA;
365
366 $template = $this->object->getApplicationTransactionTemplate();
367 if ($template instanceof PhabricatorModularTransaction) {
368 $xtypes = $template->newModularTransactionTypes();
369 foreach ($xtypes as $xtype) {
370 $types[] = $xtype->getTransactionTypeConstant();
371 }
372 }
373
374 if ($template) {
375 $comment = $template->getApplicationTransactionCommentObject();
376 if ($comment) {
377 $types[] = PhabricatorTransactions::TYPE_COMMENT;
378 }
379 }
380
381 return $types;
382 }
383
384 private function adjustTransactionValues(
385 PhabricatorLiskDAO $object,
386 PhabricatorApplicationTransaction $xaction) {
387
388 if ($xaction->shouldGenerateOldValue()) {
389 $old = $this->getTransactionOldValue($object, $xaction);
390 $xaction->setOldValue($old);
391 }
392
393 $new = $this->getTransactionNewValue($object, $xaction);
394 $xaction->setNewValue($new);
395
396 // Apply an optional transformation to convert "external" transaction
397 // values (provided by APIs) into "internal" values.
398
399 $old = $xaction->getOldValue();
400 $new = $xaction->getNewValue();
401
402 $type = $xaction->getTransactionType();
403 $xtype = $this->getModularTransactionType($object, $type);
404 if ($xtype) {
405 $xtype = clone $xtype;
406 $xtype->setStorage($xaction);
407
408
409 // TODO: Provide a modular hook for modern transactions to do a
410 // transformation.
411 list($old, $new) = array($old, $new);
412
413 return;
414 } else {
415 switch ($type) {
416 case PhabricatorTransactions::TYPE_FILE:
417 list($old, $new) = $this->newFileTransactionInternalValues(
418 $object,
419 $xaction,
420 $old,
421 $new);
422 break;
423 }
424 }
425
426 $xaction->setOldValue($old);
427 $xaction->setNewValue($new);
428 }
429
430 private function newFileTransactionInternalValues(
431 PhabricatorLiskDAO $object,
432 PhabricatorApplicationTransaction $xaction,
433 $old,
434 $new) {
435
436 $old_map = array();
437
438 if (!$this->getIsNewObject()) {
439 $phid = $object->getPHID();
440
441 $attachment_table = new PhabricatorFileAttachment();
442 $attachment_conn = $attachment_table->establishConnection('w');
443
444 $rows = queryfx_all(
445 $attachment_conn,
446 'SELECT filePHID, attachmentMode FROM %R WHERE objectPHID = %s',
447 $attachment_table,
448 $phid);
449 $old_map = ipull($rows, 'attachmentMode', 'filePHID');
450 }
451
452 $mode_ref = PhabricatorFileAttachment::MODE_REFERENCE;
453 $mode_detach = PhabricatorFileAttachment::MODE_DETACH;
454
455 $new_map = $old_map;
456
457 foreach ($new as $file_phid => $attachment_mode) {
458 $is_ref = ($attachment_mode === $mode_ref);
459 $is_detach = ($attachment_mode === $mode_detach);
460
461 if ($is_detach) {
462 unset($new_map[$file_phid]);
463 continue;
464 }
465
466 $old_mode = idx($old_map, $file_phid);
467
468 // If we're adding a reference to a file but it is already attached,
469 // don't touch it.
470
471 if ($is_ref) {
472 if ($old_mode !== null) {
473 continue;
474 }
475 }
476
477 $new_map[$file_phid] = $attachment_mode;
478 }
479
480 foreach (array_keys($old_map + $new_map) as $key) {
481 if (isset($old_map[$key]) && isset($new_map[$key])) {
482 if ($old_map[$key] === $new_map[$key]) {
483 unset($old_map[$key]);
484 unset($new_map[$key]);
485 }
486 }
487 }
488
489 return array($old_map, $new_map);
490 }
491
492 private function getTransactionOldValue(
493 PhabricatorLiskDAO $object,
494 PhabricatorApplicationTransaction $xaction) {
495
496 $type = $xaction->getTransactionType();
497
498 $xtype = $this->getModularTransactionType($object, $type);
499 if ($xtype) {
500 $xtype = clone $xtype;
501 $xtype->setStorage($xaction);
502 return $xtype->generateOldValue($object);
503 }
504
505 switch ($type) {
506 case PhabricatorTransactions::TYPE_CREATE:
507 case PhabricatorTransactions::TYPE_HISTORY:
508 case PhabricatorTransactions::TYPE_MFA:
509 case PhabricatorTransactions::TYPE_COMMENT:
510 case PhabricatorTransactions::TYPE_FILE:
511 return null;
512 case PhabricatorTransactions::TYPE_SUBTYPE:
513 return $object->getEditEngineSubtype();
514 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
515 return array_values($this->subscribers);
516 case PhabricatorTransactions::TYPE_VIEW_POLICY:
517 if ($this->getIsNewObject()) {
518 return null;
519 }
520 return $object->getViewPolicy();
521 case PhabricatorTransactions::TYPE_EDIT_POLICY:
522 if ($this->getIsNewObject()) {
523 return null;
524 }
525 return $object->getEditPolicy();
526 case PhabricatorTransactions::TYPE_JOIN_POLICY:
527 if ($this->getIsNewObject()) {
528 return null;
529 }
530 return $object->getJoinPolicy();
531 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
532 if ($this->getIsNewObject()) {
533 return null;
534 }
535 return $object->getInteractPolicy();
536 case PhabricatorTransactions::TYPE_SPACE:
537 if ($this->getIsNewObject()) {
538 return null;
539 }
540
541 $space_phid = $object->getSpacePHID();
542 if ($space_phid === null) {
543 $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace();
544 if ($default_space) {
545 $space_phid = $default_space->getPHID();
546 }
547 }
548
549 return $space_phid;
550 case PhabricatorTransactions::TYPE_EDGE:
551 $edge_type = $xaction->getMetadataValue('edge:type');
552 if (!$edge_type) {
553 throw new Exception(
554 pht(
555 "Edge transaction has no '%s'!",
556 'edge:type'));
557 }
558
559 // See T13082. If this is an inverse edit, the parent editor has
560 // already populated the transaction values correctly.
561 if ($this->getIsInverseEdgeEditor()) {
562 return $xaction->getOldValue();
563 }
564
565 $old_edges = array();
566 if ($object->getPHID()) {
567 $edge_src = $object->getPHID();
568
569 $old_edges = id(new PhabricatorEdgeQuery())
570 ->withSourcePHIDs(array($edge_src))
571 ->withEdgeTypes(array($edge_type))
572 ->needEdgeData(true)
573 ->execute();
574
575 $old_edges = $old_edges[$edge_src][$edge_type];
576 }
577 return $old_edges;
578 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
579 // NOTE: Custom fields have their old value pre-populated when they are
580 // built by PhabricatorCustomFieldList.
581 return $xaction->getOldValue();
582 default:
583 return $this->getCustomTransactionOldValue($object, $xaction);
584 }
585 }
586
587 private function getTransactionNewValue(
588 PhabricatorLiskDAO $object,
589 PhabricatorApplicationTransaction $xaction) {
590
591 $type = $xaction->getTransactionType();
592
593 $xtype = $this->getModularTransactionType($object, $type);
594 if ($xtype) {
595 $xtype = clone $xtype;
596 $xtype->setStorage($xaction);
597 return $xtype->generateNewValue($object, $xaction->getNewValue());
598 }
599
600 switch ($type) {
601 case PhabricatorTransactions::TYPE_CREATE:
602 case PhabricatorTransactions::TYPE_COMMENT:
603 return null;
604 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
605 return $this->getPHIDTransactionNewValue($xaction);
606 case PhabricatorTransactions::TYPE_VIEW_POLICY:
607 case PhabricatorTransactions::TYPE_EDIT_POLICY:
608 case PhabricatorTransactions::TYPE_JOIN_POLICY:
609 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
610 case PhabricatorTransactions::TYPE_TOKEN:
611 case PhabricatorTransactions::TYPE_INLINESTATE:
612 case PhabricatorTransactions::TYPE_SUBTYPE:
613 case PhabricatorTransactions::TYPE_HISTORY:
614 case PhabricatorTransactions::TYPE_FILE:
615 return $xaction->getNewValue();
616 case PhabricatorTransactions::TYPE_MFA:
617 return true;
618 case PhabricatorTransactions::TYPE_SPACE:
619 $space_phid = $xaction->getNewValue();
620 if (!phutil_nonempty_string($space_phid)) {
621 // If an install has no Spaces or the Spaces controls are not visible
622 // to the viewer, we might end up with the empty string here instead
623 // of a strict `null`, because some controller just used `getStr()`
624 // to read the space PHID from the request.
625 // Just make this work like callers might reasonably expect so we
626 // don't need to handle this specially in every EditController.
627 return $this->getActor()->getDefaultSpacePHID();
628 } else {
629 return $space_phid;
630 }
631 case PhabricatorTransactions::TYPE_EDGE:
632 // See T13082. If this is an inverse edit, the parent editor has
633 // already populated appropriate transaction values.
634 if ($this->getIsInverseEdgeEditor()) {
635 return $xaction->getNewValue();
636 }
637
638 $new_value = $this->getEdgeTransactionNewValue($xaction);
639
640 $edge_type = $xaction->getMetadataValue('edge:type');
641 $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST;
642 if ($edge_type == $type_project) {
643 $new_value = $this->applyProjectConflictRules($new_value);
644 }
645
646 return $new_value;
647 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
648 $field = $this->getCustomFieldForTransaction($object, $xaction);
649 return $field->getNewValueFromApplicationTransactions($xaction);
650 default:
651 return $this->getCustomTransactionNewValue($object, $xaction);
652 }
653 }
654
655 protected function getCustomTransactionOldValue(
656 PhabricatorLiskDAO $object,
657 PhabricatorApplicationTransaction $xaction) {
658 throw new Exception(pht('Capability not supported!'));
659 }
660
661 protected function getCustomTransactionNewValue(
662 PhabricatorLiskDAO $object,
663 PhabricatorApplicationTransaction $xaction) {
664 throw new Exception(pht('Capability not supported!'));
665 }
666
667 protected function transactionHasEffect(
668 PhabricatorLiskDAO $object,
669 PhabricatorApplicationTransaction $xaction) {
670
671 switch ($xaction->getTransactionType()) {
672 case PhabricatorTransactions::TYPE_CREATE:
673 case PhabricatorTransactions::TYPE_HISTORY:
674 return true;
675 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
676 $field = $this->getCustomFieldForTransaction($object, $xaction);
677 return $field->getApplicationTransactionHasEffect($xaction);
678 case PhabricatorTransactions::TYPE_EDGE:
679 // A straight value comparison here doesn't always get the right
680 // result, because newly added edges aren't fully populated. Instead,
681 // compare the changes in a more granular way.
682 $old = $xaction->getOldValue();
683 $new = $xaction->getNewValue();
684
685 $old_dst = array_keys($old);
686 $new_dst = array_keys($new);
687
688 // NOTE: For now, we don't consider edge reordering to be a change.
689 // We have very few order-dependent edges and effectively no order
690 // oriented UI. This might change in the future.
691 sort($old_dst);
692 sort($new_dst);
693
694 if ($old_dst !== $new_dst) {
695 // We've added or removed edges, so this transaction definitely
696 // has an effect.
697 return true;
698 }
699
700 // We haven't added or removed edges, but we might have changed
701 // edge data.
702 foreach ($old as $key => $old_value) {
703 $new_value = $new[$key];
704 if ($old_value['data'] !== $new_value['data']) {
705 return true;
706 }
707 }
708
709 return false;
710 }
711
712 $type = $xaction->getTransactionType();
713 $xtype = $this->getModularTransactionType($object, $type);
714 if ($xtype) {
715 return $xtype->getTransactionHasEffect(
716 $object,
717 $xaction->getOldValue(),
718 $xaction->getNewValue());
719 }
720
721 if ($xaction->hasComment()) {
722 return true;
723 }
724
725 return ($xaction->getOldValue() !== $xaction->getNewValue());
726 }
727
728 protected function shouldApplyInitialEffects(
729 PhabricatorLiskDAO $object,
730 array $xactions) {
731 return false;
732 }
733
734 protected function applyInitialEffects(
735 PhabricatorLiskDAO $object,
736 array $xactions) {
737 throw new PhutilMethodNotImplementedException();
738 }
739
740 private function applyInternalEffects(
741 PhabricatorLiskDAO $object,
742 PhabricatorApplicationTransaction $xaction) {
743
744 $type = $xaction->getTransactionType();
745
746 $xtype = $this->getModularTransactionType($object, $type);
747 if ($xtype) {
748 $xtype = clone $xtype;
749 $xtype->setStorage($xaction);
750 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
751 }
752
753 switch ($type) {
754 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
755 $field = $this->getCustomFieldForTransaction($object, $xaction);
756 return $field->applyApplicationTransactionInternalEffects($xaction);
757 case PhabricatorTransactions::TYPE_CREATE:
758 case PhabricatorTransactions::TYPE_HISTORY:
759 case PhabricatorTransactions::TYPE_SUBTYPE:
760 case PhabricatorTransactions::TYPE_MFA:
761 case PhabricatorTransactions::TYPE_TOKEN:
762 case PhabricatorTransactions::TYPE_VIEW_POLICY:
763 case PhabricatorTransactions::TYPE_EDIT_POLICY:
764 case PhabricatorTransactions::TYPE_JOIN_POLICY:
765 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
766 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
767 case PhabricatorTransactions::TYPE_INLINESTATE:
768 case PhabricatorTransactions::TYPE_EDGE:
769 case PhabricatorTransactions::TYPE_SPACE:
770 case PhabricatorTransactions::TYPE_COMMENT:
771 case PhabricatorTransactions::TYPE_FILE:
772 return $this->applyBuiltinInternalTransaction($object, $xaction);
773 }
774
775 return $this->applyCustomInternalTransaction($object, $xaction);
776 }
777
778 private function applyExternalEffects(
779 PhabricatorLiskDAO $object,
780 PhabricatorApplicationTransaction $xaction) {
781
782 $type = $xaction->getTransactionType();
783
784 $xtype = $this->getModularTransactionType($object, $type);
785 if ($xtype) {
786 $xtype = clone $xtype;
787 $xtype->setStorage($xaction);
788 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
789 }
790
791 switch ($type) {
792 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
793 $subeditor = id(new PhabricatorSubscriptionsEditor())
794 ->setObject($object)
795 ->setActor($this->requireActor());
796
797 $old_map = array_fuse($xaction->getOldValue());
798 $new_map = array_fuse($xaction->getNewValue());
799
800 $subeditor->unsubscribe(
801 array_keys(
802 array_diff_key($old_map, $new_map)));
803
804 $subeditor->subscribeExplicit(
805 array_keys(
806 array_diff_key($new_map, $old_map)));
807
808 $subeditor->save();
809
810 // for the rest of these edits, subscribers should include those just
811 // added as well as those just removed.
812 $subscribers = array_unique(array_merge(
813 $this->subscribers,
814 $xaction->getOldValue(),
815 $xaction->getNewValue()));
816 $this->subscribers = $subscribers;
817 return $this->applyBuiltinExternalTransaction($object, $xaction);
818
819 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
820 $field = $this->getCustomFieldForTransaction($object, $xaction);
821 return $field->applyApplicationTransactionExternalEffects($xaction);
822 case PhabricatorTransactions::TYPE_CREATE:
823 case PhabricatorTransactions::TYPE_HISTORY:
824 case PhabricatorTransactions::TYPE_SUBTYPE:
825 case PhabricatorTransactions::TYPE_MFA:
826 case PhabricatorTransactions::TYPE_EDGE:
827 case PhabricatorTransactions::TYPE_TOKEN:
828 case PhabricatorTransactions::TYPE_VIEW_POLICY:
829 case PhabricatorTransactions::TYPE_EDIT_POLICY:
830 case PhabricatorTransactions::TYPE_JOIN_POLICY:
831 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
832 case PhabricatorTransactions::TYPE_INLINESTATE:
833 case PhabricatorTransactions::TYPE_SPACE:
834 case PhabricatorTransactions::TYPE_COMMENT:
835 case PhabricatorTransactions::TYPE_FILE:
836 return $this->applyBuiltinExternalTransaction($object, $xaction);
837 }
838
839 return $this->applyCustomExternalTransaction($object, $xaction);
840 }
841
842 protected function applyCustomInternalTransaction(
843 PhabricatorLiskDAO $object,
844 PhabricatorApplicationTransaction $xaction) {
845 $type = $xaction->getTransactionType();
846 throw new Exception(
847 pht(
848 "Transaction type '%s' is missing an internal apply implementation!",
849 $type));
850 }
851
852 protected function applyCustomExternalTransaction(
853 PhabricatorLiskDAO $object,
854 PhabricatorApplicationTransaction $xaction) {
855 $type = $xaction->getTransactionType();
856 throw new Exception(
857 pht(
858 "Transaction type '%s' is missing an external apply implementation!",
859 $type));
860 }
861
862 /**
863 * @{class:PhabricatorTransactions} provides many built-in transactions
864 * which should not require much - if any - code in specific applications.
865 *
866 * This method is a hook for the exceedingly-rare cases where you may need
867 * to do **additional** work for built-in transactions. Developers should
868 * extend this method, making sure to return the parent implementation
869 * regardless of handling any transactions.
870 *
871 * See also @{method:applyBuiltinExternalTransaction}.
872 */
873 protected function applyBuiltinInternalTransaction(
874 PhabricatorLiskDAO $object,
875 PhabricatorApplicationTransaction $xaction) {
876
877 switch ($xaction->getTransactionType()) {
878 case PhabricatorTransactions::TYPE_VIEW_POLICY:
879 $object->setViewPolicy($xaction->getNewValue());
880 break;
881 case PhabricatorTransactions::TYPE_EDIT_POLICY:
882 $object->setEditPolicy($xaction->getNewValue());
883 break;
884 case PhabricatorTransactions::TYPE_JOIN_POLICY:
885 $object->setJoinPolicy($xaction->getNewValue());
886 break;
887 case PhabricatorTransactions::TYPE_INTERACT_POLICY:
888 $object->setInteractPolicy($xaction->getNewValue());
889 break;
890 case PhabricatorTransactions::TYPE_SPACE:
891 $object->setSpacePHID($xaction->getNewValue());
892 break;
893 case PhabricatorTransactions::TYPE_SUBTYPE:
894 $object->setEditEngineSubtype($xaction->getNewValue());
895 break;
896 }
897 }
898
899 /**
900 * See @{method::applyBuiltinInternalTransaction}.
901 */
902 protected function applyBuiltinExternalTransaction(
903 PhabricatorLiskDAO $object,
904 PhabricatorApplicationTransaction $xaction) {
905
906 switch ($xaction->getTransactionType()) {
907 case PhabricatorTransactions::TYPE_EDGE:
908 if ($this->getIsInverseEdgeEditor()) {
909 // If we're writing an inverse edge transaction, don't actually
910 // do anything. The initiating editor on the other side of the
911 // transaction will take care of the edge writes.
912 break;
913 }
914
915 $old = $xaction->getOldValue();
916 $new = $xaction->getNewValue();
917 $src = $object->getPHID();
918 $const = $xaction->getMetadataValue('edge:type');
919
920 foreach ($new as $dst_phid => $edge) {
921 $new[$dst_phid]['src'] = $src;
922 }
923
924 $editor = new PhabricatorEdgeEditor();
925
926 foreach ($old as $dst_phid => $edge) {
927 if (!empty($new[$dst_phid])) {
928 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
929 continue;
930 }
931 }
932 $editor->removeEdge($src, $const, $dst_phid);
933 }
934
935 foreach ($new as $dst_phid => $edge) {
936 if (!empty($old[$dst_phid])) {
937 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
938 continue;
939 }
940 }
941
942 $data = array(
943 'data' => $edge['data'],
944 );
945
946 $editor->addEdge($src, $const, $dst_phid, $data);
947 }
948
949 $editor->save();
950
951 $this->updateWorkboardColumns($object, $const, $old, $new);
952 break;
953 case PhabricatorTransactions::TYPE_VIEW_POLICY:
954 case PhabricatorTransactions::TYPE_SPACE:
955 $this->scrambleFileSecrets($object);
956 break;
957 case PhabricatorTransactions::TYPE_HISTORY:
958 $this->sendHistory = true;
959 break;
960 case PhabricatorTransactions::TYPE_FILE:
961 $this->applyFileTransaction($object, $xaction);
962 break;
963 }
964 }
965
966 private function applyFileTransaction(
967 PhabricatorLiskDAO $object,
968 PhabricatorApplicationTransaction $xaction) {
969
970 $old_map = $xaction->getOldValue();
971 $new_map = $xaction->getNewValue();
972
973 $add_phids = array();
974 $rem_phids = array();
975
976 foreach ($new_map as $phid => $mode) {
977 $add_phids[$phid] = $mode;
978 }
979
980 foreach ($old_map as $phid => $mode) {
981 if (!isset($new_map[$phid])) {
982 $rem_phids[] = $phid;
983 }
984 }
985
986 $now = PhabricatorTime::getNow();
987 $object_phid = $object->getPHID();
988 $attacher_phid = $this->getActingAsPHID();
989
990 $attachment_table = new PhabricatorFileAttachment();
991 $attachment_conn = $attachment_table->establishConnection('w');
992
993 $add_sql = array();
994 foreach ($add_phids as $add_phid => $add_mode) {
995 $add_sql[] = qsprintf(
996 $attachment_conn,
997 '(%s, %s, %s, %ns, %d, %d)',
998 $object_phid,
999 $add_phid,
1000 $add_mode,
1001 $attacher_phid,
1002 $now,
1003 $now);
1004 }
1005
1006 $rem_sql = array();
1007 foreach ($rem_phids as $rem_phid) {
1008 $rem_sql[] = qsprintf(
1009 $attachment_conn,
1010 '%s',
1011 $rem_phid);
1012 }
1013
1014 foreach (PhabricatorLiskDAO::chunkSQL($add_sql) as $chunk) {
1015 queryfx(
1016 $attachment_conn,
1017 'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
1018 attacherPHID, dateCreated, dateModified)
1019 VALUES %LQ
1020 ON DUPLICATE KEY UPDATE
1021 attachmentMode = VALUES(attachmentMode),
1022 attacherPHID = VALUES(attacherPHID),
1023 dateModified = VALUES(dateModified)',
1024 $attachment_table,
1025 $chunk);
1026 }
1027
1028 foreach (PhabricatorLiskDAO::chunkSQL($rem_sql) as $chunk) {
1029 queryfx(
1030 $attachment_conn,
1031 'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
1032 $attachment_table,
1033 $object_phid,
1034 $chunk);
1035 }
1036 }
1037
1038 /**
1039 * Fill in a transaction's common values, like author and content source.
1040 */
1041 protected function populateTransaction(
1042 PhabricatorLiskDAO $object,
1043 PhabricatorApplicationTransaction $xaction) {
1044
1045 $actor = $this->getActor();
1046
1047 // TODO: This needs to be more sophisticated once we have meta-policies.
1048 $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC);
1049
1050 if ($actor->isOmnipotent()) {
1051 $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE);
1052 } else {
1053 $xaction->setEditPolicy($this->getActingAsPHID());
1054 }
1055
1056 // If the transaction already has an explicit author PHID, allow it to
1057 // stand. This is used by applications like Owners that hook into the
1058 // post-apply change pipeline.
1059 if (!$xaction->getAuthorPHID()) {
1060 $xaction->setAuthorPHID($this->getActingAsPHID());
1061 }
1062
1063 $xaction->setContentSource($this->getContentSource());
1064 $xaction->attachViewer($actor);
1065 $xaction->attachObject($object);
1066
1067 if ($object->getPHID()) {
1068 $xaction->setObjectPHID($object->getPHID());
1069 }
1070
1071 if ($this->getIsSilent()) {
1072 $xaction->setIsSilentTransaction(true);
1073 }
1074
1075 return $xaction;
1076 }
1077
1078 protected function didApplyInternalEffects(
1079 PhabricatorLiskDAO $object,
1080 array $xactions) {
1081 return $xactions;
1082 }
1083
1084 protected function applyFinalEffects(
1085 PhabricatorLiskDAO $object,
1086 array $xactions) {
1087 return $xactions;
1088 }
1089
1090 final protected function didCommitTransactions(
1091 PhabricatorLiskDAO $object,
1092 array $xactions) {
1093
1094 foreach ($xactions as $xaction) {
1095 $type = $xaction->getTransactionType();
1096
1097 // See T13082. When we're writing edges that imply corresponding inverse
1098 // transactions, apply those inverse transactions now. We have to wait
1099 // until the object we're editing (with this editor) has committed its
1100 // transactions to do this. If we don't, the inverse editor may race,
1101 // build a mail before we actually commit this object, and render "alice
1102 // added an edge: Unknown Object".
1103
1104 if ($type === PhabricatorTransactions::TYPE_EDGE) {
1105 // Don't do anything if we're already an inverse edge editor.
1106 if ($this->getIsInverseEdgeEditor()) {
1107 continue;
1108 }
1109
1110 $edge_const = $xaction->getMetadataValue('edge:type');
1111 $edge_type = PhabricatorEdgeType::getByConstant($edge_const);
1112 if ($edge_type->shouldWriteInverseTransactions()) {
1113 $this->applyInverseEdgeTransactions(
1114 $object,
1115 $xaction,
1116 $edge_type->getInverseEdgeConstant());
1117 }
1118 continue;
1119 }
1120
1121 $xtype = $this->getModularTransactionType($object, $type);
1122 if (!$xtype) {
1123 continue;
1124 }
1125
1126 $xtype = clone $xtype;
1127 $xtype->setStorage($xaction);
1128 $xtype->didCommitTransaction($object, $xaction->getNewValue());
1129 }
1130 }
1131
1132 public function setContentSource(PhabricatorContentSource $content_source) {
1133 $this->contentSource = $content_source;
1134 return $this;
1135 }
1136
1137 public function setContentSourceFromRequest(AphrontRequest $request) {
1138 $this->setRequest($request);
1139 return $this->setContentSource(
1140 PhabricatorContentSource::newFromRequest($request));
1141 }
1142
1143 public function getContentSource() {
1144 return $this->contentSource;
1145 }
1146
1147 public function setRequest(AphrontRequest $request) {
1148 $this->request = $request;
1149 return $this;
1150 }
1151
1152 public function getRequest() {
1153 return $this->request;
1154 }
1155
1156 public function setCancelURI($cancel_uri) {
1157 $this->cancelURI = $cancel_uri;
1158 return $this;
1159 }
1160
1161 public function getCancelURI() {
1162 return $this->cancelURI;
1163 }
1164
1165 protected function getTransactionGroupID() {
1166 if ($this->transactionGroupID === null) {
1167 $this->transactionGroupID = Filesystem::readRandomCharacters(32);
1168 }
1169
1170 return $this->transactionGroupID;
1171 }
1172
1173 final public function applyTransactions(
1174 PhabricatorLiskDAO $object,
1175 array $xactions) {
1176
1177 $is_new = ($object->getID() === null);
1178 $this->isNewObject = $is_new;
1179
1180 $is_preview = $this->getIsPreview();
1181 $read_locking = false;
1182 $transaction_open = false;
1183
1184 // If we're attempting to apply transactions, lock and reload the object
1185 // before we go anywhere. If we don't do this at the very beginning, we
1186 // may be looking at an older version of the object when we populate and
1187 // filter the transactions. See PHI1165 for an example.
1188
1189 if (!$is_preview) {
1190 if (!$is_new) {
1191 $this->buildOldRecipientLists($object, $xactions);
1192
1193 $object->openTransaction();
1194 $transaction_open = true;
1195
1196 $object->beginReadLocking();
1197 $read_locking = true;
1198
1199 $object->reload();
1200 }
1201 }
1202
1203 try {
1204 $this->object = $object;
1205 $this->xactions = $xactions;
1206
1207 $this->validateEditParameters($object, $xactions);
1208 $xactions = $this->newMFATransactions($object, $xactions);
1209
1210 $actor = $this->requireActor();
1211
1212 // NOTE: Some transaction expansion requires that the edited object be
1213 // attached.
1214 foreach ($xactions as $xaction) {
1215 $xaction->attachObject($object);
1216 $xaction->attachViewer($actor);
1217 }
1218
1219 $xactions = $this->expandTransactions($object, $xactions);
1220 $xactions = $this->expandSupportTransactions($object, $xactions);
1221 $xactions = $this->combineTransactions($xactions);
1222
1223 foreach ($xactions as $xaction) {
1224 $xaction = $this->populateTransaction($object, $xaction);
1225 }
1226
1227 if (!$is_preview) {
1228 $errors = array();
1229 $type_map = mgroup($xactions, 'getTransactionType');
1230 foreach ($this->getTransactionTypes() as $type) {
1231 $type_xactions = idx($type_map, $type, array());
1232 $errors[] = $this->validateTransaction(
1233 $object,
1234 $type,
1235 $type_xactions);
1236 }
1237
1238 $errors[] = $this->validateAllTransactions($object, $xactions);
1239 $errors[] = $this->validateTransactionsWithExtensions(
1240 $object,
1241 $xactions);
1242 $errors = array_mergev($errors);
1243
1244 $continue_on_missing = $this->getContinueOnMissingFields();
1245 foreach ($errors as $key => $error) {
1246 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1247 unset($errors[$key]);
1248 }
1249 }
1250
1251 if ($errors) {
1252 throw new PhabricatorApplicationTransactionValidationException(
1253 $errors);
1254 }
1255
1256 if ($this->raiseWarnings) {
1257 $warnings = array();
1258 foreach ($xactions as $xaction) {
1259 if ($this->hasWarnings($object, $xaction)) {
1260 $warnings[] = $xaction;
1261 }
1262 }
1263 if ($warnings) {
1264 throw new PhabricatorApplicationTransactionWarningException(
1265 $warnings);
1266 }
1267 }
1268 }
1269
1270 foreach ($xactions as $xaction) {
1271 $this->adjustTransactionValues($object, $xaction);
1272 }
1273
1274 // Now that we've merged and combined transactions, check for required
1275 // capabilities. Note that we're doing this before filtering
1276 // transactions: if you try to apply an edit which you do not have
1277 // permission to apply, we want to give you a permissions error even
1278 // if the edit would have no effect.
1279 $this->applyCapabilityChecks($object, $xactions);
1280
1281 $xactions = $this->filterTransactions($object, $xactions);
1282
1283 if (!$is_preview) {
1284 $this->hasRequiredMFA = true;
1285 if ($this->getShouldRequireMFA()) {
1286 $this->requireMFA($object, $xactions);
1287 }
1288
1289 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1290 if (!$transaction_open) {
1291 $object->openTransaction();
1292 $transaction_open = true;
1293 }
1294 }
1295 }
1296
1297 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1298 $this->applyInitialEffects($object, $xactions);
1299 }
1300
1301 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1302 // figure this out instead.
1303 $mark_as_create = $is_new;
1304 if (!$mark_as_create) {
1305 $create_type = PhabricatorTransactions::TYPE_CREATE;
1306 foreach ($xactions as $xaction) {
1307 if ($xaction->getTransactionType() == $create_type) {
1308 $mark_as_create = true;
1309 break;
1310 }
1311 }
1312 }
1313
1314 if ($mark_as_create) {
1315 foreach ($xactions as $xaction) {
1316 $xaction->setIsCreateTransaction(true);
1317 }
1318 }
1319
1320 $xactions = $this->sortTransactions($xactions);
1321
1322 if ($is_preview) {
1323 $this->loadHandles($xactions);
1324 return $xactions;
1325 }
1326
1327 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1328 ->setActor($actor)
1329 ->setActingAsPHID($this->getActingAsPHID())
1330 ->setContentSource($this->getContentSource())
1331 ->setIsNewComment(true);
1332
1333 if (!$transaction_open) {
1334 $object->openTransaction();
1335 $transaction_open = true;
1336 }
1337
1338 // We can technically test any object for CAN_INTERACT, but we can
1339 // run into some issues in doing so (for example, in project unit tests).
1340 // For now, only test for CAN_INTERACT if the object is explicitly a
1341 // lockable object.
1342
1343 $was_locked = false;
1344 if ($object instanceof PhabricatorEditEngineLockableInterface) {
1345 $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object);
1346 }
1347
1348 foreach ($xactions as $xaction) {
1349 $this->applyInternalEffects($object, $xaction);
1350 }
1351
1352 $xactions = $this->didApplyInternalEffects($object, $xactions);
1353
1354 try {
1355 $object->save();
1356 } catch (AphrontDuplicateKeyQueryException $ex) {
1357 // This callback has an opportunity to throw a better exception,
1358 // so execution may end here.
1359 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1360
1361 throw $ex;
1362 }
1363
1364 $group_id = $this->getTransactionGroupID();
1365
1366 foreach ($xactions as $xaction) {
1367 if ($was_locked) {
1368 $is_override = $this->isLockOverrideTransaction($xaction);
1369 if ($is_override) {
1370 $xaction->setIsLockOverrideTransaction(true);
1371 }
1372 }
1373
1374 $xaction->setObjectPHID($object->getPHID());
1375 $xaction->setTransactionGroupID($group_id);
1376
1377 if ($xaction->getComment()) {
1378 $xaction->setPHID($xaction->generatePHID());
1379 $comment_editor->applyEdit($xaction, $xaction->getComment());
1380 } else {
1381
1382 // TODO: This is a transitional hack to let us migrate edge
1383 // transactions to a more efficient storage format. For now, we're
1384 // going to write a new slim format to the database but keep the old
1385 // bulky format on the objects so we don't have to upgrade all the
1386 // edit logic to the new format yet. See T13051.
1387
1388 $edge_type = PhabricatorTransactions::TYPE_EDGE;
1389 if ($xaction->getTransactionType() == $edge_type) {
1390 $bulky_old = $xaction->getOldValue();
1391 $bulky_new = $xaction->getNewValue();
1392
1393 $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction);
1394 $slim_old = $record->getModernOldEdgeTransactionData();
1395 $slim_new = $record->getModernNewEdgeTransactionData();
1396
1397 $xaction->setOldValue($slim_old);
1398 $xaction->setNewValue($slim_new);
1399 $xaction->save();
1400
1401 $xaction->setOldValue($bulky_old);
1402 $xaction->setNewValue($bulky_new);
1403 } else {
1404 $xaction->save();
1405 }
1406 }
1407 }
1408
1409 foreach ($xactions as $xaction) {
1410 $this->applyExternalEffects($object, $xaction);
1411 }
1412
1413 $xactions = $this->applyFinalEffects($object, $xactions);
1414
1415 if ($read_locking) {
1416 $object->endReadLocking();
1417 $read_locking = false;
1418 }
1419
1420 $object->saveTransaction();
1421 $transaction_open = false;
1422
1423 $this->didCommitTransactions($object, $xactions);
1424
1425 } catch (Exception $ex) {
1426 if ($read_locking) {
1427 $object->endReadLocking();
1428 $read_locking = false;
1429 }
1430
1431 if ($transaction_open) {
1432 $object->killTransaction();
1433 $transaction_open = false;
1434 }
1435
1436 throw $ex;
1437 }
1438
1439 // If we need to perform cache engine updates, execute them now.
1440 id(new PhabricatorCacheEngine())
1441 ->updateObject($object);
1442
1443 // Now that we've completely applied the core transaction set, try to apply
1444 // Herald rules. Herald rules are allowed to either take direct actions on
1445 // the database (like writing flags), or take indirect actions (like saving
1446 // some targets for CC when we generate mail a little later), or return
1447 // transactions which we'll apply normally using another Editor.
1448
1449 // First, check if *this* is a sub-editor which is itself applying Herald
1450 // rules: if it is, stop working and return so we don't descend into
1451 // madness.
1452
1453 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1454 // using a Herald editor to apply resulting transactions) and then send out
1455 // mail, notifications, and feed updates about everything.
1456
1457 if ($this->getIsHeraldEditor()) {
1458 // We are the Herald editor, so stop work here and return the updated
1459 // transactions.
1460 return $xactions;
1461 } else if ($this->getIsInverseEdgeEditor()) {
1462 // Do not run Herald if we're just recording that this object was
1463 // mentioned elsewhere. This tends to create Herald side effects which
1464 // feel arbitrary, and can really slow down edits which mention a large
1465 // number of other objects. See T13114.
1466 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1467 // We are not the Herald editor, so try to apply Herald rules.
1468 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1469
1470 if ($herald_xactions) {
1471 $xscript_id = $this->getHeraldTranscript()->getID();
1472 foreach ($herald_xactions as $herald_xaction) {
1473 // Don't set a transcript ID if this is a transaction from another
1474 // application or source, like Owners.
1475 if ($herald_xaction->getAuthorPHID()) {
1476 continue;
1477 }
1478
1479 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1480 }
1481
1482 // NOTE: We're acting as the omnipotent user because rules deal with
1483 // their own policy issues. We use a synthetic author PHID (the
1484 // Herald application) as the author of record, so that transactions
1485 // will render in a reasonable way ("Herald assigned this task ...").
1486 $herald_actor = PhabricatorUser::getOmnipotentUser();
1487 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1488
1489 // TODO: It would be nice to give transactions a more specific source
1490 // which points at the rule which generated them. You can figure this
1491 // out from transcripts, but it would be cleaner if you didn't have to.
1492
1493 $herald_source = PhabricatorContentSource::newForSource(
1494 PhabricatorHeraldContentSource::SOURCECONST);
1495
1496 $herald_editor = $this->newEditorCopy()
1497 ->setContinueOnNoEffect(true)
1498 ->setContinueOnMissingFields(true)
1499 ->setIsHeraldEditor(true)
1500 ->setActor($herald_actor)
1501 ->setActingAsPHID($herald_phid)
1502 ->setContentSource($herald_source);
1503
1504 $herald_xactions = $herald_editor->applyTransactions(
1505 $object,
1506 $herald_xactions);
1507
1508 // Merge the new transactions into the transaction list: we want to
1509 // send email and publish feed stories about them, too.
1510 $xactions = array_merge($xactions, $herald_xactions);
1511 }
1512
1513 // If Herald did not generate transactions, we may still need to handle
1514 // "Send an Email" rules.
1515 $adapter = $this->getHeraldAdapter();
1516 $this->heraldEmailPHIDs = $adapter->getEmailPHIDs();
1517 $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs();
1518 $this->webhookMap = $adapter->getWebhookMap();
1519 }
1520
1521 $xactions = $this->didApplyTransactions($object, $xactions);
1522
1523 if ($object instanceof PhabricatorCustomFieldInterface) {
1524 // Maybe this makes more sense to move into the search index itself? For
1525 // now I'm putting it here since I think we might end up with things that
1526 // need it to be up to date once the next page loads, but if we don't go
1527 // there we could move it into search once search moves to the daemons.
1528
1529 // It now happens in the search indexer as well, but the search indexer is
1530 // always daemonized, so the logic above still potentially holds. We could
1531 // possibly get rid of this. The major motivation for putting it in the
1532 // indexer was to enable reindexing to work.
1533
1534 $fields = PhabricatorCustomField::getObjectFields(
1535 $object,
1536 PhabricatorCustomField::ROLE_APPLICATIONSEARCH);
1537 $fields->readFieldsFromStorage($object);
1538 $fields->rebuildIndexes($object);
1539 }
1540
1541 $herald_xscript = $this->getHeraldTranscript();
1542 if ($herald_xscript) {
1543 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1544 $herald_header = HeraldTranscript::saveXHeraldRulesHeader(
1545 $object->getPHID(),
1546 $herald_header);
1547 } else {
1548 $herald_header = HeraldTranscript::loadXHeraldRulesHeader(
1549 $object->getPHID());
1550 }
1551 $this->heraldHeader = $herald_header;
1552
1553 // See PHI1134. If we're a subeditor, we don't publish information about
1554 // the edit yet. Our parent editor still needs to finish applying
1555 // transactions and execute Herald, which may change the information we
1556 // publish.
1557
1558 // For example, Herald actions may change the parent object's title or
1559 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1560 // email.
1561
1562 // Once the parent finishes work, it will queue its own publish step and
1563 // then queue publish steps for its children.
1564
1565 $this->publishableObject = $object;
1566 $this->publishableTransactions = $xactions;
1567 if (!$this->parentEditor) {
1568 $this->queuePublishing();
1569 }
1570
1571 return $xactions;
1572 }
1573
1574 private function queuePublishing() {
1575 $object = $this->publishableObject;
1576 $xactions = $this->publishableTransactions;
1577
1578 if (!$object) {
1579 throw new Exception(
1580 pht(
1581 'Editor method "queuePublishing()" was called, but no publishable '.
1582 'object is present. This Editor is not ready to publish.'));
1583 }
1584
1585 // We're going to compute some of the data we'll use to publish these
1586 // transactions here, before queueing a worker.
1587 //
1588 // Primarily, this is more correct: we want to publish the object as it
1589 // exists right now. The worker may not execute for some time, and we want
1590 // to use the current To/CC list, not respect any changes which may occur
1591 // between now and when the worker executes.
1592 //
1593 // As a secondary benefit, this tends to reduce the amount of state that
1594 // Editors need to pass into workers.
1595 $object = $this->willPublish($object, $xactions);
1596
1597 if (!$this->getIsSilent()) {
1598 if ($this->shouldSendMail($object, $xactions)) {
1599 $this->mailShouldSend = true;
1600 $this->mailToPHIDs = $this->getMailTo($object);
1601 $this->mailCCPHIDs = $this->getMailCC($object);
1602 $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object);
1603
1604 // Add any recipients who were previously on the notification list
1605 // but were removed by this change.
1606 $this->applyOldRecipientLists();
1607
1608 if ($object instanceof PhabricatorSubscribableInterface) {
1609 $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs(
1610 $object->getPHID(),
1611 PhabricatorMutedByEdgeType::EDGECONST);
1612 } else {
1613 $this->mailMutedPHIDs = array();
1614 }
1615
1616 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1617 $stamps = $this->newMailStamps($object, $xactions);
1618 foreach ($stamps as $stamp) {
1619 $this->mailStamps[] = $stamp->toDictionary();
1620 }
1621 }
1622
1623 if ($this->shouldPublishFeedStory($object, $xactions)) {
1624 $this->feedShouldPublish = true;
1625 $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs(
1626 $object,
1627 $xactions);
1628 $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs(
1629 $object,
1630 $xactions);
1631 }
1632 }
1633
1634 PhabricatorWorker::scheduleTask(
1635 'PhabricatorApplicationTransactionPublishWorker',
1636 array(
1637 'objectPHID' => $object->getPHID(),
1638 'actorPHID' => $this->getActingAsPHID(),
1639 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1640 'state' => $this->getWorkerState(),
1641 ),
1642 array(
1643 'objectPHID' => $object->getPHID(),
1644 'priority' => PhabricatorWorker::PRIORITY_ALERTS,
1645 ));
1646
1647 foreach ($this->subEditors as $sub_editor) {
1648 $sub_editor->queuePublishing();
1649 }
1650
1651 $this->flushTransactionQueue($object);
1652 }
1653
1654 protected function didCatchDuplicateKeyException(
1655 PhabricatorLiskDAO $object,
1656 array $xactions,
1657 Exception $ex) {
1658 return;
1659 }
1660
1661 public function publishTransactions(
1662 PhabricatorLiskDAO $object,
1663 array $xactions) {
1664
1665 $this->object = $object;
1666 $this->xactions = $xactions;
1667
1668 // Hook for edges or other properties that may need (re-)loading
1669 $object = $this->willPublish($object, $xactions);
1670
1671 // The object might have changed, so reassign it.
1672 $this->object = $object;
1673
1674 $messages = array();
1675 if ($this->mailShouldSend) {
1676 $messages = $this->buildMail($object, $xactions);
1677 }
1678
1679 if ($this->supportsSearch()) {
1680 PhabricatorSearchWorker::queueDocumentForIndexing(
1681 $object->getPHID(),
1682 array(
1683 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1684 ));
1685 }
1686
1687 if ($this->feedShouldPublish) {
1688 $mailed = array();
1689 foreach ($messages as $mail) {
1690 foreach ($mail->buildRecipientList() as $phid) {
1691 $mailed[$phid] = $phid;
1692 }
1693 }
1694
1695 $this->publishFeedStory($object, $xactions, $mailed);
1696 }
1697
1698 if ($this->sendHistory) {
1699 $history_mail = $this->buildHistoryMail($object);
1700 if ($history_mail) {
1701 $messages[] = $history_mail;
1702 }
1703 }
1704
1705 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1706 $messages[] = $message;
1707 }
1708
1709 // NOTE: This actually sends the mail. We do this last to reduce the chance
1710 // that we send some mail, hit an exception, then send the mail again when
1711 // retrying.
1712 foreach ($messages as $mail) {
1713 $mail->save();
1714 }
1715
1716 $this->queueWebhooks($object, $xactions);
1717
1718 return $xactions;
1719 }
1720
1721 protected function didApplyTransactions($object, array $xactions) {
1722 // Hook for subclasses.
1723 return $xactions;
1724 }
1725
1726 private function loadHandles(array $xactions) {
1727 $phids = array();
1728 foreach ($xactions as $key => $xaction) {
1729 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1730 }
1731 $handles = array();
1732 $merged = array_mergev($phids);
1733 if ($merged) {
1734 $handles = id(new PhabricatorHandleQuery())
1735 ->setViewer($this->requireActor())
1736 ->withPHIDs($merged)
1737 ->execute();
1738 }
1739 foreach ($xactions as $key => $xaction) {
1740 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1741 }
1742 }
1743
1744 private function loadSubscribers(PhabricatorLiskDAO $object) {
1745 if ($object->getPHID() &&
1746 ($object instanceof PhabricatorSubscribableInterface)) {
1747 $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID(
1748 $object->getPHID());
1749 $this->subscribers = array_fuse($subs);
1750 } else {
1751 $this->subscribers = array();
1752 }
1753 }
1754
1755 /**
1756 * @param PhabricatorLiskDAO $object
1757 * @param array<PhabricatorApplicationTransaction> $xactions
1758 */
1759 private function validateEditParameters(
1760 PhabricatorLiskDAO $object,
1761 array $xactions) {
1762
1763 if (!$this->getContentSource()) {
1764 throw new PhutilInvalidStateException('setContentSource');
1765 }
1766
1767 // Do a bunch of sanity checks that the incoming transactions are fresh.
1768 // They should be unsaved and have only "transactionType" and "newValue"
1769 // set.
1770
1771 $types = array_fill_keys($this->getTransactionTypes(), true);
1772
1773 assert_instances_of($xactions, PhabricatorApplicationTransaction::class);
1774 foreach ($xactions as $xaction) {
1775 if ($xaction->getPHID() || $xaction->getID()) {
1776 throw new PhabricatorApplicationTransactionStructureException(
1777 $xaction,
1778 pht('You can not apply transactions which already have IDs/PHIDs!'));
1779 }
1780
1781 if ($xaction->getObjectPHID()) {
1782 throw new PhabricatorApplicationTransactionStructureException(
1783 $xaction,
1784 pht(
1785 'You can not apply transactions which already have %s!',
1786 'objectPHIDs'));
1787 }
1788
1789 if ($xaction->getCommentPHID()) {
1790 throw new PhabricatorApplicationTransactionStructureException(
1791 $xaction,
1792 pht(
1793 'You can not apply transactions which already have %s!',
1794 'commentPHIDs'));
1795 }
1796
1797 if ($xaction->getCommentVersion() !== 0) {
1798 throw new PhabricatorApplicationTransactionStructureException(
1799 $xaction,
1800 pht(
1801 'You can not apply transactions which already have '.
1802 'commentVersions!'));
1803 }
1804
1805 $expect_value = !$xaction->shouldGenerateOldValue();
1806 $has_value = $xaction->hasOldValue();
1807
1808 // See T13082. In the narrow case of applying inverse edge edits, we
1809 // expect the old value to be populated.
1810 if ($this->getIsInverseEdgeEditor()) {
1811 $expect_value = true;
1812 }
1813
1814 if ($expect_value && !$has_value) {
1815 throw new PhabricatorApplicationTransactionStructureException(
1816 $xaction,
1817 pht(
1818 'This transaction is supposed to have an %s set, but it does not!',
1819 'oldValue'));
1820 }
1821
1822 if ($has_value && !$expect_value) {
1823 throw new PhabricatorApplicationTransactionStructureException(
1824 $xaction,
1825 pht(
1826 'This transaction should generate its %s automatically, '.
1827 'but has already had one set!',
1828 'oldValue'));
1829 }
1830
1831 $type = $xaction->getTransactionType();
1832 if (empty($types[$type])) {
1833 throw new PhabricatorApplicationTransactionStructureException(
1834 $xaction,
1835 pht(
1836 'Transaction has type "%s", but that transaction type is not '.
1837 'supported by this editor (%s).',
1838 $type,
1839 get_class($this)));
1840 }
1841 }
1842 }
1843
1844 /**
1845 * @param PhabricatorLiskDAO $object
1846 * @param array<PhabricatorApplicationTransaction> $xactions
1847 */
1848 private function applyCapabilityChecks(
1849 PhabricatorLiskDAO $object,
1850 array $xactions) {
1851 assert_instances_of($xactions, PhabricatorApplicationTransaction::class);
1852
1853 $can_edit = PhabricatorPolicyCapability::CAN_EDIT;
1854
1855 if ($this->getIsNewObject()) {
1856 // If we're creating a new object, we don't need any special capabilities
1857 // on the object. The actor has already made it through creation checks,
1858 // and objects which haven't been created yet often can not be
1859 // meaningfully tested for capabilities anyway.
1860 $required_capabilities = array();
1861 } else {
1862 if (!$xactions && !$this->xactions) {
1863 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1864 $required_capabilities = array($can_edit);
1865 } else {
1866 $required_capabilities = array();
1867
1868 foreach ($xactions as $xaction) {
1869 $type = $xaction->getTransactionType();
1870
1871 $xtype = $this->getModularTransactionType($object, $type);
1872 if (!$xtype) {
1873 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1874 } else {
1875 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1876 }
1877
1878 // For convenience, we allow flexibility in the return types because
1879 // it's very unusual that a transaction actually requires multiple
1880 // capability checks.
1881 if ($capabilities === null) {
1882 $capabilities = array();
1883 } else {
1884 $capabilities = (array)$capabilities;
1885 }
1886
1887 foreach ($capabilities as $capability) {
1888 $required_capabilities[$capability] = $capability;
1889 }
1890 }
1891 }
1892 }
1893
1894 $required_capabilities = array_fuse($required_capabilities);
1895 $actor = $this->getActor();
1896
1897 if ($required_capabilities) {
1898 id(new PhabricatorPolicyFilter())
1899 ->setViewer($actor)
1900 ->requireCapabilities($required_capabilities)
1901 ->raisePolicyExceptions(true)
1902 ->apply(array($object));
1903 }
1904 }
1905
1906 private function getLegacyRequiredCapabilities(
1907 PhabricatorApplicationTransaction $xaction) {
1908
1909 $type = $xaction->getTransactionType();
1910 switch ($type) {
1911 case PhabricatorTransactions::TYPE_COMMENT:
1912 // TODO: Comments technically require CAN_INTERACT, but this is
1913 // currently somewhat special and handled through EditEngine. For now,
1914 // don't enforce it here.
1915 return null;
1916 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
1917 // Anyone can subscribe to or unsubscribe from anything they can view,
1918 // with no other permissions.
1919
1920 $old = array_fuse($xaction->getOldValue());
1921 $new = array_fuse($xaction->getNewValue());
1922
1923 // To remove users other than yourself, you must be able to edit the
1924 // object.
1925 $rem = array_diff_key($old, $new);
1926 foreach ($rem as $phid) {
1927 if ($phid !== $this->getActingAsPHID()) {
1928 return PhabricatorPolicyCapability::CAN_EDIT;
1929 }
1930 }
1931
1932 // To add users other than yourself, you must be able to interact.
1933 // This allows "@mentioning" users to work as long as you can comment
1934 // on objects.
1935
1936 // If you can edit, we return that policy instead so that you can
1937 // override a soft lock and still make edits.
1938
1939 // TODO: This is a little bit hacky. We really want to be able to say
1940 // "this requires either interact or edit", but there's currently no
1941 // way to specify this kind of requirement.
1942
1943 $can_edit = PhabricatorPolicyFilter::hasCapability(
1944 $this->getActor(),
1945 $this->object,
1946 PhabricatorPolicyCapability::CAN_EDIT);
1947
1948 $add = array_diff_key($new, $old);
1949 foreach ($add as $phid) {
1950 if ($phid !== $this->getActingAsPHID()) {
1951 if ($can_edit) {
1952 return PhabricatorPolicyCapability::CAN_EDIT;
1953 } else {
1954 return PhabricatorPolicyCapability::CAN_INTERACT;
1955 }
1956 }
1957 }
1958
1959 return null;
1960 case PhabricatorTransactions::TYPE_TOKEN:
1961 // TODO: This technically requires CAN_INTERACT, like comments.
1962 return null;
1963 case PhabricatorTransactions::TYPE_HISTORY:
1964 // This is a special magic transaction which sends you history via
1965 // email and is only partially supported in the upstream. You don't
1966 // need any capabilities to apply it.
1967 return null;
1968 case PhabricatorTransactions::TYPE_MFA:
1969 // Signing a transaction group with MFA does not require permissions
1970 // on its own.
1971 return null;
1972 case PhabricatorTransactions::TYPE_FILE:
1973 return null;
1974 case PhabricatorTransactions::TYPE_EDGE:
1975 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1976 default:
1977 // For other older (non-modular) transactions, always require exactly
1978 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1979 // capabilities must move to ModularTransactions.
1980 return PhabricatorPolicyCapability::CAN_EDIT;
1981 }
1982 }
1983
1984 private function getLegacyRequiredEdgeCapabilities(
1985 PhabricatorApplicationTransaction $xaction) {
1986
1987 // You don't need to have edit permission on an object to mention it or
1988 // otherwise add a relationship pointing toward it.
1989 if ($this->getIsInverseEdgeEditor()) {
1990 return null;
1991 }
1992
1993 $edge_type = $xaction->getMetadataValue('edge:type');
1994 switch ($edge_type) {
1995 case PhabricatorMutedByEdgeType::EDGECONST:
1996 // At time of writing, you can only write this edge for yourself, so
1997 // you don't need permissions. If you can eventually mute an object
1998 // for other users, this would need to be revisited.
1999 return null;
2000 case PhabricatorProjectSilencedEdgeType::EDGECONST:
2001 // At time of writing, you can only write this edge for yourself, so
2002 // you don't need permissions. If you can eventually silence project
2003 // for other users, this would need to be revisited.
2004 return null;
2005 case PhabricatorObjectMentionsObjectEdgeType::EDGECONST:
2006 return null;
2007 case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST:
2008 $old = $xaction->getOldValue();
2009 $new = $xaction->getNewValue();
2010
2011 $add = array_keys(array_diff_key($new, $old));
2012 $rem = array_keys(array_diff_key($old, $new));
2013
2014 $actor_phid = $this->requireActor()->getPHID();
2015
2016 $is_join = (($add === array($actor_phid)) && !$rem);
2017 $is_leave = (($rem === array($actor_phid)) && !$add);
2018
2019 if ($is_join) {
2020 // You need CAN_JOIN to join a project.
2021 return PhabricatorPolicyCapability::CAN_JOIN;
2022 }
2023
2024 if ($is_leave) {
2025 $object = $this->object;
2026 // You usually don't need any capabilities to leave a project...
2027 if ($object->getIsMembershipLocked()) {
2028 // ...you must be able to edit to leave locked projects, though.
2029 return PhabricatorPolicyCapability::CAN_EDIT;
2030 } else {
2031 return null;
2032 }
2033 }
2034
2035 // You need CAN_EDIT to change members other than yourself.
2036 return PhabricatorPolicyCapability::CAN_EDIT;
2037 case PhabricatorObjectHasWatcherEdgeType::EDGECONST:
2038 // See PHI1024. Watching a project does not require CAN_EDIT.
2039 return null;
2040 default:
2041 return PhabricatorPolicyCapability::CAN_EDIT;
2042 }
2043 }
2044
2045
2046 private function buildSubscribeTransaction(
2047 PhabricatorLiskDAO $object,
2048 array $xactions,
2049 array $changes) {
2050
2051 if (!($object instanceof PhabricatorSubscribableInterface)) {
2052 return null;
2053 }
2054
2055 if ($this->shouldEnableMentions($object, $xactions)) {
2056 // Identify newly mentioned users. We ignore users who were previously
2057 // mentioned so that we don't re-subscribe users after an edit of text
2058 // which mentions them.
2059 $old_texts = mpull($changes, 'getOldValue');
2060 $new_texts = mpull($changes, 'getNewValue');
2061
2062 $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
2063 $this->getActor(),
2064 $old_texts);
2065
2066 $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions(
2067 $this->getActor(),
2068 $new_texts);
2069
2070 $phids = array_diff($new_phids, $old_phids);
2071 } else {
2072 $phids = array();
2073 }
2074
2075 $this->mentionedPHIDs = $phids;
2076
2077 if ($object->getPHID()) {
2078 // Don't try to subscribe already-subscribed mentions: we want to generate
2079 // a dialog about an action having no effect if the user explicitly adds
2080 // existing CCs, but not if they merely mention existing subscribers.
2081 $phids = array_diff($phids, $this->subscribers);
2082 }
2083
2084 if ($phids) {
2085 $users = id(new PhabricatorPeopleQuery())
2086 ->setViewer($this->getActor())
2087 ->withPHIDs($phids)
2088 ->execute();
2089 $users = mpull($users, null, 'getPHID');
2090
2091 foreach ($phids as $key => $phid) {
2092 $user = idx($users, $phid);
2093
2094 // Don't subscribe invalid users.
2095 if (!$user) {
2096 unset($phids[$key]);
2097 continue;
2098 }
2099
2100 // Don't subscribe bots that get mentioned. If users truly intend
2101 // to subscribe them, they can add them explicitly, but it's generally
2102 // not useful to subscribe bots to objects.
2103 if ($user->getIsSystemAgent()) {
2104 unset($phids[$key]);
2105 continue;
2106 }
2107
2108 // Do not subscribe mentioned users who do not have permission to see
2109 // the object.
2110 if ($object instanceof PhabricatorPolicyInterface) {
2111 $can_view = PhabricatorPolicyFilter::hasCapability(
2112 $user,
2113 $object,
2114 PhabricatorPolicyCapability::CAN_VIEW);
2115 if (!$can_view) {
2116 unset($phids[$key]);
2117 continue;
2118 }
2119 }
2120
2121 // Don't subscribe users who are already automatically subscribed.
2122 if ($object->isAutomaticallySubscribed($phid)) {
2123 unset($phids[$key]);
2124 continue;
2125 }
2126 }
2127
2128 $phids = array_values($phids);
2129 }
2130
2131 if (!$phids) {
2132 return null;
2133 }
2134
2135 $xaction = $object->getApplicationTransactionTemplate()
2136 ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS)
2137 ->setNewValue(array('+' => $phids));
2138
2139 return $xaction;
2140 }
2141
2142 protected function mergeTransactions(
2143 PhabricatorApplicationTransaction $u,
2144 PhabricatorApplicationTransaction $v) {
2145
2146 $object = $this->object;
2147 $type = $u->getTransactionType();
2148
2149 $xtype = $this->getModularTransactionType($object, $type);
2150 if ($xtype) {
2151 return $xtype->mergeTransactions($object, $u, $v);
2152 }
2153
2154 switch ($type) {
2155 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
2156 return $this->mergePHIDOrEdgeTransactions($u, $v);
2157 case PhabricatorTransactions::TYPE_EDGE:
2158 $u_type = $u->getMetadataValue('edge:type');
2159 $v_type = $v->getMetadataValue('edge:type');
2160 if ($u_type == $v_type) {
2161 return $this->mergePHIDOrEdgeTransactions($u, $v);
2162 }
2163 return null;
2164 }
2165
2166 // By default, do not merge the transactions.
2167 return null;
2168 }
2169
2170 /**
2171 * Optionally expand transactions which imply other effects. For example,
2172 * resigning from a revision in Differential implies removing yourself as
2173 * a reviewer.
2174 */
2175 protected function expandTransactions(
2176 PhabricatorLiskDAO $object,
2177 array $xactions) {
2178
2179 $results = array();
2180 foreach ($xactions as $xaction) {
2181 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2182 $results[] = $expanded;
2183 }
2184 }
2185
2186 return $results;
2187 }
2188
2189 protected function expandTransaction(
2190 PhabricatorLiskDAO $object,
2191 PhabricatorApplicationTransaction $xaction) {
2192 return array($xaction);
2193 }
2194
2195
2196 public function getExpandedSupportTransactions(
2197 PhabricatorLiskDAO $object,
2198 PhabricatorApplicationTransaction $xaction) {
2199
2200 $xactions = array($xaction);
2201 $xactions = $this->expandSupportTransactions(
2202 $object,
2203 $xactions);
2204
2205 if (count($xactions) == 1) {
2206 return array();
2207 }
2208
2209 foreach ($xactions as $index => $cxaction) {
2210 if ($cxaction === $xaction) {
2211 unset($xactions[$index]);
2212 break;
2213 }
2214 }
2215
2216 return $xactions;
2217 }
2218
2219 private function expandSupportTransactions(
2220 PhabricatorLiskDAO $object,
2221 array $xactions) {
2222 $this->loadSubscribers($object);
2223
2224 $xactions = $this->applyImplicitCC($object, $xactions);
2225
2226 $changes = $this->getRemarkupChanges($xactions);
2227
2228 $subscribe_xaction = $this->buildSubscribeTransaction(
2229 $object,
2230 $xactions,
2231 $changes);
2232 if ($subscribe_xaction) {
2233 $xactions[] = $subscribe_xaction;
2234 }
2235
2236 // TODO: For now, this is just a placeholder.
2237 $engine = PhabricatorMarkupEngine::getEngine('extract');
2238 $engine->setConfig('viewer', $this->requireActor());
2239
2240 $block_xactions = $this->expandRemarkupBlockTransactions(
2241 $object,
2242 $xactions,
2243 $changes,
2244 $engine);
2245
2246 foreach ($block_xactions as $xaction) {
2247 $xactions[] = $xaction;
2248 }
2249
2250 $file_xaction = $this->newFileTransaction(
2251 $object,
2252 $xactions,
2253 $changes);
2254 if ($file_xaction) {
2255 $xactions[] = $file_xaction;
2256 }
2257
2258 return $xactions;
2259 }
2260
2261 /**
2262 * @param PhabricatorLiskDAO $object
2263 * @param array<PhabricatorApplicationTransaction> $xactions
2264 * @param array<PhabricatorTransactionRemarkupChange> $remarkup_changes
2265 * @return PhabricatorApplicationTransaction|null
2266 */
2267 private function newFileTransaction(
2268 PhabricatorLiskDAO $object,
2269 array $xactions,
2270 array $remarkup_changes) {
2271
2272 assert_instances_of(
2273 $remarkup_changes,
2274 PhabricatorTransactionRemarkupChange::class);
2275
2276 $new_map = array();
2277
2278 $viewer = $this->getActor();
2279
2280 $old_blocks = mpull($remarkup_changes, 'getOldValue');
2281 foreach ($old_blocks as $key => $old_block) {
2282 $old_blocks[$key] = phutil_string_cast($old_block);
2283 }
2284
2285 $new_blocks = mpull($remarkup_changes, 'getNewValue');
2286 foreach ($new_blocks as $key => $new_block) {
2287 $new_blocks[$key] = phutil_string_cast($new_block);
2288 }
2289
2290 $old_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
2291 $viewer,
2292 $old_blocks);
2293 $old_refs = array_fuse($old_refs);
2294
2295 $new_refs = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles(
2296 $viewer,
2297 $new_blocks);
2298 $new_refs = array_fuse($new_refs);
2299
2300 $add_refs = array_diff_key($new_refs, $old_refs);
2301 foreach ($add_refs as $file_phid) {
2302 $new_map[$file_phid] = PhabricatorFileAttachment::MODE_REFERENCE;
2303 }
2304
2305 foreach ($remarkup_changes as $remarkup_change) {
2306 $metadata = $remarkup_change->getMetadata();
2307
2308 $attached_phids = idx($metadata, 'attachedFilePHIDs', array());
2309 foreach ($attached_phids as $file_phid) {
2310
2311 // If the blocks don't include a new embedded reference to this file,
2312 // do not actually attach it. A common way for this to happen is for
2313 // a user to upload a file, then change their mind and remove the
2314 // reference. We do not want to attach the file if they decided against
2315 // referencing it.
2316
2317 if (!isset($new_map[$file_phid])) {
2318 continue;
2319 }
2320
2321 $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
2322 }
2323 }
2324
2325 $file_phids = $this->extractFilePHIDs($object, $xactions);
2326 foreach ($file_phids as $file_phid) {
2327 $new_map[$file_phid] = PhabricatorFileAttachment::MODE_ATTACH;
2328 }
2329
2330 if (!$new_map) {
2331 return null;
2332 }
2333
2334 $xaction = $object->getApplicationTransactionTemplate()
2335 ->setIgnoreOnNoEffect(true)
2336 ->setTransactionType(PhabricatorTransactions::TYPE_FILE)
2337 ->setMetadataValue('attach.implicit', true)
2338 ->setNewValue($new_map);
2339
2340 return $xaction;
2341 }
2342
2343
2344 private function getRemarkupChanges(array $xactions) {
2345 $changes = array();
2346
2347 foreach ($xactions as $key => $xaction) {
2348 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2349 $changes[] = $change;
2350 }
2351 }
2352
2353 return $changes;
2354 }
2355
2356 private function getRemarkupChangesFromTransaction(
2357 PhabricatorApplicationTransaction $transaction) {
2358 return $transaction->getRemarkupChanges();
2359 }
2360
2361 private function expandRemarkupBlockTransactions(
2362 PhabricatorLiskDAO $object,
2363 array $xactions,
2364 array $changes,
2365 PhutilMarkupEngine $engine) {
2366
2367 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2368 $object,
2369 $xactions,
2370 $changes,
2371 $engine);
2372
2373 $mentioned_phids = array();
2374 if ($this->shouldEnableMentions($object, $xactions)) {
2375 foreach ($changes as $change) {
2376 // Here, we don't care about processing only new mentions after an edit
2377 // because there is no way for an object to ever "unmention" itself on
2378 // another object, so we can ignore the old value.
2379 $engine->markupText($change->getNewValue());
2380
2381 $mentioned_phids += $engine->getTextMetadata(
2382 PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS,
2383 array());
2384 }
2385 }
2386
2387 if (!$mentioned_phids) {
2388 return $block_xactions;
2389 }
2390
2391 $mentioned_objects = id(new PhabricatorObjectQuery())
2392 ->setViewer($this->getActor())
2393 ->withPHIDs($mentioned_phids)
2394 ->execute();
2395
2396 $unmentionable_map = $this->getUnmentionablePHIDMap();
2397
2398 $mentionable_phids = array();
2399 if ($this->shouldEnableMentions($object, $xactions)) {
2400 foreach ($mentioned_objects as $mentioned_object) {
2401 if ($mentioned_object instanceof PhabricatorMentionableInterface) {
2402 $mentioned_phid = $mentioned_object->getPHID();
2403 if (isset($unmentionable_map[$mentioned_phid])) {
2404 continue;
2405 }
2406 // don't let objects mention themselves
2407 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2408 continue;
2409 }
2410 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2411 }
2412 }
2413 }
2414
2415 if ($mentionable_phids) {
2416 $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST;
2417 $block_xactions[] = newv(get_class(head($xactions)), array())
2418 ->setIgnoreOnNoEffect(true)
2419 ->setTransactionType(PhabricatorTransactions::TYPE_EDGE)
2420 ->setMetadataValue('edge:type', $edge_type)
2421 ->setNewValue(array('+' => $mentionable_phids));
2422 }
2423
2424 return $block_xactions;
2425 }
2426
2427 protected function expandCustomRemarkupBlockTransactions(
2428 PhabricatorLiskDAO $object,
2429 array $xactions,
2430 array $changes,
2431 PhutilMarkupEngine $engine) {
2432 return array();
2433 }
2434
2435
2436 /**
2437 * Attempt to combine similar transactions into a smaller number of total
2438 * transactions. For example, two transactions which edit the title of an
2439 * object can be merged into a single edit.
2440 */
2441 private function combineTransactions(array $xactions) {
2442 $stray_comments = array();
2443
2444 $result = array();
2445 $types = array();
2446 foreach ($xactions as $key => $xaction) {
2447 $type = $xaction->getTransactionType();
2448 if (isset($types[$type])) {
2449 foreach ($types[$type] as $other_key) {
2450 $other_xaction = $result[$other_key];
2451
2452 // Don't merge transactions with different authors. For example,
2453 // don't merge Herald transactions and owners transactions.
2454 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2455 continue;
2456 }
2457
2458 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2459 if ($merged) {
2460 $result[$other_key] = $merged;
2461
2462 if ($xaction->getComment() &&
2463 ($xaction->getComment() !== $merged->getComment())) {
2464 $stray_comments[] = $xaction->getComment();
2465 }
2466
2467 if ($result[$other_key]->getComment() &&
2468 ($result[$other_key]->getComment() !== $merged->getComment())) {
2469 $stray_comments[] = $result[$other_key]->getComment();
2470 }
2471
2472 // Move on to the next transaction.
2473 continue 2;
2474 }
2475 }
2476 }
2477 $result[$key] = $xaction;
2478 $types[$type][] = $key;
2479 }
2480
2481 // If we merged any comments away, restore them.
2482 foreach ($stray_comments as $comment) {
2483 $xaction = newv(get_class(head($result)), array());
2484 $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT);
2485 $xaction->setComment($comment);
2486 $result[] = $xaction;
2487 }
2488
2489 return array_values($result);
2490 }
2491
2492 public function mergePHIDOrEdgeTransactions(
2493 PhabricatorApplicationTransaction $u,
2494 PhabricatorApplicationTransaction $v) {
2495
2496 $result = $u->getNewValue();
2497 foreach ($v->getNewValue() as $key => $value) {
2498 if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) {
2499 if (empty($result[$key])) {
2500 $result[$key] = $value;
2501 } else {
2502 // We're merging two lists of edge adds, sets, or removes. Merge
2503 // them by merging individual PHIDs within them.
2504 $merged = $result[$key];
2505
2506 foreach ($value as $dst => $v_spec) {
2507 if (empty($merged[$dst])) {
2508 $merged[$dst] = $v_spec;
2509 } else {
2510 // Two transactions are trying to perform the same operation on
2511 // the same edge. Normalize the edge data and then merge it. This
2512 // allows transactions to specify how data merges execute in a
2513 // precise way.
2514
2515 $u_spec = $merged[$dst];
2516
2517 if (!is_array($u_spec)) {
2518 $u_spec = array('dst' => $u_spec);
2519 }
2520 if (!is_array($v_spec)) {
2521 $v_spec = array('dst' => $v_spec);
2522 }
2523
2524 $ux_data = idx($u_spec, 'data', array());
2525 $vx_data = idx($v_spec, 'data', array());
2526
2527 $merged_data = $this->mergeEdgeData(
2528 $u->getMetadataValue('edge:type'),
2529 $ux_data,
2530 $vx_data);
2531
2532 $u_spec['data'] = $merged_data;
2533 $merged[$dst] = $u_spec;
2534 }
2535 }
2536
2537 $result[$key] = $merged;
2538 }
2539 } else {
2540 $result[$key] = array_merge($value, idx($result, $key, array()));
2541 }
2542 }
2543 $u->setNewValue($result);
2544
2545 // When combining an "ignore" transaction with a normal transaction, make
2546 // sure we don't propagate the "ignore" flag.
2547 if (!$v->getIgnoreOnNoEffect()) {
2548 $u->setIgnoreOnNoEffect(false);
2549 }
2550
2551 return $u;
2552 }
2553
2554 protected function mergeEdgeData($type, array $u, array $v) {
2555 return $v + $u;
2556 }
2557
2558 protected function getPHIDTransactionNewValue(
2559 PhabricatorApplicationTransaction $xaction,
2560 $old = null) {
2561
2562 if ($old !== null) {
2563 $old = array_fuse($old);
2564 } else {
2565 $old = array_fuse($xaction->getOldValue());
2566 }
2567
2568 return $this->getPHIDList($old, $xaction->getNewValue());
2569 }
2570
2571 public function getPHIDList(array $old, array $new) {
2572 $new_add = idx($new, '+', array());
2573 unset($new['+']);
2574 $new_rem = idx($new, '-', array());
2575 unset($new['-']);
2576 $new_set = idx($new, '=', null);
2577 if ($new_set !== null) {
2578 $new_set = array_fuse($new_set);
2579 }
2580 unset($new['=']);
2581
2582 if ($new) {
2583 throw new Exception(
2584 pht(
2585 "Invalid '%s' value for PHID transaction. Value should contain only ".
2586 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2587 'new',
2588 '+',
2589 '-',
2590 '='));
2591 }
2592
2593 $result = array();
2594
2595 foreach ($old as $phid) {
2596 if ($new_set !== null && empty($new_set[$phid])) {
2597 continue;
2598 }
2599 $result[$phid] = $phid;
2600 }
2601
2602 if ($new_set !== null) {
2603 foreach ($new_set as $phid) {
2604 $result[$phid] = $phid;
2605 }
2606 }
2607
2608 foreach ($new_add as $phid) {
2609 $result[$phid] = $phid;
2610 }
2611
2612 foreach ($new_rem as $phid) {
2613 unset($result[$phid]);
2614 }
2615
2616 return array_values($result);
2617 }
2618
2619 protected function getEdgeTransactionNewValue(
2620 PhabricatorApplicationTransaction $xaction) {
2621
2622 $new = $xaction->getNewValue();
2623 $new_add = idx($new, '+', array());
2624 unset($new['+']);
2625 $new_rem = idx($new, '-', array());
2626 unset($new['-']);
2627 $new_set = idx($new, '=', null);
2628 unset($new['=']);
2629
2630 if ($new) {
2631 throw new Exception(
2632 pht(
2633 "Invalid '%s' value for Edge transaction. Value should contain only ".
2634 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2635 'new',
2636 '+',
2637 '-',
2638 '='));
2639 }
2640
2641 $old = $xaction->getOldValue();
2642
2643 $lists = array($new_set, $new_add, $new_rem);
2644 foreach ($lists as $list) {
2645 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2646 }
2647
2648 $result = array();
2649 foreach ($old as $dst_phid => $edge) {
2650 if ($new_set !== null && empty($new_set[$dst_phid])) {
2651 continue;
2652 }
2653 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2654 $xaction,
2655 $edge,
2656 $dst_phid);
2657 }
2658
2659 if ($new_set !== null) {
2660 foreach ($new_set as $dst_phid => $edge) {
2661 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2662 $xaction,
2663 $edge,
2664 $dst_phid);
2665 }
2666 }
2667
2668 foreach ($new_add as $dst_phid => $edge) {
2669 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2670 $xaction,
2671 $edge,
2672 $dst_phid);
2673 }
2674
2675 foreach ($new_rem as $dst_phid => $edge) {
2676 unset($result[$dst_phid]);
2677 }
2678
2679 return $result;
2680 }
2681
2682 private function checkEdgeList($list, $edge_type) {
2683 if (!$list) {
2684 return;
2685 }
2686 foreach ($list as $key => $item) {
2687 if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) {
2688 throw new Exception(
2689 pht(
2690 'Edge transactions must have destination PHIDs as in edge '.
2691 'lists (found key "%s" on transaction of type "%s").',
2692 $key,
2693 $edge_type));
2694 }
2695 if (!is_array($item) && $item !== $key) {
2696 throw new Exception(
2697 pht(
2698 'Edge transactions must have PHIDs or edge specs as values '.
2699 '(found value "%s" on transaction of type "%s").',
2700 $item,
2701 $edge_type));
2702 }
2703 }
2704 }
2705
2706 private function normalizeEdgeTransactionValue(
2707 PhabricatorApplicationTransaction $xaction,
2708 $edge,
2709 $dst_phid) {
2710
2711 if (!is_array($edge)) {
2712 if ($edge != $dst_phid) {
2713 throw new Exception(
2714 pht(
2715 'Transaction edge data must either be the edge PHID or an edge '.
2716 'specification dictionary.'));
2717 }
2718 $edge = array();
2719 } else {
2720 foreach ($edge as $key => $value) {
2721 switch ($key) {
2722 case 'src':
2723 case 'dst':
2724 case 'type':
2725 case 'data':
2726 case 'dateCreated':
2727 case 'dateModified':
2728 case 'seq':
2729 case 'dataID':
2730 break;
2731 default:
2732 throw new Exception(
2733 pht(
2734 'Transaction edge specification contains unexpected key "%s".',
2735 $key));
2736 }
2737 }
2738 }
2739
2740 $edge['dst'] = $dst_phid;
2741
2742 $edge_type = $xaction->getMetadataValue('edge:type');
2743 if (empty($edge['type'])) {
2744 $edge['type'] = $edge_type;
2745 } else {
2746 if ($edge['type'] != $edge_type) {
2747 $this_type = $edge['type'];
2748 throw new Exception(
2749 pht(
2750 "Edge transaction includes edge of type '%s', but ".
2751 "transaction is of type '%s'. Each edge transaction ".
2752 "must alter edges of only one type.",
2753 $this_type,
2754 $edge_type));
2755 }
2756 }
2757
2758 if (!isset($edge['data'])) {
2759 $edge['data'] = array();
2760 }
2761
2762 return $edge;
2763 }
2764
2765 protected function sortTransactions(array $xactions) {
2766 $head = array();
2767 $tail = array();
2768
2769 // Move bare comments to the end, so the actions precede them.
2770 foreach ($xactions as $xaction) {
2771 $type = $xaction->getTransactionType();
2772 if ($type == PhabricatorTransactions::TYPE_COMMENT) {
2773 $tail[] = $xaction;
2774 } else {
2775 $head[] = $xaction;
2776 }
2777 }
2778
2779 return array_values(array_merge($head, $tail));
2780 }
2781
2782
2783 protected function filterTransactions(
2784 PhabricatorLiskDAO $object,
2785 array $xactions) {
2786
2787 $type_comment = PhabricatorTransactions::TYPE_COMMENT;
2788 $type_mfa = PhabricatorTransactions::TYPE_MFA;
2789
2790 $no_effect = array();
2791 $has_comment = false;
2792 $any_effect = false;
2793
2794 $meta_xactions = array();
2795 foreach ($xactions as $key => $xaction) {
2796 if ($xaction->getTransactionType() === $type_mfa) {
2797 $meta_xactions[$key] = $xaction;
2798 continue;
2799 }
2800
2801 if ($this->transactionHasEffect($object, $xaction)) {
2802 if ($xaction->getTransactionType() != $type_comment) {
2803 $any_effect = true;
2804 }
2805 } else if ($xaction->getIgnoreOnNoEffect()) {
2806 unset($xactions[$key]);
2807 } else {
2808 $no_effect[$key] = $xaction;
2809 }
2810
2811 if ($xaction->hasComment()) {
2812 $has_comment = true;
2813 }
2814 }
2815
2816 // If every transaction is a meta-transaction applying to the transaction
2817 // group, these transactions are junk.
2818 if (count($meta_xactions) == count($xactions)) {
2819 $no_effect = $xactions;
2820 $any_effect = false;
2821 }
2822
2823 if (!$no_effect) {
2824 return $xactions;
2825 }
2826
2827 // If none of the transactions have an effect, the meta-transactions also
2828 // have no effect. Add them to the "no effect" list so we get a full set
2829 // of errors for everything.
2830 if (!$any_effect && !$has_comment) {
2831 $no_effect += $meta_xactions;
2832 }
2833
2834 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2835 throw new PhabricatorApplicationTransactionNoEffectException(
2836 $no_effect,
2837 $any_effect,
2838 $has_comment);
2839 }
2840
2841 if (!$any_effect && !$has_comment) {
2842 // If we only have empty comment transactions, just drop them all.
2843 return array();
2844 }
2845
2846 foreach ($no_effect as $key => $xaction) {
2847 if ($xaction->hasComment()) {
2848 $xaction->setTransactionType($type_comment);
2849 $xaction->setOldValue(null);
2850 $xaction->setNewValue(null);
2851 } else {
2852 unset($xactions[$key]);
2853 }
2854 }
2855
2856 return $xactions;
2857 }
2858
2859
2860 /**
2861 * Hook for validating transactions. This callback will be invoked for each
2862 * available transaction type, even if an edit does not apply any transactions
2863 * of that type. This allows you to raise exceptions when required fields are
2864 * missing, by detecting that the object has no field value and there is no
2865 * transaction which sets one.
2866 *
2867 * @param PhabricatorLiskDAO $object Object being edited.
2868 * @param string $type Transaction type to validate.
2869 * @param list<PhabricatorApplicationTransaction> $xactions Transactions of
2870 * given type, which may be empty if the edit does not apply any
2871 * transactions of the given type.
2872 * @return list<PhabricatorApplicationTransactionValidationError> List of
2873 * validation errors.
2874 */
2875 protected function validateTransaction(
2876 PhabricatorLiskDAO $object,
2877 $type,
2878 array $xactions) {
2879
2880 $errors = array();
2881
2882 $xtype = $this->getModularTransactionType($object, $type);
2883 if ($xtype) {
2884 $errors[] = $xtype->validateTransactions($object, $xactions);
2885 }
2886
2887 switch ($type) {
2888 case PhabricatorTransactions::TYPE_VIEW_POLICY:
2889 $errors[] = $this->validatePolicyTransaction(
2890 $object,
2891 $xactions,
2892 $type,
2893 PhabricatorPolicyCapability::CAN_VIEW);
2894 break;
2895 case PhabricatorTransactions::TYPE_EDIT_POLICY:
2896 $errors[] = $this->validatePolicyTransaction(
2897 $object,
2898 $xactions,
2899 $type,
2900 PhabricatorPolicyCapability::CAN_EDIT);
2901 break;
2902 case PhabricatorTransactions::TYPE_SPACE:
2903 $errors[] = $this->validateSpaceTransactions(
2904 $object,
2905 $xactions,
2906 $type);
2907 break;
2908 case PhabricatorTransactions::TYPE_SUBTYPE:
2909 $errors[] = $this->validateSubtypeTransactions(
2910 $object,
2911 $xactions,
2912 $type);
2913 break;
2914 case PhabricatorTransactions::TYPE_MFA:
2915 $errors[] = $this->validateMFATransactions(
2916 $object,
2917 $xactions,
2918 $type);
2919 break;
2920 case PhabricatorTransactions::TYPE_CUSTOMFIELD:
2921 $groups = array();
2922 foreach ($xactions as $xaction) {
2923 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2924 }
2925
2926 $field_list = PhabricatorCustomField::getObjectFields(
2927 $object,
2928 PhabricatorCustomField::ROLE_EDIT);
2929 $field_list->setViewer($this->getActor());
2930
2931 $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS;
2932 foreach ($field_list->getFields() as $field) {
2933 if (!$field->shouldEnableForRole($role_xactions)) {
2934 continue;
2935 }
2936 $errors[] = $field->validateApplicationTransactions(
2937 $this,
2938 $type,
2939 idx($groups, $field->getFieldKey(), array()));
2940 }
2941 break;
2942 case PhabricatorTransactions::TYPE_FILE:
2943 $errors[] = $this->validateFileTransactions(
2944 $object,
2945 $xactions,
2946 $type);
2947 break;
2948 }
2949
2950 return array_mergev($errors);
2951 }
2952
2953 private function validateFileTransactions(
2954 PhabricatorLiskDAO $object,
2955 array $xactions,
2956 $transaction_type) {
2957
2958 $errors = array();
2959
2960 $mode_map = PhabricatorFileAttachment::getModeList();
2961 $mode_map = array_fuse($mode_map);
2962
2963 $file_phids = array();
2964 foreach ($xactions as $xaction) {
2965 $new = $xaction->getNewValue();
2966
2967 if (!is_array($new)) {
2968 $errors[] = new PhabricatorApplicationTransactionValidationError(
2969 $transaction_type,
2970 pht('Invalid'),
2971 pht(
2972 'File attachment transaction must have a map of files to '.
2973 'attachment modes, found "%s".',
2974 phutil_describe_type($new)),
2975 $xaction);
2976 continue;
2977 }
2978
2979 foreach ($new as $file_phid => $attachment_mode) {
2980 $file_phids[$file_phid] = $file_phid;
2981
2982 if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
2983 continue;
2984 }
2985
2986 if (!is_string($attachment_mode)) {
2987 $errors[] = new PhabricatorApplicationTransactionValidationError(
2988 $transaction_type,
2989 pht('Invalid'),
2990 pht(
2991 'File attachment mode (for file "%s") is invalid. Expected '.
2992 'a string, found "%s".',
2993 $file_phid,
2994 phutil_describe_type($attachment_mode)),
2995 $xaction);
2996 } else {
2997 $errors[] = new PhabricatorApplicationTransactionValidationError(
2998 $transaction_type,
2999 pht('Invalid'),
3000 pht(
3001 'File attachment mode "%s" (for file "%s") is invalid. Valid '.
3002 'modes are: %s.',
3003 $attachment_mode,
3004 $file_phid,
3005 pht_list($mode_map)),
3006 $xaction);
3007 }
3008 }
3009 }
3010
3011 if ($file_phids) {
3012 $file_map = id(new PhabricatorFileQuery())
3013 ->setViewer($this->getActor())
3014 ->withPHIDs($file_phids)
3015 ->execute();
3016 $file_map = mpull($file_map, null, 'getPHID');
3017 } else {
3018 $file_map = array();
3019 }
3020
3021 foreach ($xactions as $xaction) {
3022 $new = $xaction->getNewValue();
3023
3024 if (!is_array($new)) {
3025 continue;
3026 }
3027
3028 foreach ($new as $file_phid => $attachment_mode) {
3029 if (isset($file_map[$file_phid])) {
3030 continue;
3031 }
3032
3033 $errors[] = new PhabricatorApplicationTransactionValidationError(
3034 $transaction_type,
3035 pht('Invalid'),
3036 pht(
3037 'File "%s" is invalid: it could not be loaded, or you do not '.
3038 'have permission to view it. You must be able to see a file to '.
3039 'attach it to an object.',
3040 $file_phid),
3041 $xaction);
3042 }
3043 }
3044
3045 return $errors;
3046 }
3047
3048
3049 public function validatePolicyTransaction(
3050 PhabricatorLiskDAO $object,
3051 array $xactions,
3052 $transaction_type,
3053 $capability) {
3054
3055 $actor = $this->requireActor();
3056 $errors = array();
3057 // Note $this->xactions is necessary; $xactions is $this->xactions of
3058 // $transaction_type
3059 $policy_object = $this->adjustObjectForPolicyChecks(
3060 $object,
3061 $this->xactions);
3062
3063 // Make sure the user isn't editing away their ability to $capability this
3064 // object.
3065 foreach ($xactions as $xaction) {
3066 try {
3067 PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy(
3068 $actor,
3069 $policy_object,
3070 $capability,
3071 $xaction->getNewValue());
3072 } catch (PhabricatorPolicyException $ex) {
3073 $errors[] = new PhabricatorApplicationTransactionValidationError(
3074 $transaction_type,
3075 pht('Invalid'),
3076 pht(
3077 'The %s policy of this object would no longer allow '.
3078 'you to %s the object.',
3079 $capability,
3080 $capability),
3081 $xaction);
3082 }
3083 }
3084
3085 if ($this->getIsNewObject()) {
3086 if (!$xactions) {
3087 $has_capability = PhabricatorPolicyFilter::hasCapability(
3088 $actor,
3089 $policy_object,
3090 $capability);
3091 if (!$has_capability) {
3092 $errors[] = new PhabricatorApplicationTransactionValidationError(
3093 $transaction_type,
3094 pht('Invalid'),
3095 pht(
3096 'The selected %s policy excludes you. Choose a %s policy '.
3097 'which allows you to %s the object.',
3098 $capability,
3099 $capability,
3100 $capability));
3101 }
3102 }
3103 }
3104
3105 return $errors;
3106 }
3107
3108
3109 private function validateSpaceTransactions(
3110 PhabricatorLiskDAO $object,
3111 array $xactions,
3112 $transaction_type) {
3113 $errors = array();
3114
3115 $actor = $this->getActor();
3116
3117 $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor);
3118 $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor);
3119 $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces(
3120 $actor);
3121 foreach ($xactions as $xaction) {
3122 $space_phid = $xaction->getNewValue();
3123
3124 if ($space_phid === null) {
3125 if (!$has_spaces) {
3126 // The install doesn't have any spaces, so this is fine.
3127 continue;
3128 }
3129
3130 // The install has some spaces, so every object needs to be put
3131 // in a valid space.
3132 $errors[] = new PhabricatorApplicationTransactionValidationError(
3133 $transaction_type,
3134 pht('Invalid'),
3135 pht('You must choose a space for this object.'),
3136 $xaction);
3137 continue;
3138 }
3139
3140 // If the PHID isn't `null`, it needs to be a valid space that the
3141 // viewer can see.
3142 if (empty($actor_spaces[$space_phid])) {
3143 $errors[] = new PhabricatorApplicationTransactionValidationError(
3144 $transaction_type,
3145 pht('Invalid'),
3146 pht(
3147 'You can not shift this object in the selected space, because '.
3148 'the space does not exist or you do not have access to it.'),
3149 $xaction);
3150 } else if (empty($active_spaces[$space_phid])) {
3151
3152 // It's OK to edit objects in an archived space, so just move on if
3153 // we aren't adjusting the value.
3154 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
3155 if ($space_phid == $old_space_phid) {
3156 continue;
3157 }
3158
3159 $errors[] = new PhabricatorApplicationTransactionValidationError(
3160 $transaction_type,
3161 pht('Archived'),
3162 pht(
3163 'You can not shift this object into the selected space, because '.
3164 'the space is archived. Objects can not be created inside (or '.
3165 'moved into) archived spaces.'),
3166 $xaction);
3167 }
3168 }
3169
3170 return $errors;
3171 }
3172
3173 private function validateSubtypeTransactions(
3174 PhabricatorLiskDAO $object,
3175 array $xactions,
3176 $transaction_type) {
3177 $errors = array();
3178
3179 $map = $object->newEditEngineSubtypeMap();
3180 $old = $object->getEditEngineSubtype();
3181 foreach ($xactions as $xaction) {
3182 $new = $xaction->getNewValue();
3183
3184 if ($old == $new) {
3185 continue;
3186 }
3187
3188 if (!$map->isValidSubtype($new)) {
3189 $errors[] = new PhabricatorApplicationTransactionValidationError(
3190 $transaction_type,
3191 pht('Invalid'),
3192 pht(
3193 'The subtype "%s" is not a valid subtype.',
3194 $new),
3195 $xaction);
3196 continue;
3197 }
3198 }
3199
3200 return $errors;
3201 }
3202
3203 private function validateMFATransactions(
3204 PhabricatorLiskDAO $object,
3205 array $xactions,
3206 $transaction_type) {
3207 $errors = array();
3208
3209 $factors = id(new PhabricatorAuthFactorConfigQuery())
3210 ->setViewer($this->getActor())
3211 ->withUserPHIDs(array($this->getActingAsPHID()))
3212 ->withFactorProviderStatuses(
3213 array(
3214 PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE,
3215 PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED,
3216 ))
3217 ->execute();
3218
3219 foreach ($xactions as $xaction) {
3220 if (!$factors) {
3221 $mfa_panel = id(new PhabricatorMultiFactorSettingsPanel())
3222 ->setUser($this->getActor());
3223 $mfa_uri = $mfa_panel->getPanelURI();
3224 $mfa_panel_name = pht('Settings');
3225 $mfa_link = new PhutilSafeHTML(
3226 '<a href="'.$mfa_uri.'">'.$mfa_panel_name.'</a>');
3227 $errors[] = new PhabricatorApplicationTransactionValidationError(
3228 $transaction_type,
3229 pht('No MFA'),
3230 pht(
3231 'You do not have any MFA factors attached to your account, so '.
3232 'you can not sign this transaction group with MFA. Add MFA to '.
3233 'your account in %s.',
3234 $mfa_link),
3235 $xaction);
3236 }
3237 }
3238
3239 if ($xactions) {
3240 $this->setShouldRequireMFA(true);
3241 }
3242
3243 return $errors;
3244 }
3245
3246 protected function adjustObjectForPolicyChecks(
3247 PhabricatorLiskDAO $object,
3248 array $xactions) {
3249
3250 $copy = clone $object;
3251
3252 foreach ($xactions as $xaction) {
3253 switch ($xaction->getTransactionType()) {
3254 case PhabricatorTransactions::TYPE_SUBSCRIBERS:
3255 $clone_xaction = clone $xaction;
3256 $clone_xaction->setOldValue(array_values($this->subscribers));
3257 $clone_xaction->setNewValue(
3258 $this->getPHIDTransactionNewValue(
3259 $clone_xaction));
3260
3261 PhabricatorPolicyRule::passTransactionHintToRule(
3262 $copy,
3263 new PhabricatorSubscriptionsSubscribersPolicyRule(),
3264 array_fuse($clone_xaction->getNewValue()));
3265
3266 break;
3267 case PhabricatorTransactions::TYPE_SPACE:
3268 $space_phid = $this->getTransactionNewValue($object, $xaction);
3269 $copy->setSpacePHID($space_phid);
3270 break;
3271 }
3272 }
3273
3274 return $copy;
3275 }
3276
3277 protected function validateAllTransactions(
3278 PhabricatorLiskDAO $object,
3279 array $xactions) {
3280 return array();
3281 }
3282
3283 /**
3284 * Check for a missing text field.
3285 *
3286 * A text field is missing if the object has no value and there are no
3287 * transactions which set a value, or if the transactions remove the value.
3288 * This method is intended to make implementing @{method:validateTransaction}
3289 * more convenient:
3290 *
3291 * $missing = $this->validateIsEmptyTextField(
3292 * $object->getName(),
3293 * $xactions);
3294 *
3295 * This will return `true` if the net effect of the object and transactions
3296 * is an empty field.
3297 *
3298 * @param mixed $field_value Current field value.
3299 * @param list<PhabricatorApplicationTransaction> $xactions Transactions
3300 * editing the field.
3301 * @return bool True if the field will be an empty text field after edits.
3302 */
3303 protected function validateIsEmptyTextField($field_value, array $xactions) {
3304 if (($field_value !== null && strlen($field_value)) && empty($xactions)) {
3305 return false;
3306 }
3307
3308 if ($xactions && strlen(last($xactions)->getNewValue())) {
3309 return false;
3310 }
3311
3312 return true;
3313 }
3314
3315
3316/* -( Implicit CCs )------------------------------------------------------- */
3317
3318
3319 /**
3320 * Adds the actor as a subscriber to the object with which they interact
3321 * @param PhabricatorLiskDAO $object on which the action is performed
3322 * @param array $xactions Transactions to apply
3323 * @return array Transactions to apply
3324 */
3325 final public function applyImplicitCC(
3326 PhabricatorLiskDAO $object,
3327 array $xactions) {
3328
3329 if (!($object instanceof PhabricatorSubscribableInterface)) {
3330 // If the object isn't subscribable, we can't CC them.
3331 return $xactions;
3332 }
3333
3334 $actor_phid = $this->getActingAsPHID();
3335
3336 $type_user = PhabricatorPeopleUserPHIDType::TYPECONST;
3337 if (phid_get_type($actor_phid) != $type_user) {
3338 // Transactions by application actors like Herald, Harbormaster and
3339 // Diffusion should not CC the applications.
3340 return $xactions;
3341 }
3342
3343 if ($object->isAutomaticallySubscribed($actor_phid)) {
3344 // If they're auto-subscribed, don't CC them.
3345 return $xactions;
3346 }
3347
3348 $should_cc = false;
3349 foreach ($xactions as $xaction) {
3350 if ($this->shouldImplyCC($object, $xaction)) {
3351 $should_cc = true;
3352 break;
3353 }
3354 }
3355
3356 if (!$should_cc) {
3357 // Only some types of actions imply a CC (like adding a comment).
3358 return $xactions;
3359 }
3360
3361 if ($object->getPHID()) {
3362 if (isset($this->subscribers[$actor_phid])) {
3363 // If the user is already subscribed, don't implicitly CC them.
3364 return $xactions;
3365 }
3366
3367 $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs(
3368 $object->getPHID(),
3369 PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST);
3370 $unsub = array_fuse($unsub);
3371 if (isset($unsub[$actor_phid])) {
3372 // If the user has previously unsubscribed from this object explicitly,
3373 // don't implicitly CC them.
3374 return $xactions;
3375 }
3376 }
3377
3378 $actor = $this->getActor();
3379
3380 $user = id(new PhabricatorPeopleQuery())
3381 ->setViewer($actor)
3382 ->withPHIDs(array($actor_phid))
3383 ->executeOne();
3384 if (!$user) {
3385 return $xactions;
3386 }
3387
3388 // When a bot acts (usually via the API), don't automatically subscribe
3389 // them as a side effect. They can always subscribe explicitly if they
3390 // want, and bot subscriptions normally just clutter things up since bots
3391 // usually do not read email.
3392 if ($user->getIsSystemAgent()) {
3393 return $xactions;
3394 }
3395
3396 $xaction = newv(get_class(head($xactions)), array());
3397 $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS);
3398 $xaction->setNewValue(array('+' => array($actor_phid)));
3399
3400 array_unshift($xactions, $xaction);
3401
3402 return $xactions;
3403 }
3404
3405 /**
3406 * Whether the action implies the actor should be subscribed on the object
3407 * @param PhabricatorLiskDAO $object on which the action is performed
3408 * @param PhabricatorApplicationTransaction $xaction Transaction to apply
3409 * @return bool True if the actor should be subscribed on the object
3410 */
3411 protected function shouldImplyCC(
3412 PhabricatorLiskDAO $object,
3413 PhabricatorApplicationTransaction $xaction) {
3414
3415 return ($xaction->isCommentTransaction() &&
3416 !($xaction->getComment()->getIsRemoved()));
3417 }
3418
3419
3420/* -( Sending Mail )------------------------------------------------------- */
3421
3422
3423 /**
3424 * @task mail
3425 */
3426 protected function shouldSendMail(
3427 PhabricatorLiskDAO $object,
3428 array $xactions) {
3429 return false;
3430 }
3431
3432
3433 /**
3434 * @task mail
3435 */
3436 private function buildMail(
3437 PhabricatorLiskDAO $object,
3438 array $xactions) {
3439
3440 $email_to = $this->mailToPHIDs;
3441 $email_cc = $this->mailCCPHIDs;
3442 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs);
3443
3444 $unexpandable = $this->mailUnexpandablePHIDs;
3445 if (!is_array($unexpandable)) {
3446 $unexpandable = array();
3447 }
3448
3449 $messages = $this->buildMailWithRecipients(
3450 $object,
3451 $xactions,
3452 $email_to,
3453 $email_cc,
3454 $unexpandable);
3455
3456 $this->runHeraldMailRules($messages);
3457
3458 return $messages;
3459 }
3460
3461 private function buildMailWithRecipients(
3462 PhabricatorLiskDAO $object,
3463 array $xactions,
3464 array $email_to,
3465 array $email_cc,
3466 array $unexpandable) {
3467
3468 $targets = $this->buildReplyHandler($object)
3469 ->setUnexpandablePHIDs($unexpandable)
3470 ->getMailTargets($email_to, $email_cc);
3471
3472 // Set this explicitly before we start swapping out the effective actor.
3473 $this->setActingAsPHID($this->getActingAsPHID());
3474
3475 $xaction_phids = mpull($xactions, 'getPHID');
3476
3477 $messages = array();
3478 foreach ($targets as $target) {
3479 $original_actor = $this->getActor();
3480
3481 $viewer = $target->getViewer();
3482 $this->setActor($viewer);
3483 $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation());
3484
3485 $caught = null;
3486 $mail = null;
3487 try {
3488 // Reload the transactions for the current viewer.
3489 if ($xaction_phids) {
3490 $query = PhabricatorApplicationTransactionQuery::newQueryForObject(
3491 $object);
3492
3493 $mail_xactions = $query
3494 ->setViewer($viewer)
3495 ->withObjectPHIDs(array($object->getPHID()))
3496 ->withPHIDs($xaction_phids)
3497 ->execute();
3498
3499 // Sort the mail transactions in the input order.
3500 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3501 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3502 $mail_xactions = array_values($mail_xactions);
3503 } else {
3504 $mail_xactions = array();
3505 }
3506
3507 // Reload handles for the current viewer. This covers older code which
3508 // emits a list of handle PHIDs upfront.
3509 $this->loadHandles($mail_xactions);
3510
3511 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3512
3513 if ($mail) {
3514 if ($this->mustEncrypt) {
3515 $mail
3516 ->setMustEncrypt(true)
3517 ->setMustEncryptReasons($this->mustEncrypt);
3518 }
3519 }
3520 } catch (Exception $ex) {
3521 $caught = $ex;
3522 }
3523
3524 $this->setActor($original_actor);
3525 unset($locale);
3526
3527 if ($caught) {
3528 throw $ex;
3529 }
3530
3531 if ($mail) {
3532 $messages[] = $mail;
3533 }
3534 }
3535
3536 return $messages;
3537 }
3538
3539 protected function getTransactionsForMail(
3540 PhabricatorLiskDAO $object,
3541 array $xactions) {
3542 return $xactions;
3543 }
3544
3545 private function buildMailForTarget(
3546 PhabricatorLiskDAO $object,
3547 array $xactions,
3548 PhabricatorMailTarget $target) {
3549
3550 // Check if any of the transactions are visible for this viewer. If we
3551 // don't have any visible transactions, don't send the mail.
3552
3553 $any_visible = false;
3554 foreach ($xactions as $xaction) {
3555 if (!$xaction->shouldHideForMail($xactions)) {
3556 $any_visible = true;
3557 break;
3558 }
3559 }
3560
3561 if (!$any_visible) {
3562 return null;
3563 }
3564
3565 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3566
3567 $mail = $this->buildMailTemplate($object);
3568 $body = $this->buildMailBody($object, $mail_xactions);
3569
3570 $mail_tags = $this->getMailTags($object, $mail_xactions);
3571 $action = $this->getMailAction($object, $mail_xactions);
3572 $stamps = $this->generateMailStamps($object, $this->mailStamps);
3573
3574 if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) {
3575 $this->addEmailPreferenceSectionToMailBody(
3576 $body,
3577 $object,
3578 $mail_xactions);
3579 }
3580
3581 $muted_phids = $this->mailMutedPHIDs;
3582 if (!is_array($muted_phids)) {
3583 $muted_phids = array();
3584 }
3585
3586 $mail
3587 ->setSensitiveContent(false)
3588 ->setFrom($this->getActingAsPHID())
3589 ->setSubjectPrefix($this->getMailSubjectPrefix())
3590 ->setVarySubjectPrefix('['.$action.']')
3591 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3592 ->setRelatedPHID($object->getPHID())
3593 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3594 ->setMutedPHIDs($muted_phids)
3595 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs)
3596 ->setMailTags($mail_tags)
3597 ->setIsBulk(true)
3598 ->setBody($body->render())
3599 ->setHTMLBody($body->renderHTML());
3600
3601 foreach ($body->getAttachments() as $attachment) {
3602 $mail->addAttachment($attachment);
3603 }
3604
3605 if ($this->heraldHeader) {
3606 $mail->addHeader('X-Herald-Rules', $this->heraldHeader);
3607 }
3608
3609 if ($object instanceof PhabricatorProjectInterface) {
3610 $this->addMailProjectMetadata($object, $mail);
3611 }
3612
3613 if ($this->getParentMessageID()) {
3614 $mail->setParentMessageID($this->getParentMessageID());
3615 }
3616
3617 // If we have stamps, attach the raw dictionary version (not the actual
3618 // objects) to the mail so that debugging tools can see what we used to
3619 // render the final list.
3620 if ($this->mailStamps) {
3621 $mail->setMailStampMetadata($this->mailStamps);
3622 }
3623
3624 // If we have rendered stamps, attach them to the mail.
3625 if ($stamps) {
3626 $mail->setMailStamps($stamps);
3627 }
3628
3629 return $target->willSendMail($mail);
3630 }
3631
3632 private function addMailProjectMetadata(
3633 PhabricatorLiskDAO $object,
3634 PhabricatorMetaMTAMail $template) {
3635
3636 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3637 $object->getPHID(),
3638 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3639
3640 if (!$project_phids) {
3641 return;
3642 }
3643
3644 // TODO: This viewer isn't quite right. It would be slightly better to use
3645 // the mail recipient, but that's not very easy given the way rendering
3646 // works today.
3647
3648 $handles = id(new PhabricatorHandleQuery())
3649 ->setViewer($this->requireActor())
3650 ->withPHIDs($project_phids)
3651 ->execute();
3652
3653 $project_tags = array();
3654 foreach ($handles as $handle) {
3655 if (!$handle->isComplete()) {
3656 continue;
3657 }
3658 $project_tags[] = '<'.$handle->getObjectName().'>';
3659 }
3660
3661 if (!$project_tags) {
3662 return;
3663 }
3664
3665 $project_tags = implode(', ', $project_tags);
3666 $template->addHeader('X-Phabricator-Projects', $project_tags);
3667 }
3668
3669
3670 protected function getMailThreadID(PhabricatorLiskDAO $object) {
3671 return $object->getPHID();
3672 }
3673
3674
3675 /**
3676 * @task mail
3677 */
3678 protected function getStrongestAction(
3679 PhabricatorLiskDAO $object,
3680 array $xactions) {
3681 return head(msortv($xactions, 'newActionStrengthSortVector'));
3682 }
3683
3684
3685 /**
3686 * @task mail
3687 */
3688 protected function buildReplyHandler(PhabricatorLiskDAO $object) {
3689 throw new Exception(pht('Capability not supported.'));
3690 }
3691
3692 /**
3693 * @task mail
3694 */
3695 protected function getMailSubjectPrefix() {
3696 throw new Exception(pht('Capability not supported.'));
3697 }
3698
3699
3700 /**
3701 * @task mail
3702 */
3703 protected function getMailTags(
3704 PhabricatorLiskDAO $object,
3705 array $xactions) {
3706 $tags = array();
3707
3708 foreach ($xactions as $xaction) {
3709 $tags[] = $xaction->getMailTags();
3710 }
3711
3712 return array_mergev($tags);
3713 }
3714
3715 /**
3716 * @task mail
3717 */
3718 public function getMailTagsMap() {
3719 // TODO: We should move shared mail tags, like "comment", here.
3720 return array();
3721 }
3722
3723
3724 /**
3725 * @task mail
3726 */
3727 protected function getMailAction(
3728 PhabricatorLiskDAO $object,
3729 array $xactions) {
3730 return $this->getStrongestAction($object, $xactions)->getActionName();
3731 }
3732
3733
3734 /**
3735 * @task mail
3736 */
3737 protected function buildMailTemplate(PhabricatorLiskDAO $object) {
3738 throw new Exception(pht('Capability not supported.'));
3739 }
3740
3741
3742 /**
3743 * @task mail
3744 */
3745 protected function getMailTo(PhabricatorLiskDAO $object) {
3746 throw new Exception(pht('Capability not supported.'));
3747 }
3748
3749
3750 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) {
3751 return array();
3752 }
3753
3754
3755 /**
3756 * @task mail
3757 */
3758 protected function getMailCC(PhabricatorLiskDAO $object) {
3759 $phids = array();
3760 $has_support = false;
3761
3762 if ($object instanceof PhabricatorSubscribableInterface) {
3763 $phid = $object->getPHID();
3764 $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid);
3765 $has_support = true;
3766 }
3767
3768 if ($object instanceof PhabricatorProjectInterface) {
3769 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
3770 $object->getPHID(),
3771 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
3772
3773 if ($project_phids) {
3774 $projects = id(new PhabricatorProjectQuery())
3775 ->setViewer(PhabricatorUser::getOmnipotentUser())
3776 ->withPHIDs($project_phids)
3777 ->needWatchers(true)
3778 ->execute();
3779
3780 $watcher_phids = array();
3781 foreach ($projects as $project) {
3782 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3783 $watcher_phids[$phid] = $phid;
3784 }
3785 }
3786
3787 if ($watcher_phids) {
3788 // We need to do a visibility check for all the watchers, as
3789 // watching a project is not a guarantee that you can see objects
3790 // associated with it.
3791 $users = id(new PhabricatorPeopleQuery())
3792 ->setViewer($this->requireActor())
3793 ->withPHIDs($watcher_phids)
3794 ->execute();
3795
3796 $watchers = array();
3797 foreach ($users as $user) {
3798 $can_see = PhabricatorPolicyFilter::hasCapability(
3799 $user,
3800 $object,
3801 PhabricatorPolicyCapability::CAN_VIEW);
3802 if ($can_see) {
3803 $watchers[] = $user->getPHID();
3804 }
3805 }
3806 $phids[] = $watchers;
3807 }
3808 }
3809
3810 $has_support = true;
3811 }
3812
3813 if (!$has_support) {
3814 throw new Exception(
3815 pht('The object being edited does not implement any standard '.
3816 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3817 'CCs to be generated automatically. Override the "getMailCC()" '.
3818 'method and generate CCs explicitly.'));
3819 }
3820
3821 return array_mergev($phids);
3822 }
3823
3824
3825 /**
3826 * @task mail
3827 */
3828 protected function buildMailBody(
3829 PhabricatorLiskDAO $object,
3830 array $xactions) {
3831
3832 $body = id(new PhabricatorMetaMTAMailBody())
3833 ->setViewer($this->requireActor())
3834 ->setContextObject($object);
3835
3836 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3837 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3838
3839 $this->addHeadersAndCommentsToMailBody(
3840 $body,
3841 $xactions,
3842 $button_label,
3843 $button_uri);
3844
3845 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3846
3847 return $body;
3848 }
3849
3850 protected function getObjectLinkButtonLabelForMail(
3851 PhabricatorLiskDAO $object) {
3852 return null;
3853 }
3854
3855 protected function getObjectLinkButtonURIForMail(
3856 PhabricatorLiskDAO $object) {
3857
3858 // Most objects define a "getURI()" method which does what we want, but
3859 // this isn't formally part of an interface at time of writing. Try to
3860 // call the method, expecting an exception if it does not exist.
3861
3862 try {
3863 $uri = $object->getURI();
3864 return PhabricatorEnv::getProductionURI($uri);
3865 } catch (Exception $ex) {
3866 return null;
3867 }
3868 }
3869
3870 /**
3871 * @task mail
3872 */
3873 protected function addEmailPreferenceSectionToMailBody(
3874 PhabricatorMetaMTAMailBody $body,
3875 PhabricatorLiskDAO $object,
3876 array $xactions) {
3877
3878 $href = PhabricatorEnv::getProductionURI(
3879 '/settings/panel/emailpreferences/');
3880 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3881 }
3882
3883
3884 /**
3885 * @task mail
3886 */
3887 protected function addHeadersAndCommentsToMailBody(
3888 PhabricatorMetaMTAMailBody $body,
3889 array $xactions,
3890 $object_label = null,
3891 $object_uri = null) {
3892
3893 // First, remove transactions which shouldn't be rendered in mail.
3894 foreach ($xactions as $key => $xaction) {
3895 if ($xaction->shouldHideForMail($xactions)) {
3896 unset($xactions[$key]);
3897 }
3898 }
3899
3900 $headers = array();
3901 $headers_html = array();
3902 $comments = array();
3903 $details = array();
3904
3905 $seen_comment = false;
3906 foreach ($xactions as $xaction) {
3907
3908 // Most mail has zero or one comments. In these cases, we render the
3909 // "alice added a comment." transaction in the header, like a normal
3910 // transaction.
3911
3912 // Some mail, like Differential undraft mail or "!history" mail, may
3913 // have two or more comments. In these cases, we'll put the first
3914 // "alice added a comment." transaction in the header normally, but
3915 // move the other transactions down so they provide context above the
3916 // actual comment.
3917
3918 $comment = $this->getBodyForTextMail($xaction);
3919 if ($comment !== null) {
3920 $is_comment = true;
3921 $comments[] = array(
3922 'xaction' => $xaction,
3923 'comment' => $comment,
3924 'initial' => !$seen_comment,
3925 );
3926 } else {
3927 $is_comment = false;
3928 }
3929
3930 if (!$is_comment || !$seen_comment) {
3931 $header = $this->getTitleForTextMail($xaction);
3932 if ($header !== null) {
3933 $headers[] = $header;
3934 }
3935
3936 $header_html = $this->getTitleForHTMLMail($xaction);
3937 if ($header_html !== null) {
3938 $headers_html[] = $header_html;
3939 }
3940 }
3941
3942 if ($xaction->hasChangeDetailsForMail()) {
3943 $details[] = $xaction;
3944 }
3945
3946 if ($is_comment) {
3947 $seen_comment = true;
3948 }
3949 }
3950
3951 $headers_text = implode("\n", $headers);
3952 $body->addRawPlaintextSection($headers_text);
3953
3954 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3955
3956 $header_button = null;
3957 if ($object_label !== null && $object_uri !== null) {
3958 $button_style = array(
3959 'text-decoration: none;',
3960 'padding: 4px 8px;',
3961 'margin: 0 8px 8px;',
3962 'float: right;',
3963 'color: #464C5C;',
3964 'font-weight: bold;',
3965 'border-radius: 3px;',
3966 'background-color: #F7F7F9;',
3967 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3968 'display: inline-block;',
3969 'border: 1px solid rgba(71,87,120,.2);',
3970 );
3971
3972 $header_button = phutil_tag(
3973 'a',
3974 array(
3975 'style' => implode(' ', $button_style),
3976 'href' => $object_uri,
3977 ),
3978 $object_label);
3979 }
3980
3981 $header_action = phutil_tag(
3982 'td',
3983 array(),
3984 array(
3985 $headers_html,
3986 // Add an extra newline to prevent the "View Object" button from
3987 // running into the transaction text in Mail.app text snippet
3988 // previews.
3989 "\n",
3990 ));
3991
3992 $headers_html = phutil_tag(
3993 'table',
3994 array(),
3995 phutil_tag('tr', array(), array($header_action, $header_button)));
3996
3997 $body->addRawHTMLSection($headers_html);
3998
3999 foreach ($comments as $spec) {
4000 $xaction = $spec['xaction'];
4001 $comment = $spec['comment'];
4002 $is_initial = $spec['initial'];
4003
4004 // If this is not the first comment in the mail, add the header showing
4005 // who wrote the comment immediately above the comment.
4006 if (!$is_initial) {
4007 $header = $this->getTitleForTextMail($xaction);
4008 if ($header !== null) {
4009 $body->addRawPlaintextSection($header);
4010 }
4011
4012 $header_html = $this->getTitleForHTMLMail($xaction);
4013 if ($header_html !== null) {
4014 $body->addRawHTMLSection($header_html);
4015 }
4016 }
4017
4018 $body->addRemarkupSection(null, $comment);
4019 }
4020
4021 foreach ($details as $xaction) {
4022 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
4023 if ($details !== null) {
4024 $label = $this->getMailDiffSectionHeader($xaction);
4025 $body->addHTMLSection($label, $details);
4026 }
4027 }
4028
4029 }
4030
4031 private function getMailDiffSectionHeader($xaction) {
4032 $type = $xaction->getTransactionType();
4033 $object = $this->object;
4034
4035 $xtype = $this->getModularTransactionType($object, $type);
4036 if ($xtype) {
4037 return $xtype->getMailDiffSectionHeader();
4038 }
4039
4040 return pht('EDIT DETAILS');
4041 }
4042
4043 /**
4044 * @task mail
4045 */
4046 protected function addCustomFieldsToMailBody(
4047 PhabricatorMetaMTAMailBody $body,
4048 PhabricatorLiskDAO $object,
4049 array $xactions) {
4050
4051 if ($object instanceof PhabricatorCustomFieldInterface) {
4052 $field_list = PhabricatorCustomField::getObjectFields(
4053 $object,
4054 PhabricatorCustomField::ROLE_TRANSACTIONMAIL);
4055 $field_list->setViewer($this->getActor());
4056 $field_list->readFieldsFromStorage($object);
4057
4058 foreach ($field_list->getFields() as $field) {
4059 $field->updateTransactionMailBody(
4060 $body,
4061 $this,
4062 $xactions);
4063 }
4064 }
4065 }
4066
4067
4068 /**
4069 * @task mail
4070 */
4071 private function runHeraldMailRules(array $messages) {
4072 foreach ($messages as $message) {
4073 $engine = new HeraldEngine();
4074 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
4075 ->setObject($message);
4076
4077 $rules = $engine->loadRulesForAdapter($adapter);
4078 $effects = $engine->applyRules($rules, $adapter);
4079 $engine->applyEffects($effects, $adapter, $rules);
4080 }
4081 }
4082
4083
4084/* -( Publishing Feed Stories )-------------------------------------------- */
4085
4086
4087 /**
4088 * @task feed
4089 */
4090 protected function shouldPublishFeedStory(
4091 PhabricatorLiskDAO $object,
4092 array $xactions) {
4093 return false;
4094 }
4095
4096
4097 /**
4098 * @task feed
4099 */
4100 protected function getFeedStoryType() {
4101 return 'PhabricatorApplicationTransactionFeedStory';
4102 }
4103
4104
4105 /**
4106 * @task feed
4107 */
4108 protected function getFeedRelatedPHIDs(
4109 PhabricatorLiskDAO $object,
4110 array $xactions) {
4111
4112 $phids = array(
4113 $object->getPHID(),
4114 $this->getActingAsPHID(),
4115 );
4116
4117 if ($object instanceof PhabricatorProjectInterface) {
4118 $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs(
4119 $object->getPHID(),
4120 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST);
4121 foreach ($project_phids as $project_phid) {
4122 $phids[] = $project_phid;
4123 }
4124 }
4125
4126 return $phids;
4127 }
4128
4129
4130 /**
4131 * @task feed
4132 */
4133 protected function getFeedNotifyPHIDs(
4134 PhabricatorLiskDAO $object,
4135 array $xactions) {
4136
4137 // If some transactions are forcing notification delivery, add the forced
4138 // recipients to the notify list.
4139 $force_list = array();
4140 foreach ($xactions as $xaction) {
4141 $force_phids = $xaction->getForceNotifyPHIDs();
4142
4143 if (!$force_phids) {
4144 continue;
4145 }
4146
4147 foreach ($force_phids as $force_phid) {
4148 $force_list[] = $force_phid;
4149 }
4150 }
4151
4152 $to_list = $this->getMailTo($object);
4153 $cc_list = $this->getMailCC($object);
4154
4155 $full_list = array_merge($force_list, $to_list, $cc_list);
4156 $full_list = array_fuse($full_list);
4157
4158 return array_keys($full_list);
4159 }
4160
4161
4162 /**
4163 * @task feed
4164 */
4165 protected function getFeedStoryData(
4166 PhabricatorLiskDAO $object,
4167 array $xactions) {
4168
4169 $xactions = msortv($xactions, 'newActionStrengthSortVector');
4170
4171 return array(
4172 'objectPHID' => $object->getPHID(),
4173 'transactionPHIDs' => mpull($xactions, 'getPHID'),
4174 );
4175 }
4176
4177
4178 /**
4179 * @task feed
4180 */
4181 protected function publishFeedStory(
4182 PhabricatorLiskDAO $object,
4183 array $xactions,
4184 array $mailed_phids) {
4185
4186 // Remove transactions which don't publish feed stories or notifications.
4187 // These never show up anywhere, so we don't need to do anything with them.
4188 foreach ($xactions as $key => $xaction) {
4189 if (!$xaction->shouldHideForFeed()) {
4190 continue;
4191 }
4192
4193 if (!$xaction->shouldHideForNotifications()) {
4194 continue;
4195 }
4196
4197 unset($xactions[$key]);
4198 }
4199
4200 if (!$xactions) {
4201 return;
4202 }
4203
4204 $related_phids = $this->feedRelatedPHIDs;
4205 $subscribed_phids = $this->feedNotifyPHIDs;
4206
4207 // Remove muted users from the subscription list so they don't get
4208 // notifications, either.
4209 $muted_phids = $this->mailMutedPHIDs;
4210 if (!is_array($muted_phids)) {
4211 $muted_phids = array();
4212 }
4213 $subscribed_phids = array_fuse($subscribed_phids);
4214 foreach ($muted_phids as $muted_phid) {
4215 unset($subscribed_phids[$muted_phid]);
4216 }
4217 $subscribed_phids = array_values($subscribed_phids);
4218
4219 $story_type = $this->getFeedStoryType();
4220 $story_data = $this->getFeedStoryData($object, $xactions);
4221
4222 $unexpandable_phids = $this->mailUnexpandablePHIDs;
4223 if (!is_array($unexpandable_phids)) {
4224 $unexpandable_phids = array();
4225 }
4226
4227 id(new PhabricatorFeedStoryPublisher())
4228 ->setStoryType($story_type)
4229 ->setStoryData($story_data)
4230 ->setStoryTime(time())
4231 ->setStoryAuthorPHID($this->getActingAsPHID())
4232 ->setRelatedPHIDs($related_phids)
4233 ->setPrimaryObjectPHID($object->getPHID())
4234 ->setSubscribedPHIDs($subscribed_phids)
4235 ->setUnexpandablePHIDs($unexpandable_phids)
4236 ->setMailRecipientPHIDs($mailed_phids)
4237 ->setMailTags($this->getMailTags($object, $xactions))
4238 ->publish();
4239 }
4240
4241
4242/* -( Search Index )------------------------------------------------------- */
4243
4244
4245 /**
4246 * @task search
4247 */
4248 protected function supportsSearch() {
4249 return false;
4250 }
4251
4252
4253/* -( Herald Integration )-------------------------------------------------- */
4254
4255
4256 protected function shouldApplyHeraldRules(
4257 PhabricatorLiskDAO $object,
4258 array $xactions) {
4259 return false;
4260 }
4261
4262 protected function buildHeraldAdapter(
4263 PhabricatorLiskDAO $object,
4264 array $xactions) {
4265 throw new Exception(pht('No herald adapter specified.'));
4266 }
4267
4268 private function setHeraldAdapter(HeraldAdapter $adapter) {
4269 $this->heraldAdapter = $adapter;
4270 return $this;
4271 }
4272
4273 protected function getHeraldAdapter() {
4274 return $this->heraldAdapter;
4275 }
4276
4277 private function setHeraldTranscript(HeraldTranscript $transcript) {
4278 $this->heraldTranscript = $transcript;
4279 return $this;
4280 }
4281
4282 protected function getHeraldTranscript() {
4283 return $this->heraldTranscript;
4284 }
4285
4286 private function applyHeraldRules(
4287 PhabricatorLiskDAO $object,
4288 array $xactions) {
4289
4290 $adapter = $this->buildHeraldAdapter($object, $xactions)
4291 ->setContentSource($this->getContentSource())
4292 ->setIsNewObject($this->getIsNewObject())
4293 ->setActingAsPHID($this->getActingAsPHID())
4294 ->setAppliedTransactions($xactions);
4295
4296 if ($this->getApplicationEmail()) {
4297 $adapter->setApplicationEmail($this->getApplicationEmail());
4298 }
4299
4300 // If this editor is operating in silent mode, tell Herald that we aren't
4301 // going to send any mail. This allows it to skip "the first time this
4302 // rule matches, send me an email" rules which would otherwise match even
4303 // though we aren't going to send any mail.
4304 if ($this->getIsSilent()) {
4305 $adapter->setForbiddenAction(
4306 HeraldMailableState::STATECONST,
4307 HeraldCoreStateReasons::REASON_SILENT);
4308 }
4309
4310 $xscript = HeraldEngine::loadAndApplyRules($adapter);
4311
4312 $this->setHeraldAdapter($adapter);
4313 $this->setHeraldTranscript($xscript);
4314
4315 if ($adapter instanceof HarbormasterBuildableAdapterInterface) {
4316 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
4317
4318 HarbormasterBuildable::applyBuildPlans(
4319 $buildable_phid,
4320 $adapter->getHarbormasterContainerPHID(),
4321 $adapter->getQueuedHarbormasterBuildRequests());
4322
4323 // Whether we queued any builds or not, any automatic buildable for this
4324 // object is now done preparing builds and can transition into a
4325 // completed status.
4326 $buildables = id(new HarbormasterBuildableQuery())
4327 ->setViewer(PhabricatorUser::getOmnipotentUser())
4328 ->withManualBuildables(false)
4329 ->withBuildablePHIDs(array($buildable_phid))
4330 ->execute();
4331 foreach ($buildables as $buildable) {
4332 // If this buildable has already moved beyond preparation, we don't
4333 // need to nudge it again.
4334 if (!$buildable->isPreparing()) {
4335 continue;
4336 }
4337 $buildable->sendMessage(
4338 $this->getActor(),
4339 HarbormasterMessageType::BUILDABLE_BUILD,
4340 true);
4341 }
4342 }
4343
4344 $this->mustEncrypt = $adapter->getMustEncryptReasons();
4345
4346 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
4347 foreach ($this->subEditors as $sub_editor) {
4348 $sub_editor->mustEncrypt = $this->mustEncrypt;
4349 }
4350
4351 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
4352 assert_instances_of(
4353 $apply_xactions,
4354 PhabricatorApplicationTransaction::class);
4355
4356 $queue_xactions = $adapter->getQueuedTransactions();
4357
4358 return array_merge(
4359 array_values($apply_xactions),
4360 array_values($queue_xactions));
4361 }
4362
4363 protected function didApplyHeraldRules(
4364 PhabricatorLiskDAO $object,
4365 HeraldAdapter $adapter,
4366 HeraldTranscript $transcript) {
4367 return array();
4368 }
4369
4370
4371/* -( Custom Fields )------------------------------------------------------ */
4372
4373
4374 /**
4375 * @task customfield
4376 */
4377 private function getCustomFieldForTransaction(
4378 PhabricatorLiskDAO $object,
4379 PhabricatorApplicationTransaction $xaction) {
4380
4381 $field_key = $xaction->getMetadataValue('customfield:key');
4382 if (!$field_key) {
4383 throw new Exception(
4384 pht(
4385 "Custom field transaction has no '%s'!",
4386 'customfield:key'));
4387 }
4388
4389 $field = PhabricatorCustomField::getObjectField(
4390 $object,
4391 PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS,
4392 $field_key);
4393
4394 if (!$field) {
4395 throw new Exception(
4396 pht(
4397 "Custom field transaction has invalid '%s'; field '%s' ".
4398 "is disabled or does not exist.",
4399 'customfield:key',
4400 $field_key));
4401 }
4402
4403 if (!$field->shouldAppearInApplicationTransactions()) {
4404 throw new Exception(
4405 pht(
4406 "Custom field transaction '%s' does not implement ".
4407 "integration for %s.",
4408 $field_key,
4409 'ApplicationTransactions'));
4410 }
4411
4412 $field->setViewer($this->getActor());
4413
4414 return $field;
4415 }
4416
4417
4418/* -( Files )-------------------------------------------------------------- */
4419
4420
4421 /**
4422 * Extract the PHIDs of any files which these transactions attach.
4423 *
4424 * @task files
4425 */
4426 private function extractFilePHIDs(
4427 PhabricatorLiskDAO $object,
4428 array $xactions) {
4429
4430 $phids = array();
4431
4432 foreach ($xactions as $xaction) {
4433 $type = $xaction->getTransactionType();
4434
4435 $xtype = $this->getModularTransactionType($object, $type);
4436 if ($xtype) {
4437 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4438 } else {
4439 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4440 $object,
4441 $xaction);
4442 }
4443 }
4444
4445 $phids = array_unique(array_filter(array_mergev($phids)));
4446
4447 return $phids;
4448 }
4449
4450 /**
4451 * @task files
4452 */
4453 protected function extractFilePHIDsFromCustomTransaction(
4454 PhabricatorLiskDAO $object,
4455 PhabricatorApplicationTransaction $xaction) {
4456 return array();
4457 }
4458
4459
4460 private function applyInverseEdgeTransactions(
4461 PhabricatorLiskDAO $object,
4462 PhabricatorApplicationTransaction $xaction,
4463 $inverse_type) {
4464
4465 $old = $xaction->getOldValue();
4466 $new = $xaction->getNewValue();
4467
4468 $add = array_keys(array_diff_key($new, $old));
4469 $rem = array_keys(array_diff_key($old, $new));
4470
4471 $add = array_fuse($add);
4472 $rem = array_fuse($rem);
4473 $all = $add + $rem;
4474
4475 $nodes = id(new PhabricatorObjectQuery())
4476 ->setViewer($this->requireActor())
4477 ->withPHIDs($all)
4478 ->execute();
4479
4480 $object_phid = $object->getPHID();
4481
4482 foreach ($nodes as $node) {
4483 if (!($node instanceof PhabricatorApplicationTransactionInterface)) {
4484 continue;
4485 }
4486
4487 if ($node instanceof PhabricatorUser) {
4488 // TODO: At least for now, don't record inverse edge transactions
4489 // for users (for example, "alincoln joined project X"): Feed fills
4490 // this role instead.
4491 continue;
4492 }
4493
4494 $node_phid = $node->getPHID();
4495 $editor = $node->getApplicationTransactionEditor();
4496 $template = $node->getApplicationTransactionTemplate();
4497
4498 // See T13082. We have to build these transactions with synthetic values
4499 // because we've already applied the actual edit to the edge database
4500 // table. If we try to apply this transaction naturally, it will no-op
4501 // itself because it doesn't have any effect.
4502
4503 $edge_query = id(new PhabricatorEdgeQuery())
4504 ->withSourcePHIDs(array($node_phid))
4505 ->withEdgeTypes(array($inverse_type));
4506
4507 $edge_query->execute();
4508
4509 $edge_phids = $edge_query->getDestinationPHIDs();
4510 $edge_phids = array_fuse($edge_phids);
4511
4512 $new_phids = $edge_phids;
4513 $old_phids = $edge_phids;
4514
4515 if (isset($add[$node_phid])) {
4516 unset($old_phids[$object_phid]);
4517 } else {
4518 $old_phids[$object_phid] = $object_phid;
4519 }
4520
4521 $template
4522 ->setTransactionType($xaction->getTransactionType())
4523 ->setMetadataValue('edge:type', $inverse_type)
4524 ->setOldValue($old_phids)
4525 ->setNewValue($new_phids);
4526
4527 $editor = $this->newSubEditor($editor)
4528 ->setContinueOnNoEffect(true)
4529 ->setContinueOnMissingFields(true)
4530 ->setIsInverseEdgeEditor(true);
4531
4532 $editor->applyTransactions($node, array($template));
4533 }
4534 }
4535
4536
4537/* -( Workers )------------------------------------------------------------ */
4538
4539
4540 /**
4541 * Load any object state which is required to publish transactions.
4542 *
4543 * This hook is invoked in the main process before we compute data related
4544 * to publishing transactions (like email "To" and "CC" lists), and again in
4545 * the worker before publishing occurs.
4546 *
4547 * @return object Publishable object.
4548 * @task workers
4549 */
4550 protected function willPublish(PhabricatorLiskDAO $object, array $xactions) {
4551 return $object;
4552 }
4553
4554
4555 /**
4556 * Convert the editor state to a serializable dictionary which can be passed
4557 * to a worker.
4558 *
4559 * This data will be loaded with @{method:loadWorkerState} in the worker.
4560 *
4561 * @return array<string, mixed> Serializable editor state.
4562 * @task workers
4563 */
4564 private function getWorkerState() {
4565 $state = array();
4566 foreach ($this->getAutomaticStateProperties() as $property) {
4567 $state[$property] = $this->$property;
4568 }
4569
4570 $custom_state = $this->getCustomWorkerState();
4571 $custom_encoding = $this->getCustomWorkerStateEncoding();
4572
4573 $state += array(
4574 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4575 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4576 'custom.encoding' => $custom_encoding,
4577 );
4578
4579 return $state;
4580 }
4581
4582
4583 /**
4584 * Hook; return custom properties which need to be passed to workers.
4585 *
4586 * @return array<string, mixed> Custom properties.
4587 * @task workers
4588 */
4589 protected function getCustomWorkerState() {
4590 return array();
4591 }
4592
4593
4594 /**
4595 * Hook; return storage encoding for custom properties which need to be
4596 * passed to workers.
4597 *
4598 * This primarily allows binary data to be passed to workers and survive
4599 * JSON encoding.
4600 *
4601 * @return array<string, string> Property encodings.
4602 * @task workers
4603 */
4604 protected function getCustomWorkerStateEncoding() {
4605 return array();
4606 }
4607
4608
4609 /**
4610 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4611 *
4612 * This method is used to load state when running worker operations.
4613 *
4614 * @param array<string, mixed> $state Editor state, from
4615 @{method:getWorkerState}.
4616 * @return $this
4617 * @task workers
4618 */
4619 final public function loadWorkerState(array $state) {
4620 foreach ($this->getAutomaticStateProperties() as $property) {
4621 $this->$property = idx($state, $property);
4622 }
4623
4624 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4625 $this->setExcludeMailRecipientPHIDs($exclude);
4626
4627 $custom_state = idx($state, 'custom', array());
4628 $custom_encodings = idx($state, 'custom.encoding', array());
4629 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4630
4631 $this->loadCustomWorkerState($custom);
4632
4633 return $this;
4634 }
4635
4636
4637 /**
4638 * Hook; set custom properties on the editor from data emitted by
4639 * @{method:getCustomWorkerState}.
4640 *
4641 * @param array<string, mixed> $state Custom state,
4642 * from @{method:getCustomWorkerState}.
4643 * @return $this
4644 * @task workers
4645 */
4646 protected function loadCustomWorkerState(array $state) {
4647 return $this;
4648 }
4649
4650
4651 /**
4652 * Get a list of object properties which should be automatically sent to
4653 * workers in the state data.
4654 *
4655 * These properties will be automatically stored and loaded by the editor in
4656 * the worker.
4657 *
4658 * @return list<string> List of properties.
4659 * @task workers
4660 */
4661 private function getAutomaticStateProperties() {
4662 return array(
4663 'parentMessageID',
4664 'isNewObject',
4665 'heraldEmailPHIDs',
4666 'heraldForcedEmailPHIDs',
4667 'heraldHeader',
4668 'mailToPHIDs',
4669 'mailCCPHIDs',
4670 'feedNotifyPHIDs',
4671 'feedRelatedPHIDs',
4672 'feedShouldPublish',
4673 'mailShouldSend',
4674 'mustEncrypt',
4675 'mailStamps',
4676 'mailUnexpandablePHIDs',
4677 'mailMutedPHIDs',
4678 'webhookMap',
4679 'silent',
4680 'sendHistory',
4681 );
4682 }
4683
4684 /**
4685 * Apply encodings prior to storage.
4686 *
4687 * See @{method:getCustomWorkerStateEncoding}.
4688 *
4689 * @param map<string,mixed> $state Map of values to encode.
4690 * @param map<string,string> $encodings Map of encodings to apply.
4691 * @return map<string,mixed> Map of encoded values.
4692 *
4693 * @task workers
4694 */
4695 private function encodeStateForStorage(
4696 array $state,
4697 array $encodings) {
4698
4699 foreach ($state as $key => $value) {
4700 $encoding = idx($encodings, $key);
4701 switch ($encoding) {
4702 case self::STORAGE_ENCODING_BINARY:
4703 // The mechanics of this encoding (serialize + base64) are a little
4704 // awkward, but it allows us encode arrays and still be JSON-safe
4705 // with binary data.
4706
4707 $value = @serialize($value);
4708 if ($value === false) {
4709 throw new Exception(
4710 pht(
4711 'Failed to serialize() value for key "%s".',
4712 $key));
4713 }
4714
4715 $value = base64_encode($value);
4716 if ($value === false) {
4717 throw new Exception(
4718 pht(
4719 'Failed to base64 encode value for key "%s".',
4720 $key));
4721 }
4722 break;
4723 }
4724 $state[$key] = $value;
4725 }
4726
4727 return $state;
4728 }
4729
4730
4731 /**
4732 * Undo storage encoding applied when storing state.
4733 *
4734 * See @{method:getCustomWorkerStateEncoding}.
4735 *
4736 * @param map<string, mixed> $state Map of encoded values.
4737 * @param map<string, string> $encodings Map of encodings.
4738 * @return map<string, mixed> Map of decoded values.
4739 *
4740 * @task workers
4741 */
4742 private function decodeStateFromStorage(
4743 array $state,
4744 array $encodings) {
4745
4746 foreach ($state as $key => $value) {
4747 $encoding = idx($encodings, $key);
4748 switch ($encoding) {
4749 case self::STORAGE_ENCODING_BINARY:
4750 $value = base64_decode($value);
4751 if ($value === false) {
4752 throw new Exception(
4753 pht(
4754 'Failed to base64_decode() value for key "%s".',
4755 $key));
4756 }
4757
4758 $value = unserialize($value);
4759 break;
4760 }
4761 $state[$key] = $value;
4762 }
4763
4764 return $state;
4765 }
4766
4767
4768 /**
4769 * Remove conflicts from a list of projects.
4770 *
4771 * Objects aren't allowed to be tagged with multiple milestones in the same
4772 * group, nor projects such that one tag is the ancestor of any other tag.
4773 * If the list of PHIDs include mutually exclusive projects, remove the
4774 * conflicting projects.
4775 *
4776 * @param list<string> $phids List of project PHIDs.
4777 * @return list<string> List of project PHIDs with conflicts removed.
4778 */
4779 private function applyProjectConflictRules(array $phids) {
4780 if (!$phids) {
4781 return array();
4782 }
4783
4784 // Overall, the last project in the list wins in cases of conflict (so when
4785 // you add something, the thing you just added sticks and removes older
4786 // values).
4787
4788 // Beyond that, there are two basic cases:
4789
4790 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4791 // If multiple projects are milestones of the same parent, we only keep the
4792 // last one.
4793
4794 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4795 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4796 // remove "A > B" and keep "A".
4797
4798 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4799 // if one project is an ancestor of another. It's OK to have something
4800 // tagged with multiple projects which share a common ancestor, so long as
4801 // they are not mutual ancestors.
4802
4803 $viewer = PhabricatorUser::getOmnipotentUser();
4804
4805 $projects = id(new PhabricatorProjectQuery())
4806 ->setViewer($viewer)
4807 ->withPHIDs(array_keys($phids))
4808 ->execute();
4809 $projects = mpull($projects, null, 'getPHID');
4810
4811 // We're going to build a map from each project with milestones to the last
4812 // milestone in the list. This last milestone is the milestone we'll keep.
4813 $milestone_map = array();
4814
4815 // We're going to build a set of the projects which have no descendants
4816 // later in the list. This allows us to apply both ancestor rules.
4817 $ancestor_map = array();
4818
4819 foreach ($phids as $phid => $ignored) {
4820 $project = idx($projects, $phid);
4821 if (!$project) {
4822 continue;
4823 }
4824
4825 // This is the last milestone we've seen, so set it as the selection for
4826 // the project's parent. This might be setting a new value or overwriting
4827 // an earlier value.
4828 if ($project->isMilestone()) {
4829 $parent_phid = $project->getParentProjectPHID();
4830 $milestone_map[$parent_phid] = $phid;
4831 }
4832
4833 // Since this is the last item in the list we've examined so far, add it
4834 // to the set of projects with no later descendants.
4835 $ancestor_map[$phid] = $phid;
4836
4837 // Remove any ancestors from the set, since this is a later descendant.
4838 foreach ($project->getAncestorProjects() as $ancestor) {
4839 $ancestor_phid = $ancestor->getPHID();
4840 unset($ancestor_map[$ancestor_phid]);
4841 }
4842 }
4843
4844 // Now that we've built the maps, we can throw away all the projects which
4845 // have conflicts.
4846 foreach ($phids as $phid => $ignored) {
4847 $project = idx($projects, $phid);
4848
4849 if (!$project) {
4850 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4851 // but leaving it untouched is less likely to cause collateral damage.
4852 continue;
4853 }
4854
4855 // If this was a milestone, check if it was the last milestone from its
4856 // group in the list. If not, remove it from the list.
4857 if ($project->isMilestone()) {
4858 $parent_phid = $project->getParentProjectPHID();
4859 if ($milestone_map[$parent_phid] !== $phid) {
4860 unset($phids[$phid]);
4861 continue;
4862 }
4863 }
4864
4865 // If a later project in the list is a subproject of this one, it will
4866 // have removed ancestors from the map. If this project does not point
4867 // at itself in the ancestor map, it should be discarded in favor of a
4868 // subproject that comes later.
4869 if (idx($ancestor_map, $phid) !== $phid) {
4870 unset($phids[$phid]);
4871 continue;
4872 }
4873
4874 // If a later project in the list is an ancestor of this one, it will
4875 // have added itself to the map. If any ancestor of this project points
4876 // at itself in the map, this project should be discarded in favor of
4877 // that later ancestor.
4878 foreach ($project->getAncestorProjects() as $ancestor) {
4879 $ancestor_phid = $ancestor->getPHID();
4880 if (isset($ancestor_map[$ancestor_phid])) {
4881 unset($phids[$phid]);
4882 continue 2;
4883 }
4884 }
4885 }
4886
4887 return $phids;
4888 }
4889
4890 /**
4891 * When the view policy for an object is changed, scramble the secret keys
4892 * for attached files to invalidate existing URIs.
4893 */
4894 private function scrambleFileSecrets($object) {
4895 // If this is a newly created object, we don't need to scramble anything
4896 // since it couldn't have been previously published.
4897 if ($this->getIsNewObject()) {
4898 return;
4899 }
4900
4901 // If the object is a file itself, scramble it.
4902 if ($object instanceof PhabricatorFile) {
4903 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4904 $object->scrambleSecret();
4905 $object->save();
4906 }
4907 }
4908
4909 $omnipotent_viewer = PhabricatorUser::getOmnipotentUser();
4910
4911 $files = id(new PhabricatorFileQuery())
4912 ->setViewer($omnipotent_viewer)
4913 ->withAttachedObjectPHIDs(array($object->getPHID()))
4914 ->execute();
4915 foreach ($files as $file) {
4916 $view_policy = $file->getViewPolicy();
4917 if ($this->shouldScramblePolicy($view_policy)) {
4918 $file->scrambleSecret();
4919 $file->save();
4920 }
4921 }
4922 }
4923
4924
4925 /**
4926 * Check if a policy is strong enough to justify scrambling. Objects which
4927 * are set to very open policies don't need to scramble their files, and
4928 * files with very open policies don't need to be scrambled when associated
4929 * objects change.
4930 */
4931 private function shouldScramblePolicy($policy) {
4932 switch ($policy) {
4933 case PhabricatorPolicies::POLICY_PUBLIC:
4934 case PhabricatorPolicies::POLICY_USER:
4935 return false;
4936 }
4937
4938 return true;
4939 }
4940
4941 private function updateWorkboardColumns($object, $const, $old, $new) {
4942 // If an object is removed from a project, remove it from any proxy
4943 // columns for that project. This allows a task which is moved up from a
4944 // milestone to the parent to move back into the "Backlog" column on the
4945 // parent workboard.
4946
4947 if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) {
4948 return;
4949 }
4950
4951 // TODO: This should likely be some future WorkboardInterface.
4952 $appears_on_workboards = ($object instanceof ManiphestTask);
4953 if (!$appears_on_workboards) {
4954 return;
4955 }
4956
4957 $removed_phids = array_keys(array_diff_key($old, $new));
4958 if (!$removed_phids) {
4959 return;
4960 }
4961
4962 // Find any proxy columns for the removed projects.
4963 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4964 ->setViewer(PhabricatorUser::getOmnipotentUser())
4965 ->withProxyPHIDs($removed_phids)
4966 ->execute();
4967 if (!$proxy_columns) {
4968 return;
4969 }
4970
4971 $proxy_phids = mpull($proxy_columns, 'getPHID');
4972
4973 $position_table = new PhabricatorProjectColumnPosition();
4974 $conn_w = $position_table->establishConnection('w');
4975
4976 queryfx(
4977 $conn_w,
4978 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4979 $position_table->getTableName(),
4980 $object->getPHID(),
4981 $proxy_phids);
4982 }
4983
4984 private function getModularTransactionTypes(
4985 PhabricatorLiskDAO $object) {
4986
4987 if ($this->modularTypes === null) {
4988 $template = $object->getApplicationTransactionTemplate();
4989 if ($template instanceof PhabricatorModularTransaction) {
4990 $xtypes = $template->newModularTransactionTypes();
4991 foreach ($xtypes as $key => $xtype) {
4992 $xtype = clone $xtype;
4993 $xtype->setEditor($this);
4994 $xtypes[$key] = $xtype;
4995 }
4996 } else {
4997 $xtypes = array();
4998 }
4999
5000 $this->modularTypes = $xtypes;
5001 }
5002
5003 return $this->modularTypes;
5004 }
5005
5006 private function getModularTransactionType($object, $type) {
5007 $types = $this->getModularTransactionTypes($object);
5008 return idx($types, $type);
5009 }
5010
5011 public function getCreateObjectTitle($author, $object) {
5012 return pht('%s created this object.', $author);
5013 }
5014
5015 public function getCreateObjectTitleForFeed($author, $object) {
5016 return pht('%s created an object: %s.', $author, $object);
5017 }
5018
5019/* -( Queue )-------------------------------------------------------------- */
5020
5021 protected function queueTransaction(
5022 PhabricatorApplicationTransaction $xaction) {
5023 $this->transactionQueue[] = $xaction;
5024 return $this;
5025 }
5026
5027 private function flushTransactionQueue($object) {
5028 if (!$this->transactionQueue) {
5029 return;
5030 }
5031
5032 $xactions = $this->transactionQueue;
5033 $this->transactionQueue = array();
5034
5035 $editor = $this->newEditorCopy();
5036
5037 return $editor->applyTransactions($object, $xactions);
5038 }
5039
5040 final protected function newSubEditor(
5041 ?PhabricatorApplicationTransactionEditor $template = null) {
5042 $editor = $this->newEditorCopy($template);
5043
5044 $editor->parentEditor = $this;
5045 $this->subEditors[] = $editor;
5046
5047 return $editor;
5048 }
5049
5050 private function newEditorCopy(
5051 ?PhabricatorApplicationTransactionEditor $template = null) {
5052 if ($template === null) {
5053 $template = newv(get_class($this), array());
5054 }
5055
5056 $editor = id(clone $template)
5057 ->setActor($this->getActor())
5058 ->setContentSource($this->getContentSource())
5059 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
5060 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
5061 ->setParentMessageID($this->getParentMessageID())
5062 ->setIsSilent($this->getIsSilent());
5063
5064 if ($this->actingAsPHID !== null) {
5065 $editor->setActingAsPHID($this->actingAsPHID);
5066 }
5067
5068 $editor->mustEncrypt = $this->mustEncrypt;
5069 $editor->transactionGroupID = $this->getTransactionGroupID();
5070
5071 return $editor;
5072 }
5073
5074
5075/* -( Stamps )------------------------------------------------------------- */
5076
5077
5078 public function newMailStampTemplates($object) {
5079 $actor = $this->getActor();
5080
5081 $templates = array();
5082
5083 $extensions = $this->newMailExtensions($object);
5084 foreach ($extensions as $extension) {
5085 $stamps = $extension->newMailStampTemplates($object);
5086 foreach ($stamps as $stamp) {
5087 $key = $stamp->getKey();
5088 if (isset($templates[$key])) {
5089 throw new Exception(
5090 pht(
5091 'Mail extension ("%s") defines a stamp template with the '.
5092 'same key ("%s") as another template. Each stamp template '.
5093 'must have a unique key.',
5094 get_class($extension),
5095 $key));
5096 }
5097
5098 $stamp->setViewer($actor);
5099
5100 $templates[$key] = $stamp;
5101 }
5102 }
5103
5104 return $templates;
5105 }
5106
5107 final public function getMailStamp($key) {
5108 if (!isset($this->stampTemplates)) {
5109 throw new PhutilInvalidStateException('newMailStampTemplates');
5110 }
5111
5112 if (!isset($this->stampTemplates[$key])) {
5113 throw new Exception(
5114 pht(
5115 'Editor ("%s") has no mail stamp template with provided key ("%s").',
5116 get_class($this),
5117 $key));
5118 }
5119
5120 return $this->stampTemplates[$key];
5121 }
5122
5123 private function newMailStamps($object, array $xactions) {
5124 $actor = $this->getActor();
5125
5126 $this->stampTemplates = $this->newMailStampTemplates($object);
5127
5128 $extensions = $this->newMailExtensions($object);
5129 $stamps = array();
5130 foreach ($extensions as $extension) {
5131 $extension->newMailStamps($object, $xactions);
5132 }
5133
5134 return $this->stampTemplates;
5135 }
5136
5137 private function newMailExtensions($object) {
5138 $actor = $this->getActor();
5139
5140 $all_extensions = PhabricatorMailEngineExtension::getAllExtensions();
5141
5142 $extensions = array();
5143 foreach ($all_extensions as $key => $template) {
5144 $extension = id(clone $template)
5145 ->setViewer($actor)
5146 ->setEditor($this);
5147
5148 if ($extension->supportsObject($object)) {
5149 $extensions[$key] = $extension;
5150 }
5151 }
5152
5153 return $extensions;
5154 }
5155
5156 protected function newAuxiliaryMail($object, array $xactions) {
5157 return array();
5158 }
5159
5160 private function generateMailStamps($object, $data) {
5161 if (!$data || !is_array($data)) {
5162 return null;
5163 }
5164
5165 $templates = $this->newMailStampTemplates($object);
5166 foreach ($data as $spec) {
5167 if (!is_array($spec)) {
5168 continue;
5169 }
5170
5171 $key = idx($spec, 'key');
5172 if (!isset($templates[$key])) {
5173 continue;
5174 }
5175
5176 $type = idx($spec, 'type');
5177 if ($templates[$key]->getStampType() !== $type) {
5178 continue;
5179 }
5180
5181 $value = idx($spec, 'value');
5182 $templates[$key]->setValueFromDictionary($value);
5183 }
5184
5185 $results = array();
5186 foreach ($templates as $template) {
5187 $value = $template->getValueForRendering();
5188
5189 $rendered = $template->renderStamps($value);
5190 if ($rendered === null) {
5191 continue;
5192 }
5193
5194 $rendered = (array)$rendered;
5195 foreach ($rendered as $stamp) {
5196 $results[] = $stamp;
5197 }
5198 }
5199
5200 natcasesort($results);
5201
5202 return $results;
5203 }
5204
5205 public function getRemovedRecipientPHIDs() {
5206 return $this->mailRemovedPHIDs;
5207 }
5208
5209 private function buildOldRecipientLists($object, $xactions) {
5210 // See T4776. Before we start making any changes, build a list of the old
5211 // recipients. If a change removes a user from the recipient list for an
5212 // object we still want to notify the user about that change. This allows
5213 // them to respond if they didn't want to be removed.
5214
5215 if (!$this->shouldSendMail($object, $xactions)) {
5216 return;
5217 }
5218
5219 $this->oldTo = $this->getMailTo($object);
5220 $this->oldCC = $this->getMailCC($object);
5221
5222 return $this;
5223 }
5224
5225 private function applyOldRecipientLists() {
5226 $actor_phid = $this->getActingAsPHID();
5227
5228 // If you took yourself off the recipient list (for example, by
5229 // unsubscribing or resigning) assume that you know what you did and
5230 // don't need to be notified.
5231
5232 // If you just moved from "To" to "Cc" (or vice versa), you're still a
5233 // recipient so we don't need to add you back in.
5234
5235 $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs);
5236
5237 foreach ($this->oldTo as $phid) {
5238 if ($phid === $actor_phid) {
5239 continue;
5240 }
5241
5242 if (isset($map[$phid])) {
5243 continue;
5244 }
5245
5246 $this->mailToPHIDs[] = $phid;
5247 $this->mailRemovedPHIDs[] = $phid;
5248 }
5249
5250 foreach ($this->oldCC as $phid) {
5251 if ($phid === $actor_phid) {
5252 continue;
5253 }
5254
5255 if (isset($map[$phid])) {
5256 continue;
5257 }
5258
5259 $this->mailCCPHIDs[] = $phid;
5260 $this->mailRemovedPHIDs[] = $phid;
5261 }
5262
5263 return $this;
5264 }
5265
5266 private function queueWebhooks($object, array $xactions) {
5267 $hook_viewer = PhabricatorUser::getOmnipotentUser();
5268
5269 $webhook_map = $this->webhookMap;
5270 if (!is_array($webhook_map)) {
5271 $webhook_map = array();
5272 }
5273
5274 // Add any "Firehose" hooks to the list of hooks we're going to call.
5275 $firehose_hooks = id(new HeraldWebhookQuery())
5276 ->setViewer($hook_viewer)
5277 ->withStatuses(
5278 array(
5279 HeraldWebhook::HOOKSTATUS_FIREHOSE,
5280 ))
5281 ->execute();
5282 foreach ($firehose_hooks as $firehose_hook) {
5283 // This is "the hook itself is the reason this hook is being called",
5284 // since we're including it because it's configured as a firehose
5285 // hook.
5286 $hook_phid = $firehose_hook->getPHID();
5287 $webhook_map[$hook_phid][] = $hook_phid;
5288 }
5289
5290 if (!$webhook_map) {
5291 return;
5292 }
5293
5294 // NOTE: We're going to queue calls to disabled webhooks, they'll just
5295 // immediately fail in the worker queue. This makes the behavior more
5296 // visible.
5297
5298 $call_hooks = id(new HeraldWebhookQuery())
5299 ->setViewer($hook_viewer)
5300 ->withPHIDs(array_keys($webhook_map))
5301 ->execute();
5302
5303 foreach ($call_hooks as $call_hook) {
5304 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
5305
5306 $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook)
5307 ->setObjectPHID($object->getPHID())
5308 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
5309 ->setTriggerPHIDs($trigger_phids)
5310 ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER)
5311 ->setIsSilentAction((bool)$this->getIsSilent())
5312 ->setIsSecureAction((bool)$this->getMustEncrypt())
5313 ->save();
5314
5315 $request->queueCall();
5316 }
5317 }
5318
5319 /**
5320 * This can only apply to Differential Revisions which are drafts.
5321 *
5322 * @return bool
5323 */
5324 private function hasWarnings($object, $xaction) {
5325 // TODO: For the moment, this is a very un-modular hack to support
5326 // a small number of warnings related to draft revisions. See PHI433.
5327
5328 if (!($object instanceof DifferentialRevision)) {
5329 return false;
5330 }
5331
5332 $type = $xaction->getTransactionType();
5333
5334 // TODO: This doesn't warn for inlines in Audit, even though they have
5335 // the same overall workflow.
5336 if ($type === DifferentialTransaction::TYPE_INLINE) {
5337 return (bool)$xaction->getComment()->getAttribute('editing', false);
5338 }
5339
5340 if (!$object->isDraft()) {
5341 return false;
5342 }
5343
5344 if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5345 return false;
5346 }
5347
5348 // We're only going to raise a warning if the transaction adds subscribers
5349 // other than the acting user. (This implementation is clumsy because the
5350 // code runs before a lot of normalization occurs.)
5351
5352 $old = $this->getTransactionOldValue($object, $xaction);
5353 $new = $this->getPHIDTransactionNewValue($xaction, $old);
5354 $old = array_fuse($old);
5355 $new = array_fuse($new);
5356 $add = array_diff_key($new, $old);
5357
5358 unset($add[$this->getActingAsPHID()]);
5359
5360 if (!$add) {
5361 return false;
5362 }
5363
5364 return true;
5365 }
5366
5367 /**
5368 * Get an entire object's history (via the "!history" email command)
5369 */
5370 private function buildHistoryMail(PhabricatorLiskDAO $object) {
5371 $viewer = $this->requireActor();
5372 $recipient_phid = $this->getActingAsPHID();
5373
5374 // Load every transaction so we can build a mail message with a complete
5375 // history for the object.
5376 $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object);
5377 $xactions = $query
5378 ->setViewer($viewer)
5379 ->withObjectPHIDs(array($object->getPHID()))
5380 ->execute();
5381 $xactions = array_reverse($xactions);
5382
5383 $mail_messages = $this->buildMailWithRecipients(
5384 $object,
5385 $xactions,
5386 array($recipient_phid),
5387 array(),
5388 array());
5389 $mail = head($mail_messages);
5390
5391 // Since the user explicitly requested "!history", force delivery of this
5392 // message regardless of their other mail settings.
5393 $mail->setForceDelivery(true);
5394
5395 return $mail;
5396 }
5397
5398 public function newAutomaticInlineTransactions(
5399 PhabricatorLiskDAO $object,
5400 $transaction_type,
5401 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5402
5403 $actor = $this->getActor();
5404
5405 $inlines = id(clone $query_template)
5406 ->setViewer($actor)
5407 ->withObjectPHIDs(array($object->getPHID()))
5408 ->withPublishableComments(true)
5409 ->needAppliedDrafts(true)
5410 ->needReplyToComments(true)
5411 ->execute();
5412 $inlines = msort($inlines, 'getID');
5413
5414 $xactions = array();
5415
5416 foreach ($inlines as $key => $inline) {
5417 $xactions[] = $object->getApplicationTransactionTemplate()
5418 ->setTransactionType($transaction_type)
5419 ->attachComment($inline);
5420 }
5421
5422 $state_xaction = $this->newInlineStateTransaction(
5423 $object,
5424 $query_template);
5425
5426 if ($state_xaction) {
5427 $xactions[] = $state_xaction;
5428 }
5429
5430 return $xactions;
5431 }
5432
5433 protected function newInlineStateTransaction(
5434 PhabricatorLiskDAO $object,
5435 PhabricatorCursorPagedPolicyAwareQuery $query_template) {
5436
5437 $actor_phid = $this->getActingAsPHID();
5438 $author_phid = $object->getAuthorPHID();
5439 $actor_is_author = ($actor_phid == $author_phid);
5440
5441 $state_map = PhabricatorTransactions::getInlineStateMap();
5442
5443 $inline_query = id(clone $query_template)
5444 ->setViewer($this->getActor())
5445 ->withObjectPHIDs(array($object->getPHID()))
5446 ->withFixedStates(array_keys($state_map))
5447 ->withPublishableComments(true);
5448
5449 if ($actor_is_author) {
5450 $inline_query->withPublishedComments(true);
5451 }
5452
5453 $inlines = $inline_query->execute();
5454
5455 if (!$inlines) {
5456 return null;
5457 }
5458
5459 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5460 $new_value = array();
5461 foreach ($old_value as $key => $state) {
5462 $new_value[$key] = $state_map[$state];
5463 }
5464
5465 // See PHI995. Copy some information about the inlines into the transaction
5466 // so we can tailor rendering behavior. In particular, we don't want to
5467 // render transactions about users marking their own inlines as "Done".
5468
5469 $inline_details = array();
5470 foreach ($inlines as $inline) {
5471 $inline_details[$inline->getPHID()] = array(
5472 'authorPHID' => $inline->getAuthorPHID(),
5473 );
5474 }
5475
5476 return $object->getApplicationTransactionTemplate()
5477 ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE)
5478 ->setIgnoreOnNoEffect(true)
5479 ->setMetadataValue('inline.details', $inline_details)
5480 ->setOldValue($old_value)
5481 ->setNewValue($new_value);
5482 }
5483
5484 private function requireMFA(PhabricatorLiskDAO $object, array $xactions) {
5485 $actor = $this->getActor();
5486
5487 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5488 if ($actor->isOmnipotent()) {
5489 return;
5490 }
5491
5492 $editor_class = get_class($this);
5493
5494 $object_phid = $object->getPHID();
5495 if ($object_phid) {
5496 $workflow_key = sprintf(
5497 'editor(%s).phid(%s)',
5498 $editor_class,
5499 $object_phid);
5500 } else {
5501 $workflow_key = sprintf(
5502 'editor(%s).new()',
5503 $editor_class);
5504 }
5505
5506 $request = $this->getRequest();
5507 if ($request === null) {
5508 $source_type = $this->getContentSource()->getSourceTypeConstant();
5509 $conduit_type = PhabricatorConduitContentSource::SOURCECONST;
5510 $is_conduit = ($source_type === $conduit_type);
5511 if ($is_conduit) {
5512 throw new Exception(
5513 pht(
5514 'This transaction group requires MFA to apply, but you can not '.
5515 'provide an MFA response via Conduit. Edit this object via the '.
5516 'web UI.'));
5517 } else {
5518 throw new Exception(
5519 pht(
5520 'This transaction group requires MFA to apply, but the Editor was '.
5521 'not configured with a Request. This workflow can not perform an '.
5522 'MFA check.'));
5523 }
5524 }
5525
5526 $cancel_uri = $this->getCancelURI();
5527 if ($cancel_uri === null) {
5528 throw new Exception(
5529 pht(
5530 'This transaction group requires MFA to apply, but the Editor was '.
5531 'not configured with a Cancel URI. This workflow can not perform '.
5532 'an MFA check.'));
5533 }
5534
5535 $token = id(new PhabricatorAuthSessionEngine())
5536 ->setWorkflowKey($workflow_key)
5537 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5538
5539 if (!$token->getIsUnchallengedToken()) {
5540 foreach ($xactions as $xaction) {
5541 $xaction->setIsMFATransaction(true);
5542 }
5543 }
5544 }
5545
5546 private function newMFATransactions(
5547 PhabricatorLiskDAO $object,
5548 array $xactions) {
5549
5550 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface);
5551 if ($has_engine) {
5552 $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object)
5553 ->setViewer($this->getActor());
5554 $require_mfa = $engine->shouldRequireMFA();
5555 $try_mfa = $engine->shouldTryMFA();
5556 } else {
5557 $require_mfa = false;
5558 $try_mfa = false;
5559 }
5560
5561 // If the user is mentioning an MFA object on another object or creating
5562 // a relationship like "parent" or "child" to this object, we always
5563 // allow the edit to move forward without requiring MFA.
5564 if ($this->getIsInverseEdgeEditor()) {
5565 return $xactions;
5566 }
5567
5568 if (!$require_mfa) {
5569 // If the object hasn't already opted into MFA, see if any of the
5570 // transactions want it.
5571 if (!$try_mfa) {
5572 foreach ($xactions as $xaction) {
5573 $type = $xaction->getTransactionType();
5574
5575 $xtype = $this->getModularTransactionType($object, $type);
5576 if ($xtype) {
5577 $xtype = clone $xtype;
5578 $xtype->setStorage($xaction);
5579 if ($xtype->shouldTryMFA($object, $xaction)) {
5580 $try_mfa = true;
5581 break;
5582 }
5583 }
5584 }
5585 }
5586
5587 if ($try_mfa) {
5588 $this->setShouldRequireMFA(true);
5589 }
5590
5591 return $xactions;
5592 }
5593
5594 $type_mfa = PhabricatorTransactions::TYPE_MFA;
5595
5596 $has_mfa = false;
5597 foreach ($xactions as $xaction) {
5598 if ($xaction->getTransactionType() === $type_mfa) {
5599 $has_mfa = true;
5600 break;
5601 }
5602 }
5603
5604 if ($has_mfa) {
5605 return $xactions;
5606 }
5607
5608 $template = $object->getApplicationTransactionTemplate();
5609
5610 $mfa_xaction = id(clone $template)
5611 ->setTransactionType($type_mfa)
5612 ->setNewValue(true);
5613
5614 array_unshift($xactions, $mfa_xaction);
5615
5616 return $xactions;
5617 }
5618
5619 private function getTitleForTextMail(
5620 PhabricatorApplicationTransaction $xaction) {
5621 $type = $xaction->getTransactionType();
5622 $object = $this->object;
5623
5624 $xtype = $this->getModularTransactionType($object, $type);
5625 if ($xtype) {
5626 $xtype = clone $xtype;
5627 $xtype->setStorage($xaction);
5628 $comment = $xtype->getTitleForTextMail();
5629 if ($comment !== false) {
5630 return $comment;
5631 }
5632 }
5633
5634 return $xaction->getTitleForTextMail();
5635 }
5636
5637 private function getTitleForHTMLMail(
5638 PhabricatorApplicationTransaction $xaction) {
5639 $type = $xaction->getTransactionType();
5640 $object = $this->object;
5641
5642 $xtype = $this->getModularTransactionType($object, $type);
5643 if ($xtype) {
5644 $xtype = clone $xtype;
5645 $xtype->setStorage($xaction);
5646 $comment = $xtype->getTitleForHTMLMail();
5647 if ($comment !== false) {
5648 return $comment;
5649 }
5650 }
5651
5652 return $xaction->getTitleForHTMLMail();
5653 }
5654
5655
5656 private function getBodyForTextMail(
5657 PhabricatorApplicationTransaction $xaction) {
5658 $type = $xaction->getTransactionType();
5659 $object = $this->object;
5660
5661 $xtype = $this->getModularTransactionType($object, $type);
5662 if ($xtype) {
5663 $xtype = clone $xtype;
5664 $xtype->setStorage($xaction);
5665 $comment = $xtype->getBodyForTextMail();
5666 if ($comment !== false) {
5667 return $comment;
5668 }
5669 }
5670
5671 return $xaction->getBodyForMail();
5672 }
5673
5674 private function isLockOverrideTransaction(
5675 PhabricatorApplicationTransaction $xaction) {
5676
5677 // See PHI1209. When an object is locked, certain types of transactions
5678 // can still be applied without requiring a policy check, like subscribing
5679 // or unsubscribing. We don't want these transactions to show the "Lock
5680 // Override" icon in the transaction timeline.
5681
5682 // We could test if a transaction did no direct policy checks, but it may
5683 // have done additional policy checks during validation, so this is not a
5684 // reliable test (and could cause false negatives, where edits which did
5685 // override a lock are not marked properly).
5686
5687 // For now, do this in a narrow way and just check against a hard-coded
5688 // list of non-override transaction situations. Some day, this should
5689 // likely be modularized.
5690
5691
5692 // Inverse edge edits don't interact with locks.
5693 if ($this->getIsInverseEdgeEditor()) {
5694 return false;
5695 }
5696
5697 // For now, all edits other than subscribes always override locks.
5698 $type = $xaction->getTransactionType();
5699 if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) {
5700 return true;
5701 }
5702
5703 // Subscribes override locks if they affect any users other than the
5704 // acting user.
5705
5706 $acting_phid = $this->getActingAsPHID();
5707
5708 $old = array_fuse($xaction->getOldValue());
5709 $new = array_fuse($xaction->getNewValue());
5710 $add = array_diff_key($new, $old);
5711 $rem = array_diff_key($old, $new);
5712
5713 $all = $add + $rem;
5714 foreach ($all as $phid) {
5715 if ($phid !== $acting_phid) {
5716 return true;
5717 }
5718 }
5719
5720 return false;
5721 }
5722
5723
5724/* -( Extensions )--------------------------------------------------------- */
5725
5726
5727 private function validateTransactionsWithExtensions(
5728 PhabricatorLiskDAO $object,
5729 array $xactions) {
5730 $errors = array();
5731
5732 $extensions = $this->getEditorExtensions();
5733 foreach ($extensions as $extension) {
5734 $extension_errors = $extension
5735 ->setObject($object)
5736 ->validateTransactions($object, $xactions);
5737
5738 assert_instances_of(
5739 $extension_errors,
5740 PhabricatorApplicationTransactionValidationError::class);
5741
5742 $errors[] = $extension_errors;
5743 }
5744
5745 return array_mergev($errors);
5746 }
5747
5748 private function getEditorExtensions() {
5749 if ($this->extensions === null) {
5750 $this->extensions = $this->newEditorExtensions();
5751 }
5752 return $this->extensions;
5753 }
5754
5755 private function newEditorExtensions() {
5756 $extensions = PhabricatorEditorExtension::getAllExtensions();
5757
5758 $actor = $this->getActor();
5759 $object = $this->object;
5760 foreach ($extensions as $key => $extension) {
5761
5762 $extension = id(clone $extension)
5763 ->setViewer($actor)
5764 ->setEditor($this)
5765 ->setObject($object);
5766
5767 if (!$extension->supportsObject($this, $object)) {
5768 unset($extensions[$key]);
5769 continue;
5770 }
5771
5772 $extensions[$key] = $extension;
5773 }
5774
5775 return $extensions;
5776 }
5777
5778
5779}