diff --git a/src/applications/calendar/storage/PhabricatorCalendarEvent.php b/src/applications/calendar/storage/PhabricatorCalendarEvent.php index e566a77b76..3a09d2e021 100644 --- a/src/applications/calendar/storage/PhabricatorCalendarEvent.php +++ b/src/applications/calendar/storage/PhabricatorCalendarEvent.php @@ -1,1446 +1,1450 @@ setViewer($actor) ->withClasses(array('PhabricatorCalendarApplication')) ->executeOne(); $view_default = PhabricatorCalendarEventDefaultViewCapability::CAPABILITY; $edit_default = PhabricatorCalendarEventDefaultEditCapability::CAPABILITY; $view_policy = $app->getPolicy($view_default); $edit_policy = $app->getPolicy($edit_default); $now = PhabricatorTime::getNow(); $default_icon = 'fa-calendar'; $datetime_defaults = self::newDefaultEventDateTimes( $actor, $now); list($datetime_start, $datetime_end) = $datetime_defaults; // When importing events from a context like "bin/calendar reload", we may // be acting as the omnipotent user. $host_phid = $actor->getPHID(); if (!$host_phid) { $host_phid = $app->getPHID(); } return id(new PhabricatorCalendarEvent()) ->setDescription('') ->setHostPHID($host_phid) ->setIsCancelled(0) ->setIsAllDay(0) ->setIsStub(0) ->setIsRecurring(0) ->setIcon($default_icon) ->setViewPolicy($view_policy) ->setEditPolicy($edit_policy) ->setSpacePHID($actor->getDefaultSpacePHID()) ->attachInvitees(array()) ->setStartDateTime($datetime_start) ->setEndDateTime($datetime_end) ->attachImportSource(null) ->applyViewerTimezone($actor); } public static function newDefaultEventDateTimes( PhabricatorUser $viewer, $now) { $datetime_start = PhutilCalendarAbsoluteDateTime::newFromEpoch( $now, $viewer->getTimezoneIdentifier()); // Advance the time by an hour, then round downwards to the nearest hour. // For example, if it is currently 3:25 PM, we suggest a default start time // of 4 PM. $datetime_start = $datetime_start ->newRelativeDateTime('PT1H') ->newAbsoluteDateTime(); $datetime_start->setMinute(0); $datetime_start->setSecond(0); // Default the end time to an hour after the start time. $datetime_end = $datetime_start ->newRelativeDateTime('PT1H') ->newAbsoluteDateTime(); return array($datetime_start, $datetime_end); } private function newChild( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { if (!$this->isParentEvent()) { throw new Exception( pht( 'Unable to generate a new child event for an event which is not '. 'a recurring parent event!')); } $series_phid = $this->getSeriesParentPHID(); if (!$series_phid) { $series_phid = $this->getPHID(); } $child = id(new self()) ->setIsCancelled(0) ->setIsStub(0) ->setInstanceOfEventPHID($this->getPHID()) ->setSeriesParentPHID($series_phid) ->setSequenceIndex($sequence) ->setIsRecurring(true) ->attachParentEvent($this) ->attachImportSource(null); return $child->copyFromParent($actor, $start); } protected function readField($field) { static $inherit = array( 'hostPHID' => true, 'isAllDay' => true, 'icon' => true, 'spacePHID' => true, 'viewPolicy' => true, 'editPolicy' => true, 'name' => true, 'description' => true, 'isCancelled' => true, ); // Read these fields from the parent event instead of this event. For // example, we want any changes to the parent event's name to apply to // the child. if (isset($inherit[$field])) { if ($this->getIsStub()) { // TODO: This should be unconditional, but the execution order of // CalendarEventQuery and applyViewerTimezone() are currently odd. if ($this->parentEvent !== self::ATTACHABLE) { return $this->getParentEvent()->readField($field); } } } return parent::readField($field); } public function copyFromParent( PhabricatorUser $actor, PhutilCalendarDateTime $start = null) { if (!$this->isChildEvent()) { throw new Exception( pht( 'Unable to copy from parent event: this is not a child event.')); } $parent = $this->getParentEvent(); $this ->setHostPHID($parent->getHostPHID()) ->setIsAllDay($parent->getIsAllDay()) ->setIcon($parent->getIcon()) ->setSpacePHID($parent->getSpacePHID()) ->setViewPolicy($parent->getViewPolicy()) ->setEditPolicy($parent->getEditPolicy()) ->setName($parent->getName()) ->setDescription($parent->getDescription()) ->setIsCancelled($parent->getIsCancelled()); if ($start) { $start_datetime = $start; } else { $sequence = $this->getSequenceIndex(); $start_datetime = $parent->newSequenceIndexDateTime($sequence); if (!$start_datetime) { throw new Exception( pht( 'Sequence "%s" is not valid for event!', $sequence)); } } $duration = $parent->newDuration(); $end_datetime = $start_datetime->newRelativeDateTime($duration); $this ->setStartDateTime($start_datetime) ->setEndDateTime($end_datetime); if ($parent->isImportedEvent()) { $full_uid = $parent->getImportUID().'/'.$start_datetime->getEpoch(); // NOTE: We don't attach the import source because this gets called // from CalendarEventQuery while building ghosts, before we've loaded // and attached sources. Possibly this sequence should be flipped. $this ->setImportAuthorPHID($parent->getImportAuthorPHID()) ->setImportSourcePHID($parent->getImportSourcePHID()) ->setImportUID($full_uid); } return $this; } public function isValidSequenceIndex(PhabricatorUser $viewer, $sequence) { return (bool)$this->newSequenceIndexDateTime($sequence); } public function newSequenceIndexDateTime($sequence) { $set = $this->newRecurrenceSet(); if (!$set) { return null; } $limit = $sequence + 1; $count = $this->getRecurrenceCount(); if ($count && ($count < $limit)) { return null; } $instances = $set->getEventsBetween( null, $this->newUntilDateTime(), $limit); return idx($instances, $sequence, null); } public function newStub(PhabricatorUser $actor, $sequence) { $stub = $this->newChild($actor, $sequence); $stub->setIsStub(1); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $stub->save(); unset($unguarded); $stub->applyViewerTimezone($actor); return $stub; } public function newGhost( PhabricatorUser $actor, $sequence, PhutilCalendarDateTime $start = null) { $ghost = $this->newChild($actor, $sequence, $start); $ghost ->setIsGhostEvent(true) ->makeEphemeral(); $ghost->applyViewerTimezone($actor); return $ghost; } public function applyViewerTimezone(PhabricatorUser $viewer) { $this->viewerTimezone = $viewer->getTimezoneIdentifier(); return $this; } public function getDuration() { return ($this->getEndDateTimeEpoch() - $this->getStartDateTimeEpoch()); } public function updateUTCEpochs() { // The "intitial" epoch is the start time of the event, in UTC. $start_date = $this->newStartDateTime() ->setViewerTimezone('UTC'); $start_epoch = $start_date->getEpoch(); $this->setUTCInitialEpoch($start_epoch); // The "until" epoch is the last UTC epoch on which any instance of this // event occurs. For infinitely recurring events, it is `null`. if (!$this->getIsRecurring()) { $end_date = $this->newEndDateTime() ->setViewerTimezone('UTC'); $until_epoch = $end_date->getEpoch(); } else { $until_epoch = null; $until_date = $this->newUntilDateTime(); if ($until_date) { $until_date->setViewerTimezone('UTC'); $duration = $this->newDuration(); $until_epoch = id(new PhutilCalendarRelativeDateTime()) ->setOrigin($until_date) ->setDuration($duration) ->getEpoch(); } } $this->setUTCUntilEpoch($until_epoch); // The "instance" epoch is a property of instances of recurring events. // It's the original UTC epoch on which the instance started. Usually that // is the same as the start date, but they may be different if the instance // has been edited. // The ICS format uses this value (original start time) to identify event // instances, and must do so because it allows additional arbitrary // instances to be added (with "RDATE"). $instance_epoch = null; $instance_date = $this->newInstanceDateTime(); if ($instance_date) { $instance_epoch = $instance_date ->setViewerTimezone('UTC') ->getEpoch(); } $this->setUTCInstanceEpoch($instance_epoch); return $this; } public function save() { if (!$this->mailKey) { $this->mailKey = Filesystem::readRandomCharacters(20); } $import_uid = $this->getImportUID(); if ($import_uid !== null) { $index = PhabricatorHash::digestForIndex($import_uid); } else { $index = null; } $this->setImportUIDIndex($index); $this->updateUTCEpochs(); return parent::save(); } /** * Get the event start epoch for evaluating invitee availability. * * When assessing availability, we pretend events start earlier than they * really do. This allows us to mark users away for the entire duration of a * series of back-to-back meetings, even if they don't strictly overlap. * * @return int Event start date for availability caches. */ public function getStartDateTimeEpochForCache() { $epoch = $this->getStartDateTimeEpoch(); $window = phutil_units('15 minutes in seconds'); return ($epoch - $window); } + public function getEndDateTimeEpochForCache() { + return $this->getEndDateTimeEpoch(); + } + protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'text', 'description' => 'text', 'isCancelled' => 'bool', 'isAllDay' => 'bool', 'icon' => 'text32', 'mailKey' => 'bytes20', 'isRecurring' => 'bool', 'seriesParentPHID' => 'phid?', 'instanceOfEventPHID' => 'phid?', 'sequenceIndex' => 'uint32?', 'isStub' => 'bool', 'utcInitialEpoch' => 'epoch', 'utcUntilEpoch' => 'epoch?', 'utcInstanceEpoch' => 'epoch?', 'importAuthorPHID' => 'phid?', 'importSourcePHID' => 'phid?', 'importUIDIndex' => 'bytes12?', 'importUID' => 'text?', ), self::CONFIG_KEY_SCHEMA => array( 'key_instance' => array( 'columns' => array('instanceOfEventPHID', 'sequenceIndex'), 'unique' => true, ), 'key_epoch' => array( 'columns' => array('utcInitialEpoch', 'utcUntilEpoch'), ), 'key_rdate' => array( 'columns' => array('instanceOfEventPHID', 'utcInstanceEpoch'), 'unique' => true, ), 'key_series' => array( 'columns' => array('seriesParentPHID', 'utcInitialEpoch'), ), ), self::CONFIG_SERIALIZATION => array( 'parameters' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorCalendarEventPHIDType::TYPECONST); } public function getMonogram() { return 'E'.$this->getID(); } public function getInvitees() { if ($this->getIsGhostEvent() || $this->getIsStub()) { if ($this->stubInvitees === null) { $this->stubInvitees = $this->newStubInvitees(); } return $this->stubInvitees; } return $this->assertAttached($this->invitees); } public function getInviteeForPHID($phid) { $invitees = $this->getInvitees(); $invitees = mpull($invitees, null, 'getInviteePHID'); return idx($invitees, $phid); } public static function getFrequencyMap() { return array( PhutilCalendarRecurrenceRule::FREQUENCY_DAILY => array( 'label' => pht('Daily'), ), PhutilCalendarRecurrenceRule::FREQUENCY_WEEKLY => array( 'label' => pht('Weekly'), ), PhutilCalendarRecurrenceRule::FREQUENCY_MONTHLY => array( 'label' => pht('Monthly'), ), PhutilCalendarRecurrenceRule::FREQUENCY_YEARLY => array( 'label' => pht('Yearly'), ), ); } private function newStubInvitees() { $parent = $this->getParentEvent(); $parent_invitees = $parent->getInvitees(); $stub_invitees = array(); foreach ($parent_invitees as $invitee) { $stub_invitee = id(new PhabricatorCalendarEventInvitee()) ->setInviteePHID($invitee->getInviteePHID()) ->setInviterPHID($invitee->getInviterPHID()) ->setStatus(PhabricatorCalendarEventInvitee::STATUS_INVITED); $stub_invitees[] = $stub_invitee; } return $stub_invitees; } public function attachInvitees(array $invitees) { $this->invitees = $invitees; return $this; } public function getInviteePHIDsForEdit() { $invitees = array(); foreach ($this->getInvitees() as $invitee) { if ($invitee->isUninvited()) { continue; } $invitees[] = $invitee->getInviteePHID(); } return $invitees; } public function getUserInviteStatus($phid) { $invitees = $this->getInvitees(); $invitees = mpull($invitees, null, 'getInviteePHID'); $invited = idx($invitees, $phid); if (!$invited) { return PhabricatorCalendarEventInvitee::STATUS_UNINVITED; } $invited = $invited->getStatus(); return $invited; } public function getIsUserAttending($phid) { $attending_status = PhabricatorCalendarEventInvitee::STATUS_ATTENDING; $old_status = $this->getUserInviteStatus($phid); $is_attending = ($old_status == $attending_status); return $is_attending; } public function getIsGhostEvent() { return $this->isGhostEvent; } public function setIsGhostEvent($is_ghost_event) { $this->isGhostEvent = $is_ghost_event; return $this; } public function getURI() { if ($this->getIsGhostEvent()) { $base = $this->getParentEvent()->getURI(); $sequence = $this->getSequenceIndex(); return "{$base}/{$sequence}/"; } return '/'.$this->getMonogram(); } public function getParentEvent() { return $this->assertAttached($this->parentEvent); } public function attachParentEvent(PhabricatorCalendarEvent $event = null) { $this->parentEvent = $event; return $this; } public function isParentEvent() { return ($this->getIsRecurring() && !$this->getInstanceOfEventPHID()); } public function isChildEvent() { return ($this->instanceOfEventPHID !== null); } public function renderEventDate( PhabricatorUser $viewer, $show_end) { $start = $this->newStartDateTime(); $end = $this->newEndDateTime(); $min_date = $start->newPHPDateTime(); $max_date = $end->newPHPDateTime(); if ($this->getIsAllDay()) { // Subtract one second since the stored date is exclusive. $max_date = $max_date->modify('-1 second'); } if ($show_end) { $min_day = $min_date->format('Y m d'); $max_day = $max_date->format('Y m d'); $show_end_date = ($min_day != $max_day); } else { $show_end_date = false; } $min_epoch = $min_date->format('U'); $max_epoch = $max_date->format('U'); if ($this->getIsAllDay()) { if ($show_end_date) { return pht( '%s - %s, All Day', phabricator_date($min_epoch, $viewer), phabricator_date($max_epoch, $viewer)); } else { return pht( '%s, All Day', phabricator_date($min_epoch, $viewer)); } } else if ($show_end_date) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_datetime($max_epoch, $viewer)); } else if ($show_end) { return pht( '%s - %s', phabricator_datetime($min_epoch, $viewer), phabricator_time($max_epoch, $viewer)); } else { return pht( '%s', phabricator_datetime($min_epoch, $viewer)); } } public function getDisplayIcon(PhabricatorUser $viewer) { if ($this->getIsCancelled()) { return 'fa-times'; } if ($viewer->isLoggedIn()) { $viewer_phid = $viewer->getPHID(); if ($this->isRSVPInvited($viewer_phid)) { return 'fa-users'; } else { $status = $this->getUserInviteStatus($viewer_phid); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'fa-check-circle'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'fa-user-plus'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'fa-times-circle'; } } } if ($this->isImportedEvent()) { return 'fa-download'; } return $this->getIcon(); } public function getDisplayIconColor(PhabricatorUser $viewer) { if ($this->getIsCancelled()) { return 'red'; } if ($this->isImportedEvent()) { return 'orange'; } if ($viewer->isLoggedIn()) { $viewer_phid = $viewer->getPHID(); if ($this->isRSVPInvited($viewer_phid)) { return 'green'; } $status = $this->getUserInviteStatus($viewer_phid); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_INVITED: return 'green'; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return 'grey'; } } return 'bluegrey'; } public function getDisplayIconLabel(PhabricatorUser $viewer) { if ($this->getIsCancelled()) { return pht('Cancelled'); } if ($viewer->isLoggedIn()) { $status = $this->getUserInviteStatus($viewer->getPHID()); switch ($status) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: return pht('Attending'); case PhabricatorCalendarEventInvitee::STATUS_INVITED: return pht('Invited'); case PhabricatorCalendarEventInvitee::STATUS_DECLINED: return pht('Declined'); } } return null; } public function getICSFilename() { return $this->getMonogram().'.ics'; } public function newIntermediateEventNode( PhabricatorUser $viewer, array $children) { $base_uri = new PhutilURI(PhabricatorEnv::getProductionURI('/')); $domain = $base_uri->getDomain(); // NOTE: For recurring events, all of the events in the series have the // same UID (the UID of the parent). The child event instances are // differentiated by the "RECURRENCE-ID" field. if ($this->isChildEvent()) { $parent = $this->getParentEvent(); $instance_datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch( $this->getUTCInstanceEpoch()); $recurrence_id = $instance_datetime->getISO8601(); $rrule = null; } else { $parent = $this; $recurrence_id = null; $rrule = $this->newRecurrenceRule(); } $uid = $parent->getPHID().'@'.$domain; $created = $this->getDateCreated(); $created = PhutilCalendarAbsoluteDateTime::newFromEpoch($created); $modified = $this->getDateModified(); $modified = PhutilCalendarAbsoluteDateTime::newFromEpoch($modified); $date_start = $this->newStartDateTime(); $date_end = $this->newEndDateTime(); if ($this->getIsAllDay()) { $date_start->setIsAllDay(true); $date_end->setIsAllDay(true); } $host_phid = $this->getHostPHID(); $invitees = $this->getInvitees(); foreach ($invitees as $key => $invitee) { if ($invitee->isUninvited()) { unset($invitees[$key]); } } $phids = array(); $phids[] = $host_phid; foreach ($invitees as $invitee) { $phids[] = $invitee->getInviteePHID(); } $handles = $viewer->loadHandles($phids); $host_handle = $handles[$host_phid]; $host_name = $host_handle->getFullName(); // NOTE: Gmail shows "Who: Unknown Organizer*" if the organizer URI does // not look like an email address. Use a synthetic address so it shows // the host name instead. $install_uri = PhabricatorEnv::getProductionURI('/'); $install_uri = new PhutilURI($install_uri); // This should possibly use "metamta.reply-handler-domain" instead, but // we do not currently accept mail for users anyway, and that option may // not be configured. $mail_domain = $install_uri->getDomain(); $host_uri = "mailto:{$host_phid}@{$mail_domain}"; $organizer = id(new PhutilCalendarUserNode()) ->setName($host_name) ->setURI($host_uri); $attendees = array(); foreach ($invitees as $invitee) { $invitee_phid = $invitee->getInviteePHID(); $invitee_handle = $handles[$invitee_phid]; $invitee_name = $invitee_handle->getFullName(); $invitee_uri = $invitee_handle->getURI(); $invitee_uri = PhabricatorEnv::getURI($invitee_uri); switch ($invitee->getStatus()) { case PhabricatorCalendarEventInvitee::STATUS_ATTENDING: $status = PhutilCalendarUserNode::STATUS_ACCEPTED; break; case PhabricatorCalendarEventInvitee::STATUS_DECLINED: $status = PhutilCalendarUserNode::STATUS_DECLINED; break; case PhabricatorCalendarEventInvitee::STATUS_INVITED: default: $status = PhutilCalendarUserNode::STATUS_INVITED; break; } $attendees[] = id(new PhutilCalendarUserNode()) ->setName($invitee_name) ->setURI($invitee_uri) ->setStatus($status); } // TODO: Use $children to generate EXDATE/RDATE information. $node = id(new PhutilCalendarEventNode()) ->setUID($uid) ->setName($this->getName()) ->setDescription($this->getDescription()) ->setCreatedDateTime($created) ->setModifiedDateTime($modified) ->setStartDateTime($date_start) ->setEndDateTime($date_end) ->setOrganizer($organizer) ->setAttendees($attendees); if ($rrule) { $node->setRecurrenceRule($rrule); } if ($recurrence_id) { $node->setRecurrenceID($recurrence_id); } return $node; } public function newStartDateTime() { $datetime = $this->getParameter('startDateTime'); return $this->newDateTimeFromDictionary($datetime); } public function getStartDateTimeEpoch() { return $this->newStartDateTime()->getEpoch(); } public function newEndDateTimeForEdit() { $datetime = $this->getParameter('endDateTime'); return $this->newDateTimeFromDictionary($datetime); } public function newEndDateTime() { $datetime = $this->newEndDateTimeForEdit(); // If this is an all day event, we move the end date time forward to the // first second of the following day. This is consistent with what users // expect: an all day event from "Nov 1" to "Nov 1" lasts the entire day. // For imported events, the end date is already stored with this // adjustment. if ($this->getIsAllDay() && !$this->isImportedEvent()) { $datetime = $datetime ->newAbsoluteDateTime() ->setHour(0) ->setMinute(0) ->setSecond(0) ->newRelativeDateTime('P1D') ->newAbsoluteDateTime(); } return $datetime; } public function getEndDateTimeEpoch() { return $this->newEndDateTime()->getEpoch(); } public function newUntilDateTime() { $datetime = $this->getParameter('untilDateTime'); if ($datetime) { return $this->newDateTimeFromDictionary($datetime); } return null; } public function getUntilDateTimeEpoch() { $datetime = $this->newUntilDateTime(); if (!$datetime) { return null; } return $datetime->getEpoch(); } public function newDuration() { return id(new PhutilCalendarDuration()) ->setSeconds($this->getDuration()); } public function newInstanceDateTime() { if (!$this->getIsRecurring()) { return null; } $index = $this->getSequenceIndex(); if (!$index) { return null; } return $this->newSequenceIndexDateTime($index); } private function newDateTimeFromEpoch($epoch) { $datetime = PhutilCalendarAbsoluteDateTime::newFromEpoch($epoch); if ($this->getIsAllDay()) { $datetime->setIsAllDay(true); } return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDictionary(array $dict) { $datetime = PhutilCalendarAbsoluteDateTime::newFromDictionary($dict); return $this->newDateTimeFromDateTime($datetime); } private function newDateTimeFromDateTime(PhutilCalendarDateTime $datetime) { $viewer_timezone = $this->viewerTimezone; if ($viewer_timezone) { $datetime->setViewerTimezone($viewer_timezone); } return $datetime; } public function getParameter($key, $default = null) { return idx($this->parameters, $key, $default); } public function setParameter($key, $value) { $this->parameters[$key] = $value; return $this; } public function setStartDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'startDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setEndDateTime(PhutilCalendarDateTime $datetime) { return $this->setParameter( 'endDateTime', $datetime->newAbsoluteDateTime()->toDictionary()); } public function setUntilDateTime(PhutilCalendarDateTime $datetime = null) { if ($datetime) { $value = $datetime->newAbsoluteDateTime()->toDictionary(); } else { $value = null; } return $this->setParameter('untilDateTime', $value); } public function setRecurrenceRule(PhutilCalendarRecurrenceRule $rrule) { return $this->setParameter( 'recurrenceRule', $rrule->toDictionary()); } public function newRecurrenceRule() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceRule(); } if (!$this->getIsRecurring()) { return null; } $dict = $this->getParameter('recurrenceRule'); if (!$dict) { return null; } $rrule = PhutilCalendarRecurrenceRule::newFromDictionary($dict); $start = $this->newStartDateTime(); $rrule->setStartDateTime($start); $until = $this->newUntilDateTime(); if ($until) { $rrule->setUntil($until); } $count = $this->getRecurrenceCount(); if ($count) { $rrule->setCount($count); } return $rrule; } public function getRecurrenceCount() { $count = (int)$this->getParameter('recurrenceCount'); if (!$count) { return null; } return $count; } public function newRecurrenceSet() { if ($this->isChildEvent()) { return $this->getParentEvent()->newRecurrenceSet(); } $set = new PhutilCalendarRecurrenceSet(); if ($this->viewerTimezone) { $set->setViewerTimezone($this->viewerTimezone); } $rrule = $this->newRecurrenceRule(); if (!$rrule) { return null; } $set->addSource($rrule); return $set; } public function isImportedEvent() { return (bool)$this->getImportSourcePHID(); } public function getImportSource() { return $this->assertAttached($this->importSource); } public function attachImportSource( PhabricatorCalendarImport $import = null) { $this->importSource = $import; return $this; } public function loadForkTarget(PhabricatorUser $viewer) { if (!$this->getIsRecurring()) { // Can't fork an event which isn't recurring. return null; } if ($this->isChildEvent()) { // If this is a child event, this is the fork target. return $this; } if (!$this->isValidSequenceIndex($viewer, 1)) { // This appears to be a "recurring" event with no valid instances: for // example, its "until" date is before the second instance would occur. // This can happen if we already forked the event or if users entered // silly stuff. Just edit the event directly without forking anything. return null; } $next_event = id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withInstanceSequencePairs( array( array($this->getPHID(), 1), )) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$next_event) { $next_event = $this->newStub($viewer, 1); } return $next_event; } public function loadFutureEvents(PhabricatorUser $viewer) { // NOTE: If you can't edit some of the future events, we just // don't try to update them. This seems like it's probably what // users are likely to expect. // NOTE: This only affects events that are currently in the same // series, not all events that were ever in the original series. // We could use series PHIDs instead of parent PHIDs to affect more // events if this turns out to be counterintuitive. Other // applications differ in their behavior. return id(new PhabricatorCalendarEventQuery()) ->setViewer($viewer) ->withParentEventPHIDs(array($this->getPHID())) ->withUTCInitialEpochBetween($this->getUTCInitialEpoch(), null) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->execute(); } public function getNotificationPHIDs() { $phids = array(); if ($this->getPHID()) { $phids[] = $this->getPHID(); } if ($this->getSeriesParentPHID()) { $phids[] = $this->getSeriesParentPHID(); } return $phids; } public function getRSVPs($phid) { return $this->assertAttachedKey($this->rsvps, $phid); } public function attachRSVPs(array $rsvps) { $this->rsvps = $rsvps; return $this; } public function isRSVPInvited($phid) { $status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED; return ($this->getRSVPStatus($phid) == $status_invited); } public function hasRSVPAuthority($phid, $other_phid) { foreach ($this->getRSVPs($phid) as $rsvp) { if ($rsvp->getInviteePHID() == $other_phid) { return true; } } return false; } public function getRSVPStatus($phid) { // Check for an individual invitee record first. $invitees = $this->invitees; $invitees = mpull($invitees, null, 'getInviteePHID'); $invitee = idx($invitees, $phid); if ($invitee) { return $invitee->getStatus(); } // If we don't have one, try to find an invited status for the user's // projects. $status_invited = PhabricatorCalendarEventInvitee::STATUS_INVITED; foreach ($this->getRSVPs($phid) as $rsvp) { if ($rsvp->getStatus() == $status_invited) { return $status_invited; } } return PhabricatorCalendarEventInvitee::STATUS_UNINVITED; } /* -( Markup Interface )--------------------------------------------------- */ /** * @task markup */ public function getMarkupFieldKey($field) { $content = $this->getMarkupText($field); return PhabricatorMarkupEngine::digestRemarkupContent($this, $content); } /** * @task markup */ public function getMarkupText($field) { return $this->getDescription(); } /** * @task markup */ public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newCalendarMarkupEngine(); } /** * @task markup */ public function didMarkupText( $field, $output, PhutilMarkupEngine $engine) { return $output; } /** * @task markup */ public function shouldUseMarkupCache($field) { return (bool)$this->getID(); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: if ($this->isImportedEvent()) { return PhabricatorPolicies::POLICY_NOONE; } else { return $this->getEditPolicy(); } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { if ($this->isImportedEvent()) { return false; } // The host of an event can always view and edit it. $user_phid = $this->getHostPHID(); if ($user_phid) { $viewer_phid = $viewer->getPHID(); if ($viewer_phid == $user_phid) { return true; } } if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { $status = $this->getUserInviteStatus($viewer->getPHID()); if ($status == PhabricatorCalendarEventInvitee::STATUS_INVITED || $status == PhabricatorCalendarEventInvitee::STATUS_ATTENDING || $status == PhabricatorCalendarEventInvitee::STATUS_DECLINED) { return true; } } return false; } /* -( PhabricatorExtendedPolicyInterface )--------------------------------- */ public function getExtendedPolicy($capability, PhabricatorUser $viewer) { $extended = array(); switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: $import_source = $this->getImportSource(); if ($import_source) { $extended[] = array( $import_source, PhabricatorPolicyCapability::CAN_VIEW, ); } break; } return $extended; } /* -( PhabricatorPolicyCodexInterface )------------------------------------ */ public function newPolicyCodex() { return new PhabricatorCalendarEventPolicyCodex(); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorCalendarEventEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorCalendarEventTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return ($phid == $this->getHostPHID()); } /* -( PhabricatorTokenReceiverInterface )---------------------------------- */ public function getUsersToNotifyOfTokenGiven() { return array($this->getHostPHID()); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $invitees = id(new PhabricatorCalendarEventInvitee())->loadAllWhere( 'eventPHID = %s', $this->getPHID()); foreach ($invitees as $invitee) { $invitee->delete(); } $notifications = id(new PhabricatorCalendarNotification())->loadAllWhere( 'eventPHID = %s', $this->getPHID()); foreach ($notifications as $notification) { $notification->delete(); } $this->delete(); $this->saveTransaction(); } /* -( PhabricatorSpacesInterface )----------------------------------------- */ public function getSpacePHID() { return $this->spacePHID; } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorCalendarEventFulltextEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('name') ->setType('string') ->setDescription(pht('The name of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('description') ->setType('string') ->setDescription(pht('The event description.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('isAllDay') ->setType('bool') ->setDescription(pht('True if the event is an all day event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('startDateTime') ->setType('datetime') ->setDescription(pht('Start date and time of the event.')), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('endDateTime') ->setType('datetime') ->setDescription(pht('End date and time of the event.')), ); } public function getFieldValuesForConduit() { $start_datetime = $this->newStartDateTime(); $end_datetime = $this->newEndDateTime(); return array( 'name' => $this->getName(), 'description' => $this->getDescription(), 'isAllDay' => (bool)$this->getIsAllDay(), 'startDateTime' => $this->getConduitDateTime($start_datetime), 'endDateTime' => $this->getConduitDateTime($end_datetime), ); } public function getConduitSearchAttachments() { return array(); } private function getConduitDateTime($datetime) { if (!$datetime) { return null; } $epoch = $datetime->getEpoch(); // TODO: Possibly pass the actual viewer in from the Conduit stuff, or // retain it when setting the viewer timezone? $viewer = id(new PhabricatorUser()) ->overrideTimezoneIdentifier($this->viewerTimezone); return array( 'epoch' => (int)$epoch, 'display' => array( 'default' => phabricator_datetime($epoch, $viewer), ), 'iso8601' => $datetime->getISO8601(), 'timezone' => $this->viewerTimezone, ); } } diff --git a/src/applications/people/query/PhabricatorPeopleQuery.php b/src/applications/people/query/PhabricatorPeopleQuery.php index 7367382e50..e756a9a4fd 100644 --- a/src/applications/people/query/PhabricatorPeopleQuery.php +++ b/src/applications/people/query/PhabricatorPeopleQuery.php @@ -1,642 +1,642 @@ ids = $ids; return $this; } public function withPHIDs(array $phids) { $this->phids = $phids; return $this; } public function withEmails(array $emails) { $this->emails = $emails; return $this; } public function withRealnames(array $realnames) { $this->realnames = $realnames; return $this; } public function withUsernames(array $usernames) { $this->usernames = $usernames; return $this; } public function withDateCreatedBefore($date_created_before) { $this->dateCreatedBefore = $date_created_before; return $this; } public function withDateCreatedAfter($date_created_after) { $this->dateCreatedAfter = $date_created_after; return $this; } public function withIsAdmin($admin) { $this->isAdmin = $admin; return $this; } public function withIsSystemAgent($system_agent) { $this->isSystemAgent = $system_agent; return $this; } public function withIsMailingList($mailing_list) { $this->isMailingList = $mailing_list; return $this; } public function withIsDisabled($disabled) { $this->isDisabled = $disabled; return $this; } public function withIsApproved($approved) { $this->isApproved = $approved; return $this; } public function withNameLike($like) { $this->nameLike = $like; return $this; } public function withNameTokens(array $tokens) { $this->nameTokens = array_values($tokens); return $this; } public function withNamePrefixes(array $prefixes) { $this->namePrefixes = $prefixes; return $this; } public function withIsEnrolledInMultiFactor($enrolled) { $this->isEnrolledInMultiFactor = $enrolled; return $this; } public function needPrimaryEmail($need) { $this->needPrimaryEmail = $need; return $this; } public function needProfile($need) { $this->needProfile = $need; return $this; } public function needProfileImage($need) { $cache_key = PhabricatorUserProfileImageCacheType::KEY_URI; if ($need) { $this->cacheKeys[$cache_key] = true; } else { unset($this->cacheKeys[$cache_key]); } return $this; } public function needAvailability($need) { $this->needAvailability = $need; return $this; } public function needUserSettings($need) { $cache_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES; if ($need) { $this->cacheKeys[$cache_key] = true; } else { unset($this->cacheKeys[$cache_key]); } return $this; } public function needBadgeAwards($need) { $cache_key = PhabricatorUserBadgesCacheType::KEY_BADGES; if ($need) { $this->cacheKeys[$cache_key] = true; } else { unset($this->cacheKeys[$cache_key]); } return $this; } public function newResultObject() { return new PhabricatorUser(); } protected function loadPage() { $table = new PhabricatorUser(); $data = $this->loadStandardPageRows($table); if ($this->needPrimaryEmail) { $table->putInSet(new LiskDAOSet()); } return $table->loadAllFromArray($data); } protected function didFilterPage(array $users) { if ($this->needProfile) { $user_list = mpull($users, null, 'getPHID'); $profiles = new PhabricatorUserProfile(); $profiles = $profiles->loadAllWhere( 'userPHID IN (%Ls)', array_keys($user_list)); $profiles = mpull($profiles, null, 'getUserPHID'); foreach ($user_list as $user_phid => $user) { $profile = idx($profiles, $user_phid); if (!$profile) { $profile = PhabricatorUserProfile::initializeNewProfile($user); } $user->attachUserProfile($profile); } } if ($this->needAvailability) { $rebuild = array(); foreach ($users as $user) { $cache = $user->getAvailabilityCache(); if ($cache !== null) { $user->attachAvailability($cache); } else { $rebuild[] = $user; } } if ($rebuild) { $this->rebuildAvailabilityCache($rebuild); } } $this->fillUserCaches($users); return $users; } protected function shouldGroupQueryResultRows() { if ($this->nameTokens) { return true; } return parent::shouldGroupQueryResultRows(); } protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = parent::buildJoinClauseParts($conn); if ($this->emails) { $email_table = new PhabricatorUserEmail(); $joins[] = qsprintf( $conn, 'JOIN %T email ON email.userPHID = user.PHID', $email_table->getTableName()); } if ($this->nameTokens) { foreach ($this->nameTokens as $key => $token) { $token_table = 'token_'.$key; $joins[] = qsprintf( $conn, 'JOIN %T %T ON %T.userID = user.id AND %T.token LIKE %>', PhabricatorUser::NAMETOKEN_TABLE, $token_table, $token_table, $token_table, $token); } } return $joins; } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->usernames !== null) { $where[] = qsprintf( $conn, 'user.userName IN (%Ls)', $this->usernames); } if ($this->namePrefixes) { $parts = array(); foreach ($this->namePrefixes as $name_prefix) { $parts[] = qsprintf( $conn, 'user.username LIKE %>', $name_prefix); } $where[] = '('.implode(' OR ', $parts).')'; } if ($this->emails !== null) { $where[] = qsprintf( $conn, 'email.address IN (%Ls)', $this->emails); } if ($this->realnames !== null) { $where[] = qsprintf( $conn, 'user.realName IN (%Ls)', $this->realnames); } if ($this->phids !== null) { $where[] = qsprintf( $conn, 'user.phid IN (%Ls)', $this->phids); } if ($this->ids !== null) { $where[] = qsprintf( $conn, 'user.id IN (%Ld)', $this->ids); } if ($this->dateCreatedAfter) { $where[] = qsprintf( $conn, 'user.dateCreated >= %d', $this->dateCreatedAfter); } if ($this->dateCreatedBefore) { $where[] = qsprintf( $conn, 'user.dateCreated <= %d', $this->dateCreatedBefore); } if ($this->isAdmin !== null) { $where[] = qsprintf( $conn, 'user.isAdmin = %d', (int)$this->isAdmin); } if ($this->isDisabled !== null) { $where[] = qsprintf( $conn, 'user.isDisabled = %d', (int)$this->isDisabled); } if ($this->isApproved !== null) { $where[] = qsprintf( $conn, 'user.isApproved = %d', (int)$this->isApproved); } if ($this->isSystemAgent !== null) { $where[] = qsprintf( $conn, 'user.isSystemAgent = %d', (int)$this->isSystemAgent); } if ($this->isMailingList !== null) { $where[] = qsprintf( $conn, 'user.isMailingList = %d', (int)$this->isMailingList); } if (strlen($this->nameLike)) { $where[] = qsprintf( $conn, 'user.username LIKE %~ OR user.realname LIKE %~', $this->nameLike, $this->nameLike); } if ($this->isEnrolledInMultiFactor !== null) { $where[] = qsprintf( $conn, 'user.isEnrolledInMultiFactor = %d', (int)$this->isEnrolledInMultiFactor); } return $where; } protected function getPrimaryTableAlias() { return 'user'; } public function getQueryApplicationClass() { return 'PhabricatorPeopleApplication'; } public function getOrderableColumns() { return parent::getOrderableColumns() + array( 'username' => array( 'table' => 'user', 'column' => 'username', 'type' => 'string', 'reverse' => true, 'unique' => true, ), ); } protected function getPagingValueMap($cursor, array $keys) { $user = $this->loadCursorObject($cursor); return array( 'id' => $user->getID(), 'username' => $user->getUsername(), ); } private function rebuildAvailabilityCache(array $rebuild) { $rebuild = mpull($rebuild, null, 'getPHID'); // Limit the window we look at because far-future events are largely // irrelevant and this makes the cache cheaper to build and allows it to // self-heal over time. $min_range = PhabricatorTime::getNow(); $max_range = $min_range + phutil_units('72 hours in seconds'); // NOTE: We don't need to generate ghosts here, because we only care if // the user is attending, and you can't attend a ghost event: RSVP'ing // to it creates a real event. $events = id(new PhabricatorCalendarEventQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withInvitedPHIDs(array_keys($rebuild)) ->withIsCancelled(false) ->withDateRange($min_range, $max_range) ->execute(); // Group all the events by invited user. Only examine events that users // are actually attending. $map = array(); $invitee_map = array(); foreach ($events as $event) { foreach ($event->getInvitees() as $invitee) { if (!$invitee->isAttending()) { continue; } // If the user is set to "Available" for this event, don't consider it // when computin their away status. if (!$invitee->getDisplayAvailability($event)) { continue; } $invitee_phid = $invitee->getInviteePHID(); if (!isset($rebuild[$invitee_phid])) { continue; } $map[$invitee_phid][] = $event; $event_phid = $event->getPHID(); $invitee_map[$invitee_phid][$event_phid] = $invitee; } } // We need to load these users' timezone settings to figure out their // availability if they're attending all-day events. $this->needUserSettings(true); $this->fillUserCaches($rebuild); foreach ($rebuild as $phid => $user) { $events = idx($map, $phid, array()); // We loaded events with the omnipotent user, but want to shift them // into the user's timezone before building the cache because they will // be unavailable during their own local day. foreach ($events as $event) { $event->applyViewerTimezone($user); } $cursor = $min_range; $next_event = null; if ($events) { // Find the next time when the user has no meetings. If we move forward // because of an event, we check again for events after that one ends. while (true) { foreach ($events as $event) { $from = $event->getStartDateTimeEpochForCache(); - $to = $event->getEndDateTimeEpoch(); + $to = $event->getEndDateTimeEpochForCache(); if (($from <= $cursor) && ($to > $cursor)) { $cursor = $to; if (!$next_event) { $next_event = $event; } continue 2; } } break; } } if ($cursor > $min_range) { $invitee = $invitee_map[$phid][$next_event->getPHID()]; $availability_type = $invitee->getDisplayAvailability($next_event); $availability = array( 'until' => $cursor, - 'eventPHID' => $event->getPHID(), + 'eventPHID' => $next_event->getPHID(), 'availability' => $availability_type, ); // We only cache this availability until the end of the current event, // since the event PHID (and possibly the availability type) are only // valid for that long. // NOTE: This doesn't handle overlapping events with the greatest // possible care. In theory, if you're attenting multiple events // simultaneously we should accommodate that. However, it's complex // to compute, rare, and probably not confusing most of the time. - $availability_ttl = $next_event->getStartDateTimeEpochForCache(); + $availability_ttl = $next_event->getEndDateTimeEpochForCache(); } else { $availability = array( 'until' => null, 'eventPHID' => null, 'availability' => null, ); // Cache that the user is available until the next event they are // invited to starts. $availability_ttl = $max_range; foreach ($events as $event) { $from = $event->getStartDateTimeEpochForCache(); if ($from > $cursor) { $availability_ttl = min($from, $availability_ttl); } } } // Never TTL the cache to longer than the maximum range we examined. $availability_ttl = min($availability_ttl, $max_range); $user->writeAvailabilityCache($availability, $availability_ttl); $user->attachAvailability($availability); } } private function fillUserCaches(array $users) { if (!$this->cacheKeys) { return; } $user_map = mpull($users, null, 'getPHID'); $keys = array_keys($this->cacheKeys); $hashes = array(); foreach ($keys as $key) { $hashes[] = PhabricatorHash::digestForIndex($key); } $types = PhabricatorUserCacheType::getAllCacheTypes(); // First, pull any available caches. If we wanted to be particularly clever // we could do this with JOINs in the main query. $cache_table = new PhabricatorUserCache(); $cache_conn = $cache_table->establishConnection('r'); $cache_data = queryfx_all( $cache_conn, 'SELECT cacheKey, userPHID, cacheData, cacheType FROM %T WHERE cacheIndex IN (%Ls) AND userPHID IN (%Ls)', $cache_table->getTableName(), $hashes, array_keys($user_map)); $skip_validation = array(); // After we read caches from the database, discard any which have data that // invalid or out of date. This allows cache types to implement TTLs or // versions instead of or in addition to explicit cache clears. foreach ($cache_data as $row_key => $row) { $cache_type = $row['cacheType']; if (isset($skip_validation[$cache_type])) { continue; } if (empty($types[$cache_type])) { unset($cache_data[$row_key]); continue; } $type = $types[$cache_type]; if (!$type->shouldValidateRawCacheData()) { $skip_validation[$cache_type] = true; continue; } $user = $user_map[$row['userPHID']]; $raw_data = $row['cacheData']; if (!$type->isRawCacheDataValid($user, $row['cacheKey'], $raw_data)) { unset($cache_data[$row_key]); continue; } } $need = array(); $cache_data = igroup($cache_data, 'userPHID'); foreach ($user_map as $user_phid => $user) { $raw_rows = idx($cache_data, $user_phid, array()); $raw_data = ipull($raw_rows, 'cacheData', 'cacheKey'); foreach ($keys as $key) { if (isset($raw_data[$key]) || array_key_exists($key, $raw_data)) { continue; } $need[$key][$user_phid] = $user; } $user->attachRawCacheData($raw_data); } // If we missed any cache values, bulk-construct them now. This is // usually much cheaper than generating them on-demand for each user // record. if (!$need) { return; } $writes = array(); foreach ($need as $cache_key => $need_users) { $type = PhabricatorUserCacheType::getCacheTypeForKey($cache_key); if (!$type) { continue; } $data = $type->newValueForUsers($cache_key, $need_users); foreach ($data as $user_phid => $raw_value) { $data[$user_phid] = $raw_value; $writes[] = array( 'userPHID' => $user_phid, 'key' => $cache_key, 'type' => $type, 'value' => $raw_value, ); } foreach ($need_users as $user_phid => $user) { if (isset($data[$user_phid]) || array_key_exists($user_phid, $data)) { $user->attachRawCacheData( array( $cache_key => $data[$user_phid], )); } } } PhabricatorUserCache::writeCaches($writes); } } diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 4a35d9956c..2a1c1394a6 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -1,1610 +1,1610 @@ isAdmin; case 'isDisabled': return (bool)$this->isDisabled; case 'isSystemAgent': return (bool)$this->isSystemAgent; case 'isMailingList': return (bool)$this->isMailingList; case 'isEmailVerified': return (bool)$this->isEmailVerified; case 'isApproved': return (bool)$this->isApproved; default: return parent::readField($field); } } /** * Is this a live account which has passed required approvals? Returns true * if this is an enabled, verified (if required), approved (if required) * account, and false otherwise. * * @return bool True if this is a standard, usable account. */ public function isUserActivated() { if (!$this->isLoggedIn()) { return false; } if ($this->isOmnipotent()) { return true; } if ($this->getIsDisabled()) { return false; } if (!$this->getIsApproved()) { return false; } if (PhabricatorUserEmail::isEmailVerificationRequired()) { if (!$this->getIsEmailVerified()) { return false; } } return true; } /** * Is this a user who we can reasonably expect to respond to requests? * * This is used to provide a grey "disabled/unresponsive" dot cue when * rendering handles and tags, so it isn't a surprise if you get ignored * when you ask things of users who will not receive notifications or could * not respond to them (because they are disabled, unapproved, do not have * verified email addresses, etc). * * @return bool True if this user can receive and respond to requests from * other humans. */ public function isResponsive() { if (!$this->isUserActivated()) { return false; } if (!$this->getIsEmailVerified()) { return false; } return true; } public function canEstablishWebSessions() { if ($this->getIsMailingList()) { return false; } if ($this->getIsSystemAgent()) { return false; } return true; } public function canEstablishAPISessions() { if ($this->getIsDisabled()) { return false; } // Intracluster requests are permitted even if the user is logged out: // in particular, public users are allowed to issue intracluster requests // when browsing Diffusion. if (PhabricatorEnv::isClusterRemoteAddress()) { if (!$this->isLoggedIn()) { return true; } } if (!$this->isUserActivated()) { return false; } if ($this->getIsMailingList()) { return false; } return true; } public function canEstablishSSHSessions() { if (!$this->isUserActivated()) { return false; } if ($this->getIsMailingList()) { return false; } return true; } /** * Returns `true` if this is a standard user who is logged in. Returns `false` * for logged out, anonymous, or external users. * * @return bool `true` if the user is a standard user who is logged in with * a normal session. */ public function getIsStandardUser() { $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; return $this->getPHID() && (phid_get_type($this->getPHID()) == $type_user); } protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, self::CONFIG_COLUMN_SCHEMA => array( 'userName' => 'sort64', 'realName' => 'text128', 'passwordSalt' => 'text32?', 'passwordHash' => 'text128?', 'profileImagePHID' => 'phid?', 'conduitCertificate' => 'text255', 'isSystemAgent' => 'bool', 'isMailingList' => 'bool', 'isDisabled' => 'bool', 'isAdmin' => 'bool', 'isEmailVerified' => 'uint32', 'isApproved' => 'uint32', 'accountSecret' => 'bytes64', 'isEnrolledInMultiFactor' => 'bool', 'availabilityCache' => 'text255?', 'availabilityCacheTTL' => 'uint32?', 'defaultProfileImagePHID' => 'phid?', 'defaultProfileImageVersion' => 'text64?', ), self::CONFIG_KEY_SCHEMA => array( 'key_phid' => null, 'phid' => array( 'columns' => array('phid'), 'unique' => true, ), 'userName' => array( 'columns' => array('userName'), 'unique' => true, ), 'realName' => array( 'columns' => array('realName'), ), 'key_approved' => array( 'columns' => array('isApproved'), ), ), self::CONFIG_NO_MUTATE => array( 'availabilityCache' => true, 'availabilityCacheTTL' => true, ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID( PhabricatorPeopleUserPHIDType::TYPECONST); } public function setPassword(PhutilOpaqueEnvelope $envelope) { if (!$this->getPHID()) { throw new Exception( pht( 'You can not set a password for an unsaved user because their PHID '. 'is a salt component in the password hash.')); } if (!strlen($envelope->openEnvelope())) { $this->setPasswordHash(''); } else { $this->setPasswordSalt(md5(Filesystem::readRandomBytes(32))); $hash = $this->hashPassword($envelope); $this->setPasswordHash($hash->openEnvelope()); } return $this; } public function getMonogram() { return '@'.$this->getUsername(); } public function isLoggedIn() { return !($this->getPHID() === null); } public function save() { if (!$this->getConduitCertificate()) { $this->setConduitCertificate($this->generateConduitCertificate()); } if (!strlen($this->getAccountSecret())) { $this->setAccountSecret(Filesystem::readRandomCharacters(64)); } $result = parent::save(); if ($this->profile) { $this->profile->save(); } $this->updateNameTokens(); PhabricatorSearchWorker::queueDocumentForIndexing($this->getPHID()); return $result; } public function attachSession(PhabricatorAuthSession $session) { $this->session = $session; return $this; } public function getSession() { return $this->assertAttached($this->session); } public function hasSession() { return ($this->session !== self::ATTACHABLE); } private function generateConduitCertificate() { return Filesystem::readRandomCharacters(255); } public function comparePassword(PhutilOpaqueEnvelope $envelope) { if (!strlen($envelope->openEnvelope())) { return false; } if (!strlen($this->getPasswordHash())) { return false; } return PhabricatorPasswordHasher::comparePassword( $this->getPasswordHashInput($envelope), new PhutilOpaqueEnvelope($this->getPasswordHash())); } private function getPasswordHashInput(PhutilOpaqueEnvelope $password) { $input = $this->getUsername(). $password->openEnvelope(). $this->getPHID(). $this->getPasswordSalt(); return new PhutilOpaqueEnvelope($input); } private function hashPassword(PhutilOpaqueEnvelope $password) { $hasher = PhabricatorPasswordHasher::getBestHasher(); $input_envelope = $this->getPasswordHashInput($password); return $hasher->getPasswordHashForStorage($input_envelope); } const CSRF_CYCLE_FREQUENCY = 3600; const CSRF_SALT_LENGTH = 8; const CSRF_TOKEN_LENGTH = 16; const CSRF_BREACH_PREFIX = 'B@'; const EMAIL_CYCLE_FREQUENCY = 86400; const EMAIL_TOKEN_LENGTH = 24; private function getRawCSRFToken($offset = 0) { return $this->generateToken( time() + (self::CSRF_CYCLE_FREQUENCY * $offset), self::CSRF_CYCLE_FREQUENCY, PhabricatorEnv::getEnvConfig('phabricator.csrf-key'), self::CSRF_TOKEN_LENGTH); } public function getCSRFToken() { if ($this->isOmnipotent()) { // We may end up here when called from the daemons. The omnipotent user // has no meaningful CSRF token, so just return `null`. return null; } if ($this->csrfSalt === null) { $this->csrfSalt = Filesystem::readRandomCharacters( self::CSRF_SALT_LENGTH); } $salt = $this->csrfSalt; // Generate a token hash to mitigate BREACH attacks against SSL. See // discussion in T3684. $token = $this->getRawCSRFToken(); $hash = PhabricatorHash::weakDigest($token, $salt); return self::CSRF_BREACH_PREFIX.$salt.substr( $hash, 0, self::CSRF_TOKEN_LENGTH); } public function validateCSRFToken($token) { // We expect a BREACH-mitigating token. See T3684. $breach_prefix = self::CSRF_BREACH_PREFIX; $breach_prelen = strlen($breach_prefix); if (strncmp($token, $breach_prefix, $breach_prelen) !== 0) { return false; } $salt = substr($token, $breach_prelen, self::CSRF_SALT_LENGTH); $token = substr($token, $breach_prelen + self::CSRF_SALT_LENGTH); // When the user posts a form, we check that it contains a valid CSRF token. // Tokens cycle each hour (every CSRF_CYLCE_FREQUENCY seconds) and we accept // either the current token, the next token (users can submit a "future" // token if you have two web frontends that have some clock skew) or any of // the last 6 tokens. This means that pages are valid for up to 7 hours. // There is also some Javascript which periodically refreshes the CSRF // tokens on each page, so theoretically pages should be valid indefinitely. // However, this code may fail to run (if the user loses their internet // connection, or there's a JS problem, or they don't have JS enabled). // Choosing the size of the window in which we accept old CSRF tokens is // an issue of balancing concerns between security and usability. We could // choose a very narrow (e.g., 1-hour) window to reduce vulnerability to // attacks using captured CSRF tokens, but it's also more likely that real // users will be affected by this, e.g. if they close their laptop for an // hour, open it back up, and try to submit a form before the CSRF refresh // can kick in. Since the user experience of submitting a form with expired // CSRF is often quite bad (you basically lose data, or it's a big pain to // recover at least) and I believe we gain little additional protection // by keeping the window very short (the overwhelming value here is in // preventing blind attacks, and most attacks which can capture CSRF tokens // can also just capture authentication information [sniffing networks] // or act as the user [xss]) the 7 hour default seems like a reasonable // balance. Other major platforms have much longer CSRF token lifetimes, // like Rails (session duration) and Django (forever), which suggests this // is a reasonable analysis. $csrf_window = 6; for ($ii = -$csrf_window; $ii <= 1; $ii++) { $valid = $this->getRawCSRFToken($ii); $digest = PhabricatorHash::weakDigest($valid, $salt); $digest = substr($digest, 0, self::CSRF_TOKEN_LENGTH); if (phutil_hashes_are_identical($digest, $token)) { return true; } } return false; } private function generateToken($epoch, $frequency, $key, $len) { if ($this->getPHID()) { $vec = $this->getPHID().$this->getAccountSecret(); } else { $vec = $this->getAlternateCSRFString(); } if ($this->hasSession()) { $vec = $vec.$this->getSession()->getSessionKey(); } $time_block = floor($epoch / $frequency); $vec = $vec.$key.$time_block; return substr(PhabricatorHash::weakDigest($vec), 0, $len); } public function getUserProfile() { return $this->assertAttached($this->profile); } public function attachUserProfile(PhabricatorUserProfile $profile) { $this->profile = $profile; return $this; } public function loadUserProfile() { if ($this->profile) { return $this->profile; } $profile_dao = new PhabricatorUserProfile(); $this->profile = $profile_dao->loadOneWhere('userPHID = %s', $this->getPHID()); if (!$this->profile) { $this->profile = PhabricatorUserProfile::initializeNewProfile($this); } return $this->profile; } public function loadPrimaryEmailAddress() { $email = $this->loadPrimaryEmail(); if (!$email) { throw new Exception(pht('User has no primary email address!')); } return $email->getAddress(); } public function loadPrimaryEmail() { return $this->loadOneRelative( new PhabricatorUserEmail(), 'userPHID', 'getPHID', '(isPrimary = 1)'); } /* -( Settings )----------------------------------------------------------- */ public function getUserSetting($key) { // NOTE: We store available keys and cached values separately to make it // faster to check for `null` in the cache, which is common. if (isset($this->settingCacheKeys[$key])) { return $this->settingCache[$key]; } $settings_key = PhabricatorUserPreferencesCacheType::KEY_PREFERENCES; if ($this->getPHID()) { $settings = $this->requireCacheData($settings_key); } else { $settings = $this->loadGlobalSettings(); } if (array_key_exists($key, $settings)) { $value = $settings[$key]; return $this->writeUserSettingCache($key, $value); } $cache = PhabricatorCaches::getRuntimeCache(); $cache_key = "settings.defaults({$key})"; $cache_map = $cache->getKeys(array($cache_key)); if ($cache_map) { $value = $cache_map[$cache_key]; } else { $defaults = PhabricatorSetting::getAllSettings(); if (isset($defaults[$key])) { $value = id(clone $defaults[$key]) ->setViewer($this) ->getSettingDefaultValue(); } else { $value = null; } $cache->setKey($cache_key, $value); } return $this->writeUserSettingCache($key, $value); } /** * Test if a given setting is set to a particular value. * * @param const Setting key. * @param wild Value to compare. * @return bool True if the setting has the specified value. * @task settings */ public function compareUserSetting($key, $value) { $actual = $this->getUserSetting($key); return ($actual == $value); } private function writeUserSettingCache($key, $value) { $this->settingCacheKeys[$key] = true; $this->settingCache[$key] = $value; return $value; } public function getTranslation() { return $this->getUserSetting(PhabricatorTranslationSetting::SETTINGKEY); } public function getTimezoneIdentifier() { return $this->getUserSetting(PhabricatorTimezoneSetting::SETTINGKEY); } public static function getGlobalSettingsCacheKey() { return 'user.settings.globals.v1'; } private function loadGlobalSettings() { $cache_key = self::getGlobalSettingsCacheKey(); $cache = PhabricatorCaches::getMutableStructureCache(); $settings = $cache->getKey($cache_key); if (!$settings) { $preferences = PhabricatorUserPreferences::loadGlobalPreferences($this); $settings = $preferences->getPreferences(); $cache->setKey($cache_key, $settings); } return $settings; } /** * Override the user's timezone identifier. * * This is primarily useful for unit tests. * * @param string New timezone identifier. * @return this * @task settings */ public function overrideTimezoneIdentifier($identifier) { $timezone_key = PhabricatorTimezoneSetting::SETTINGKEY; $this->settingCacheKeys[$timezone_key] = true; $this->settingCache[$timezone_key] = $identifier; return $this; } public function getGender() { return $this->getUserSetting(PhabricatorPronounSetting::SETTINGKEY); } public function loadEditorLink( $path, $line, PhabricatorRepository $repository = null) { $editor = $this->getUserSetting(PhabricatorEditorSetting::SETTINGKEY); if (is_array($path)) { $multi_key = PhabricatorEditorMultipleSetting::SETTINGKEY; $multiedit = $this->getUserSetting($multi_key); switch ($multiedit) { case PhabricatorEditorMultipleSetting::VALUE_SPACES: $path = implode(' ', $path); break; case PhabricatorEditorMultipleSetting::VALUE_SINGLE: default: return null; } } if (!strlen($editor)) { return null; } if ($repository) { $callsign = $repository->getCallsign(); } else { $callsign = null; } $uri = strtr($editor, array( '%%' => '%', '%f' => phutil_escape_uri($path), '%l' => phutil_escape_uri($line), '%r' => phutil_escape_uri($callsign), )); // The resulting URI must have an allowed protocol. Otherwise, we'll return // a link to an error page explaining the misconfiguration. $ok = PhabricatorHelpEditorProtocolController::hasAllowedProtocol($uri); if (!$ok) { return '/help/editorprotocol/'; } return (string)$uri; } public function getAlternateCSRFString() { return $this->assertAttached($this->alternateCSRFString); } public function attachAlternateCSRFString($string) { $this->alternateCSRFString = $string; return $this; } /** * Populate the nametoken table, which used to fetch typeahead results. When * a user types "linc", we want to match "Abraham Lincoln" from on-demand * typeahead sources. To do this, we need a separate table of name fragments. */ public function updateNameTokens() { $table = self::NAMETOKEN_TABLE; $conn_w = $this->establishConnection('w'); $tokens = PhabricatorTypeaheadDatasource::tokenizeString( $this->getUserName().' '.$this->getRealName()); $sql = array(); foreach ($tokens as $token) { $sql[] = qsprintf( $conn_w, '(%d, %s)', $this->getID(), $token); } queryfx( $conn_w, 'DELETE FROM %T WHERE userID = %d', $table, $this->getID()); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (userID, token) VALUES %Q', $table, implode(', ', $sql)); } } public function sendWelcomeEmail(PhabricatorUser $admin) { if (!$this->canEstablishWebSessions()) { throw new Exception( pht( 'Can not send welcome mail to users who can not establish '. 'web sessions!')); } $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $user_username = $this->getUserName(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $base_uri = PhabricatorEnv::getProductionURI('/'); $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, $this->loadPrimaryEmail(), PhabricatorAuthSessionEngine::ONETIME_WELCOME); $body = pht( "Welcome to Phabricator!\n\n". "%s (%s) has created an account for you.\n\n". " Username: %s\n\n". "To login to Phabricator, follow this link and set a password:\n\n". " %s\n\n". "After you have set a password, you can login in the future by ". "going here:\n\n". " %s\n", $admin_username, $admin_realname, $user_username, $uri, $base_uri); if (!$is_serious) { $body .= sprintf( "\n%s\n", pht("Love,\nPhabricator")); } $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject(pht('[Phabricator] Welcome to Phabricator')) ->setBody($body) ->saveAndSend(); } public function sendUsernameChangeEmail( PhabricatorUser $admin, $old_username) { $admin_username = $admin->getUserName(); $admin_realname = $admin->getRealName(); $new_username = $this->getUserName(); $password_instructions = null; if (PhabricatorPasswordAuthProvider::getPasswordProvider()) { $engine = new PhabricatorAuthSessionEngine(); $uri = $engine->getOneTimeLoginURI( $this, null, PhabricatorAuthSessionEngine::ONETIME_USERNAME); $password_instructions = sprintf( "%s\n\n %s\n\n%s\n", pht( "If you use a password to login, you'll need to reset it ". "before you can login again. You can reset your password by ". "following this link:"), $uri, pht( "And, of course, you'll need to use your new username to login ". "from now on. If you use OAuth to login, nothing should change.")); } $body = sprintf( "%s\n\n %s\n %s\n\n%s", pht( '%s (%s) has changed your Phabricator username.', $admin_username, $admin_realname), pht( 'Old Username: %s', $old_username), pht( 'New Username: %s', $new_username), $password_instructions); $mail = id(new PhabricatorMetaMTAMail()) ->addTos(array($this->getPHID())) ->setForceDelivery(true) ->setSubject(pht('[Phabricator] Username Changed')) ->setBody($body) ->saveAndSend(); } public static function describeValidUsername() { return pht( 'Usernames must contain only numbers, letters, period, underscore and '. 'hyphen, and can not end with a period. They must have no more than %d '. 'characters.', new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH)); } public static function validateUsername($username) { // NOTE: If you update this, make sure to update: // // - Remarkup rule for @mentions. // - Routing rule for "/p/username/". // - Unit tests, obviously. // - describeValidUsername() method, above. if (strlen($username) > self::MAXIMUM_USERNAME_LENGTH) { return false; } return (bool)preg_match('/^[a-zA-Z0-9._-]*[a-zA-Z0-9_-]\z/', $username); } public static function getDefaultProfileImageURI() { return celerity_get_resource_uri('/rsrc/image/avatar.png'); } public function getProfileImageURI() { $uri_key = PhabricatorUserProfileImageCacheType::KEY_URI; return $this->requireCacheData($uri_key); } public function getUnreadNotificationCount() { $notification_key = PhabricatorUserNotificationCountCacheType::KEY_COUNT; return $this->requireCacheData($notification_key); } public function getUnreadMessageCount() { $message_key = PhabricatorUserMessageCountCacheType::KEY_COUNT; return $this->requireCacheData($message_key); } public function getRecentBadgeAwards() { $badges_key = PhabricatorUserBadgesCacheType::KEY_BADGES; return $this->requireCacheData($badges_key); } public function getFullName() { if (strlen($this->getRealName())) { return $this->getUsername().' ('.$this->getRealName().')'; } else { return $this->getUsername(); } } public function getTimeZone() { return new DateTimeZone($this->getTimezoneIdentifier()); } public function getTimeZoneOffset() { $timezone = $this->getTimeZone(); $now = new DateTime('@'.PhabricatorTime::getNow()); $offset = $timezone->getOffset($now); // Javascript offsets are in minutes and have the opposite sign. $offset = -(int)($offset / 60); return $offset; } public function getTimeZoneOffsetInHours() { $offset = $this->getTimeZoneOffset(); $offset = (int)round($offset / 60); $offset = -$offset; return $offset; } public function formatShortDateTime($when, $now = null) { if ($now === null) { $now = PhabricatorTime::getNow(); } try { $when = new DateTime('@'.$when); $now = new DateTime('@'.$now); } catch (Exception $ex) { return null; } $zone = $this->getTimeZone(); $when->setTimeZone($zone); $now->setTimeZone($zone); if ($when->format('Y') !== $now->format('Y')) { // Different year, so show "Feb 31 2075". $format = 'M j Y'; } else if ($when->format('Ymd') !== $now->format('Ymd')) { // Same year but different month and day, so show "Feb 31". $format = 'M j'; } else { // Same year, month and day so show a time of day. $pref_time = PhabricatorTimeFormatSetting::SETTINGKEY; $format = $this->getUserSetting($pref_time); } return $when->format($format); } public function __toString() { return $this->getUsername(); } public static function loadOneWithEmailAddress($address) { $email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', $address); if (!$email) { return null; } return id(new PhabricatorUser())->loadOneWhere( 'phid = %s', $email->getUserPHID()); } public function getDefaultSpacePHID() { // TODO: We might let the user switch which space they're "in" later on; // for now just use the global space if one exists. // If the viewer has access to the default space, use that. $spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces($this); foreach ($spaces as $space) { if ($space->getIsDefaultNamespace()) { return $space->getPHID(); } } // Otherwise, use the space with the lowest ID that they have access to. // This just tends to keep the default stable and predictable over time, // so adding a new space won't change behavior for users. if ($spaces) { $spaces = msort($spaces, 'getID'); return head($spaces)->getPHID(); } return null; } /** * Grant a user a source of authority, to let them bypass policy checks they * could not otherwise. */ public function grantAuthority($authority) { $this->authorities[] = $authority; return $this; } /** * Get authorities granted to the user. */ public function getAuthorities() { return $this->authorities; } public function hasConduitClusterToken() { return ($this->conduitClusterToken !== self::ATTACHABLE); } public function attachConduitClusterToken(PhabricatorConduitToken $token) { $this->conduitClusterToken = $token; return $this; } public function getConduitClusterToken() { return $this->assertAttached($this->conduitClusterToken); } /* -( Availability )------------------------------------------------------- */ /** * @task availability */ public function attachAvailability(array $availability) { $this->availability = $availability; return $this; } /** * Get the timestamp the user is away until, if they are currently away. * * @return int|null Epoch timestamp, or `null` if the user is not away. * @task availability */ public function getAwayUntil() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } return idx($availability, 'until'); } public function getDisplayAvailability() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } $busy = PhabricatorCalendarEventInvitee::AVAILABILITY_BUSY; return idx($availability, 'availability', $busy); } public function getAvailabilityEventPHID() { $availability = $this->availability; $this->assertAttached($availability); if (!$availability) { return null; } return idx($availability, 'eventPHID'); } /** * Get cached availability, if present. * * @return wild|null Cache data, or null if no cache is available. * @task availability */ public function getAvailabilityCache() { $now = PhabricatorTime::getNow(); if ($this->availabilityCacheTTL <= $now) { return null; } try { return phutil_json_decode($this->availabilityCache); } catch (Exception $ex) { return null; } } /** * Write to the availability cache. * * @param wild Availability cache data. * @param int|null Cache TTL. * @return this * @task availability */ public function writeAvailabilityCache(array $availability, $ttl) { if (PhabricatorEnv::isReadOnly()) { return $this; } $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $this->establishConnection('w'), 'UPDATE %T SET availabilityCache = %s, availabilityCacheTTL = %nd WHERE id = %d', $this->getTableName(), - json_encode($availability), + phutil_json_encode($availability), $ttl, $this->getID()); unset($unguarded); return $this; } /* -( Multi-Factor Authentication )---------------------------------------- */ /** * Update the flag storing this user's enrollment in multi-factor auth. * * With certain settings, we need to check if a user has MFA on every page, * so we cache MFA enrollment on the user object for performance. Calling this * method synchronizes the cache by examining enrollment records. After * updating the cache, use @{method:getIsEnrolledInMultiFactor} to check if * the user is enrolled. * * This method should be called after any changes are made to a given user's * multi-factor configuration. * * @return void * @task factors */ public function updateMultiFactorEnrollment() { $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); $enrolled = count($factors) ? 1 : 0; if ($enrolled !== $this->isEnrolledInMultiFactor) { $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); queryfx( $this->establishConnection('w'), 'UPDATE %T SET isEnrolledInMultiFactor = %d WHERE id = %d', $this->getTableName(), $enrolled, $this->getID()); unset($unguarded); $this->isEnrolledInMultiFactor = $enrolled; } } /** * Check if the user is enrolled in multi-factor authentication. * * Enrolled users have one or more multi-factor authentication sources * attached to their account. For performance, this value is cached. You * can use @{method:updateMultiFactorEnrollment} to update the cache. * * @return bool True if the user is enrolled. * @task factors */ public function getIsEnrolledInMultiFactor() { return $this->isEnrolledInMultiFactor; } /* -( Omnipotence )-------------------------------------------------------- */ /** * Returns true if this user is omnipotent. Omnipotent users bypass all policy * checks. * * @return bool True if the user bypasses policy checks. */ public function isOmnipotent() { return $this->omnipotent; } /** * Get an omnipotent user object for use in contexts where there is no acting * user, notably daemons. * * @return PhabricatorUser An omnipotent user. */ public static function getOmnipotentUser() { static $user = null; if (!$user) { $user = new PhabricatorUser(); $user->omnipotent = true; $user->makeEphemeral(); } return $user; } /** * Get a scalar string identifying this user. * * This is similar to using the PHID, but distinguishes between ominpotent * and public users explicitly. This allows safe construction of cache keys * or cache buckets which do not conflate public and omnipotent users. * * @return string Scalar identifier. */ public function getCacheFragment() { if ($this->isOmnipotent()) { return 'u.omnipotent'; } $phid = $this->getPHID(); if ($phid) { return 'u.'.$phid; } return 'u.public'; } /* -( Managing Handles )--------------------------------------------------- */ /** * Get a @{class:PhabricatorHandleList} which benefits from this viewer's * internal handle pool. * * @param list List of PHIDs to load. * @return PhabricatorHandleList Handle list object. * @task handle */ public function loadHandles(array $phids) { if ($this->handlePool === null) { $this->handlePool = id(new PhabricatorHandlePool()) ->setViewer($this); } return $this->handlePool->newHandleList($phids); } /** * Get a @{class:PHUIHandleView} for a single handle. * * This benefits from the viewer's internal handle pool. * * @param phid PHID to render a handle for. * @return PHUIHandleView View of the handle. * @task handle */ public function renderHandle($phid) { return $this->loadHandles(array($phid))->renderHandle($phid); } /** * Get a @{class:PHUIHandleListView} for a list of handles. * * This benefits from the viewer's internal handle pool. * * @param list List of PHIDs to render. * @return PHUIHandleListView View of the handles. * @task handle */ public function renderHandleList(array $phids) { return $this->loadHandles($phids)->renderList(); } public function attachBadgePHIDs(array $phids) { $this->badgePHIDs = $phids; return $this; } public function getBadgePHIDs() { return $this->assertAttached($this->badgePHIDs); } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return PhabricatorPolicies::POLICY_PUBLIC; case PhabricatorPolicyCapability::CAN_EDIT: if ($this->getIsSystemAgent() || $this->getIsMailingList()) { return PhabricatorPolicies::POLICY_ADMIN; } else { return PhabricatorPolicies::POLICY_NOONE; } } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return $this->getPHID() && ($viewer->getPHID() === $this->getPHID()); } public function describeAutomaticCapability($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_EDIT: return pht('Only you can edit your information.'); default: return null; } } /* -( PhabricatorCustomFieldInterface )------------------------------------ */ public function getCustomFieldSpecificationForRole($role) { return PhabricatorEnv::getEnvConfig('user.fields'); } public function getCustomFieldBaseClass() { return 'PhabricatorUserCustomField'; } public function getCustomFields() { return $this->assertAttached($this->customFields); } public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { $this->customFields = $fields; return $this; } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $externals = id(new PhabricatorExternalAccount())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($externals as $external) { $external->delete(); } $prefs = id(new PhabricatorUserPreferencesQuery()) ->setViewer($engine->getViewer()) ->withUsers(array($this)) ->execute(); foreach ($prefs as $pref) { $engine->destroyObject($pref); } $profiles = id(new PhabricatorUserProfile())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($profiles as $profile) { $profile->delete(); } $keys = id(new PhabricatorAuthSSHKeyQuery()) ->setViewer($engine->getViewer()) ->withObjectPHIDs(array($this->getPHID())) ->execute(); foreach ($keys as $key) { $engine->destroyObject($key); } $emails = id(new PhabricatorUserEmail())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($emails as $email) { $email->delete(); } $sessions = id(new PhabricatorAuthSession())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($sessions as $session) { $session->delete(); } $factors = id(new PhabricatorAuthFactorConfig())->loadAllWhere( 'userPHID = %s', $this->getPHID()); foreach ($factors as $factor) { $factor->delete(); } $this->saveTransaction(); } /* -( PhabricatorSSHPublicKeyInterface )----------------------------------- */ public function getSSHPublicKeyManagementURI(PhabricatorUser $viewer) { if ($viewer->getPHID() == $this->getPHID()) { // If the viewer is managing their own keys, take them to the normal // panel. return '/settings/panel/ssh/'; } else { // Otherwise, take them to the administrative panel for this user. return '/settings/'.$this->getID().'/panel/ssh/'; } } public function getSSHKeyDefaultName() { return 'id_rsa_phabricator'; } public function getSSHKeyNotifyPHIDs() { return array( $this->getPHID(), ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new PhabricatorUserProfileEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new PhabricatorUserTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorFulltextInterface )--------------------------------------- */ public function newFulltextEngine() { return new PhabricatorUserFulltextEngine(); } /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { return array( id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('username') ->setType('string') ->setDescription(pht("The user's username.")), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('realName') ->setType('string') ->setDescription(pht("The user's real name.")), id(new PhabricatorConduitSearchFieldSpecification()) ->setKey('roles') ->setType('list') ->setDescription(pht('List of acccount roles.')), ); } public function getFieldValuesForConduit() { $roles = array(); if ($this->getIsDisabled()) { $roles[] = 'disabled'; } if ($this->getIsSystemAgent()) { $roles[] = 'bot'; } if ($this->getIsMailingList()) { $roles[] = 'list'; } if ($this->getIsAdmin()) { $roles[] = 'admin'; } if ($this->getIsEmailVerified()) { $roles[] = 'verified'; } if ($this->getIsApproved()) { $roles[] = 'approved'; } if ($this->isUserActivated()) { $roles[] = 'activated'; } return array( 'username' => $this->getUsername(), 'realName' => $this->getRealName(), 'roles' => $roles, ); } public function getConduitSearchAttachments() { return array(); } /* -( User Cache )--------------------------------------------------------- */ /** * @task cache */ public function attachRawCacheData(array $data) { $this->rawCacheData = $data + $this->rawCacheData; return $this; } public function setAllowInlineCacheGeneration($allow_cache_generation) { $this->allowInlineCacheGeneration = $allow_cache_generation; return $this; } /** * @task cache */ protected function requireCacheData($key) { if (isset($this->usableCacheData[$key])) { return $this->usableCacheData[$key]; } $type = PhabricatorUserCacheType::requireCacheTypeForKey($key); if (isset($this->rawCacheData[$key])) { $raw_value = $this->rawCacheData[$key]; $usable_value = $type->getValueFromStorage($raw_value); $this->usableCacheData[$key] = $usable_value; return $usable_value; } // By default, we throw if a cache isn't available. This is consistent // with the standard `needX()` + `attachX()` + `getX()` interaction. if (!$this->allowInlineCacheGeneration) { throw new PhabricatorDataNotAttachedException($this); } $user_phid = $this->getPHID(); // Try to read the actual cache before we generate a new value. We can // end up here via Conduit, which does not use normal sessions and can // not pick up a free cache load during session identification. if ($user_phid) { $raw_data = PhabricatorUserCache::readCaches( $type, $key, array($user_phid)); if (array_key_exists($user_phid, $raw_data)) { $raw_value = $raw_data[$user_phid]; $usable_value = $type->getValueFromStorage($raw_value); $this->rawCacheData[$key] = $raw_value; $this->usableCacheData[$key] = $usable_value; return $usable_value; } } $usable_value = $type->getDefaultValue(); if ($user_phid) { $map = $type->newValueForUsers($key, array($this)); if (array_key_exists($user_phid, $map)) { $raw_value = $map[$user_phid]; $usable_value = $type->getValueFromStorage($raw_value); $this->rawCacheData[$key] = $raw_value; PhabricatorUserCache::writeCache( $type, $key, $user_phid, $raw_value); } } $this->usableCacheData[$key] = $usable_value; return $usable_value; } /** * @task cache */ public function clearCacheData($key) { unset($this->rawCacheData[$key]); unset($this->usableCacheData[$key]); return $this; } public function getCSSValue($variable_key) { $preference = PhabricatorAccessibilitySetting::SETTINGKEY; $key = $this->getUserSetting($preference); $postprocessor = CelerityPostprocessor::getPostprocessor($key); $variables = $postprocessor->getVariables(); if (!isset($variables[$variable_key])) { throw new Exception( pht( 'Unknown CSS variable "%s"!', $variable_key)); } return $variables[$variable_key]; } }