@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
fork

Configure Feed

Select the types of activity you want to include in your feed.

at recaptime-dev/main 5779 lines 173 kB view raw
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}