]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
- break OMB profile update pings to a background queue
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 /**
21  * @package OStatusPlugin
22  * @maintainer Brion Vibber <brion@status.net>
23  */
24
25 class Ostatus_profile extends Memcached_DataObject
26 {
27     public $__table = 'ostatus_profile';
28
29     public $uri;
30
31     public $profile_id;
32     public $group_id;
33
34     public $feeduri;
35     public $salmonuri;
36     public $avatar; // remote URL of the last avatar we saved
37
38     public $created;
39     public $modified;
40
41     public /*static*/ function staticGet($k, $v=null)
42     {
43         return parent::staticGet(__CLASS__, $k, $v);
44     }
45
46     /**
47      * return table definition for DB_DataObject
48      *
49      * DB_DataObject needs to know something about the table to manipulate
50      * instances. This method provides all the DB_DataObject needs to know.
51      *
52      * @return array array of column definitions
53      */
54
55     function table()
56     {
57         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
58                      'profile_id' => DB_DATAOBJECT_INT,
59                      'group_id' => DB_DATAOBJECT_INT,
60                      'feeduri' => DB_DATAOBJECT_STR,
61                      'salmonuri' =>  DB_DATAOBJECT_STR,
62                      'avatar' =>  DB_DATAOBJECT_STR,
63                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
64                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
65     }
66
67     static function schemaDef()
68     {
69         return array(new ColumnDef('uri', 'varchar',
70                                    255, false, 'PRI'),
71                      new ColumnDef('profile_id', 'integer',
72                                    null, true, 'UNI'),
73                      new ColumnDef('group_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('feeduri', 'varchar',
76                                    255, true, 'UNI'),
77                      new ColumnDef('salmonuri', 'text',
78                                    null, true),
79                      new ColumnDef('avatar', 'text',
80                                    null, true),
81                      new ColumnDef('created', 'datetime',
82                                    null, false),
83                      new ColumnDef('modified', 'datetime',
84                                    null, false));
85     }
86
87     /**
88      * return key definitions for DB_DataObject
89      *
90      * DB_DataObject needs to know about keys that the table has; this function
91      * defines them.
92      *
93      * @return array key definitions
94      */
95
96     function keys()
97     {
98         return array_keys($this->keyTypes());
99     }
100
101     /**
102      * return key definitions for Memcached_DataObject
103      *
104      * Our caching system uses the same key definitions, but uses a different
105      * method to get them.
106      *
107      * @return array key definitions
108      */
109
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             $object = new ActivityObject();
154             $object->type = 'http://activitystrea.ms/schema/1.0/group';
155             $object->id = $this->uri;
156             $self = $this->localGroup();
157
158             // @fixme put a standard getAvatar() interface on groups too
159             if ($self->homepage_logo) {
160                 $object->avatar = $self->homepage_logo;
161                 $map = array('png' => 'image/png',
162                              'jpg' => 'image/jpeg',
163                              'jpeg' => 'image/jpeg',
164                              'gif' => 'image/gif');
165                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
166                 if (isset($map[$extension])) {
167                     // @fixme this ain't used/saved yet
168                     $object->avatarType = $map[$extension];
169                 }
170             }
171
172             $object->link = $this->uri; // @fixme accurate?
173             return $object;
174         } else {
175             return ActivityObject::fromProfile($this->localProfile());
176         }
177     }
178
179     /**
180      * Returns an XML string fragment with profile information as an
181      * Activity Streams noun object with the given element type.
182      *
183      * Assumes that 'activity' namespace has been previously defined.
184      *
185      * @fixme replace with wrappers on asActivityObject when it's got everything.
186      *
187      * @param string $element one of 'actor', 'subject', 'object', 'target'
188      * @return string
189      */
190     function asActivityNoun($element)
191     {
192         $xs = new XMLStringer(true);
193         $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE);
194         $avatarType = 'image/png';
195         if ($this->isGroup()) {
196             $type = 'http://activitystrea.ms/schema/1.0/group';
197             $self = $this->localGroup();
198
199             // @fixme put a standard getAvatar() interface on groups too
200             if ($self->homepage_logo) {
201                 $avatarHref = $self->homepage_logo;
202                 $map = array('png' => 'image/png',
203                              'jpg' => 'image/jpeg',
204                              'jpeg' => 'image/jpeg',
205                              'gif' => 'image/gif');
206                 $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION);
207                 if (isset($map[$extension])) {
208                     $avatarType = $map[$extension];
209                 }
210             }
211         } else {
212             $type = 'http://activitystrea.ms/schema/1.0/person';
213             $self = $this->localProfile();
214             $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE);
215             if ($avatar) {
216                   $avatarHref = $avatar->url;
217                   $avatarType = $avatar->mediatype;
218             }
219         }
220         $xs->elementStart('activity:' . $element);
221         $xs->element(
222             'activity:object-type',
223             null,
224             $type
225         );
226         $xs->element(
227             'id',
228             null,
229             $this->uri); // ?
230         $xs->element('title', null, $self->getBestName());
231
232         $xs->element(
233             'link', array(
234                 'type' => $avatarType,
235                 'href' => $avatarHref
236             ),
237             ''
238         );
239
240         $xs->elementEnd('activity:' . $element);
241
242         return $xs->getString();
243     }
244
245     /**
246      * @return boolean true if this is a remote group
247      */
248     function isGroup()
249     {
250         if ($this->profile_id && !$this->group_id) {
251             return false;
252         } else if ($this->group_id && !$this->profile_id) {
253             return true;
254         } else if ($this->group_id && $this->profile_id) {
255             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
256         } else {
257             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
258         }
259     }
260
261     /**
262      * Subscribe a local user to this remote user.
263      * PuSH subscription will be started if necessary, and we'll
264      * send a Salmon notification to the remote server if available
265      * notifying them of the sub.
266      *
267      * @param User $user
268      * @return boolean success
269      * @throws FeedException
270      */
271     public function subscribeLocalToRemote(User $user)
272     {
273         if ($this->isGroup()) {
274             throw new ServerException("Can't subscribe to a remote group");
275         }
276
277         if ($this->subscribe()) {
278             if ($user->subscribeTo($this->localProfile())) {
279                 $this->notify($user->getProfile(), ActivityVerb::FOLLOW, $this);
280                 return true;
281             }
282         }
283         return false;
284     }
285
286     /**
287      * Mark this remote profile as subscribing to the given local user,
288      * and send appropriate notifications to the user.
289      *
290      * This will generally be in response to a subscription notification
291      * from a foreign site to our local Salmon response channel.
292      *
293      * @param User $user
294      * @return boolean success
295      */
296     public function subscribeRemoteToLocal(User $user)
297     {
298         if ($this->isGroup()) {
299             throw new ServerException("Remote groups can't subscribe to local users");
300         }
301
302         // @fixme use regular channels for subbing, once they accept remote profiles
303         $sub = new Subscription();
304         $sub->subscriber = $this->profile_id;
305         $sub->subscribed = $user->id;
306         $sub->created = common_sql_now(); // current time
307
308         if ($sub->insert()) {
309             // @fixme use subs_notify() if refactored to take profiles?
310             mail_subscribe_notify_profile($user, $this->localProfile());
311             return true;
312         }
313         return false;
314     }
315
316     /**
317      * Send a subscription request to the hub for this feed.
318      * The hub will later send us a confirmation POST to /main/push/callback.
319      *
320      * @return bool true on success, false on failure
321      * @throws ServerException if feed state is not valid
322      */
323     public function subscribe()
324     {
325         $feedsub = FeedSub::ensureFeed($this->feeduri);
326         if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
327             return true;
328         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
329             return $feedsub->subscribe();
330         } else if ('unsubscribe') {
331             throw new FeedSubException("Unsub is pending, can't subscribe...");
332         }
333     }
334
335     /**
336      * Send a PuSH unsubscription request to the hub for this feed.
337      * The hub will later send us a confirmation POST to /main/push/callback.
338      *
339      * @return bool true on success, false on failure
340      * @throws ServerException if feed state is not valid
341      */
342     public function unsubscribe() {
343         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
344         if ($feedsub->sub_state == 'active') {
345             return $feedsub->unsubscribe();
346         } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
347             return true;
348         } else if ($feedsub->sub_state == 'subscribe') {
349             throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
350         }
351     }
352
353     /**
354      * Check if this remote profile has any active local subscriptions, and
355      * if not drop the PuSH subscription feed.
356      *
357      * @return boolean
358      */
359     public function garbageCollect()
360     {
361         if ($this->isGroup()) {
362             $members = $this->localGroup()->getMembers(0, 1);
363             $count = $members->N;
364         } else {
365             $count = $this->localProfile()->subscriberCount();
366         }
367         if ($count == 0) {
368             common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $oprofile->feeduri");
369             $this->unsubscribe();
370             return true;
371         } else {
372             return false;
373         }
374     }
375
376     /**
377      * Send an Activity Streams notification to the remote Salmon endpoint,
378      * if so configured.
379      *
380      * @param Profile $actor  Actor who did the activity
381      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
382      * @param Object  $object object of the action; must define asActivityNoun($tag)
383      */
384     public function notify($actor, $verb, $object=null)
385     {
386         if (!($actor instanceof Profile)) {
387             $type = gettype($actor);
388             if ($type == 'object') {
389                 $type = get_class($actor);
390             }
391             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
392         }
393         if ($object == null) {
394             $object = $this;
395         }
396         if ($this->salmonuri) {
397
398             $text = 'update';
399             $id = TagURI::mint('%s:%s:%s',
400                                $verb,
401                                $actor->getURI(),
402                                common_date_iso8601(time()));
403
404             // @fixme consolidate all these NS settings somewhere
405             $attributes = array('xmlns' => Activity::ATOM,
406                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
407                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
408                                 'xmlns:georss' => 'http://www.georss.org/georss',
409                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
410                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0');
411
412             $entry = new XMLStringer();
413             $entry->elementStart('entry', $attributes);
414             $entry->element('id', null, $id);
415             $entry->element('title', null, $text);
416             $entry->element('summary', null, $text);
417             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
418
419             $entry->element('activity:verb', null, $verb);
420             $entry->raw($actor->asAtomAuthor());
421             $entry->raw($actor->asActivityActor());
422             $entry->raw($object->asActivityNoun('object'));
423             $entry->elementEnd('entry');
424
425             $xml = $entry->getString();
426             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
427
428             $salmon = new Salmon(); // ?
429             return $salmon->post($this->salmonuri, $xml);
430         }
431         return false;
432     }
433
434     /**
435      * Send a Salmon notification ping immediately, and confirm that we got
436      * an acceptable response from the remote site.
437      *
438      * @param mixed $entry XML string, Notice, or Activity
439      * @return boolean success
440      */
441     public function notifyActivity($entry)
442     {
443         if ($this->salmonuri) {
444             $salmon = new Salmon();
445             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry));
446         }
447
448         return false;
449     }
450
451     /**
452      * Queue a Salmon notification for later. If queues are disabled we'll
453      * send immediately but won't get the return value.
454      *
455      * @param mixed $entry XML string, Notice, or Activity
456      * @return boolean success
457      */
458     public function notifyDeferred($entry)
459     {
460         if ($this->salmonuri) {
461             $data = array('salmonuri' => $this->salmonuri,
462                           'entry' => $this->notifyPrepXml($entry));
463
464             $qm = QueueManager::get();
465             return $qm->enqueue($data, 'salmon');
466         }
467
468         return false;
469     }
470
471     protected function notifyPrepXml($entry)
472     {
473         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
474         if (is_string($entry)) {
475             return $entry;
476         } else if ($entry instanceof Activity) {
477             return $preamble . $entry->asString(true);
478         } else if ($entry instanceof Notice) {
479             return $preamble . $entry->asAtomEntry(true, true);
480         } else {
481             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
482         }
483     }
484
485     function getBestName()
486     {
487         if ($this->isGroup()) {
488             return $this->localGroup()->getBestName();
489         } else {
490             return $this->localProfile()->getBestName();
491         }
492     }
493
494     function atomFeed($actor)
495     {
496         $feed = new Atom10Feed();
497         // @fixme should these be set up somewhere else?
498         $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/');
499         $feed->addNamespace('thr', 'http://purl.org/syndication/thread/1.0');
500         $feed->addNamespace('georss', 'http://www.georss.org/georss');
501         $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0');
502
503         $taguribase = common_config('integration', 'taguri');
504         $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ???
505
506         $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme
507         $feed->setUpdated(time());
508         $feed->setPublished(time());
509
510         $feed->addLink(common_local_url('ApiTimelineUser',
511                                         array('id' => $actor->id,
512                                               'type' => 'atom')),
513                        array('rel' => 'self',
514                              'type' => 'application/atom+xml'));
515
516         $feed->addLink(common_local_url('userbyid',
517                                         array('id' => $actor->id)),
518                        array('rel' => 'alternate',
519                              'type' => 'text/html'));
520
521         return $feed;
522     }
523
524     /**
525      * Read and post notices for updates from the feed.
526      * Currently assumes that all items in the feed are new,
527      * coming from a PuSH hub.
528      *
529      * @param DOMDocument $feed
530      */
531     public function processFeed($feed, $source)
532     {
533         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
534         if ($entries->length == 0) {
535             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
536             return;
537         }
538
539         for ($i = 0; $i < $entries->length; $i++) {
540             $entry = $entries->item($i);
541             $this->processEntry($entry, $feed, $source);
542         }
543     }
544
545     /**
546      * Process a posted entry from this feed source.
547      *
548      * @param DOMElement $entry
549      * @param DOMElement $feed for context
550      */
551     public function processEntry($entry, $feed, $source)
552     {
553         $activity = new Activity($entry, $feed);
554
555         if ($activity->verb == ActivityVerb::POST) {
556             $this->processPost($activity, $source);
557         } else {
558             common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
559         }
560     }
561
562     /**
563      * Process an incoming post activity from this remote feed.
564      * @param Activity $activity
565      * @param string $method 'push' or 'salmon'
566      * @return mixed saved Notice or false
567      * @fixme break up this function, it's getting nasty long
568      */
569     public function processPost($activity, $method)
570     {
571         if ($this->isGroup()) {
572             // A group feed will contain posts from multiple authors.
573             // @fixme validate these profiles in some way!
574             $oprofile = self::ensureActorProfile($activity);
575             if ($oprofile->isGroup()) {
576                 // Groups can't post notices in StatusNet.
577                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
578                 return false;
579             }
580         } else {
581             // Individual user feeds may contain only posts from themselves.
582             // Authorship is validated against the profile URI on upper layers,
583             // through PuSH setup or Salmon signature checks.
584             $actorUri = self::getActorProfileURI($activity);
585             if ($actorUri == $this->uri) {
586                 // Check if profile info has changed and update it
587                 $this->updateFromActivityObject($activity->actor);
588             } else {
589                 common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
590                 return false;
591             }
592             $oprofile = $this;
593         }
594
595         // The id URI will be used as a unique identifier for for the notice,
596         // protecting against duplicate saves. It isn't required to be a URL;
597         // tag: URIs for instance are found in Google Buzz feeds.
598         $sourceUri = $activity->object->id;
599         $dupe = Notice::staticGet('uri', $sourceUri);
600         if ($dupe) {
601             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
602             return false;
603         }
604
605         // We'll also want to save a web link to the original notice, if provided.
606         $sourceUrl = null;
607         if ($activity->object->link) {
608             $sourceUrl = $activity->object->link;
609         } else if ($activity->link) {
610             $sourceUrl = $activity->link;
611         } else if (preg_match('!^https?://!', $activity->object->id)) {
612             $sourceUrl = $activity->object->id;
613         }
614
615         // Get (safe!) HTML and text versions of the content
616         $rendered = $this->purify($activity->object->content);
617         $content = html_entity_decode(strip_tags($rendered));
618
619         $options = array('is_local' => Notice::REMOTE_OMB,
620                         'url' => $sourceUrl,
621                         'uri' => $sourceUri,
622                         'rendered' => $rendered,
623                         'replies' => array(),
624                         'groups' => array());
625
626         // Check for optional attributes...
627
628         if (!empty($activity->time)) {
629             $options['created'] = common_sql_date($activity->time);
630         }
631
632         if ($activity->context) {
633             // Any individual or group attn: targets?
634             $replies = $activity->context->attention;
635             $options['groups'] = $this->filterReplies($oprofile, $replies);
636             $options['replies'] = $replies;
637
638             // Maintain direct reply associations
639             // @fixme what about conversation ID?
640             if (!empty($activity->context->replyToID)) {
641                 $orig = Notice::staticGet('uri',
642                                           $activity->context->replyToID);
643                 if (!empty($orig)) {
644                     $options['reply_to'] = $orig->id;
645                 }
646             }
647
648             $location = $activity->context->location;
649             if ($location) {
650                 $options['lat'] = $location->lat;
651                 $options['lon'] = $location->lon;
652                 if ($location->location_id) {
653                     $options['location_ns'] = $location->location_ns;
654                     $options['location_id'] = $location->location_id;
655                 }
656             }
657         }
658
659         try {
660             $saved = Notice::saveNew($oprofile->profile_id,
661                                      $content,
662                                      'ostatus',
663                                      $options);
664             if ($saved) {
665                 Ostatus_source::saveNew($saved, $this, $method);
666             }
667         } catch (Exception $e) {
668             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
669             throw $e;
670         }
671         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
672         return $saved;
673     }
674
675     /**
676      * Clean up HTML
677      */
678     protected function purify($html)
679     {
680         // @fixme disable caching or set a sane temp dir
681         require_once(INSTALLDIR.'/extlib/HTMLPurifier/HTMLPurifier.auto.php');
682         $purifier = new HTMLPurifier();
683         return $purifier->purify($html);
684     }
685
686     /**
687      * Filters a list of recipient ID URIs to just those for local delivery.
688      * @param Ostatus_profile local profile of sender
689      * @param array in/out &$attention_uris set of URIs, will be pruned on output
690      * @return array of group IDs
691      */
692     protected function filterReplies($sender, &$attention_uris)
693     {
694         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
695         $groups = array();
696         $replies = array();
697         foreach ($attention_uris as $recipient) {
698             // Is the recipient a local user?
699             $user = User::staticGet('uri', $recipient);
700             if ($user) {
701                 // @fixme sender verification, spam etc?
702                 $replies[] = $recipient;
703                 continue;
704             }
705
706             // Is the recipient a remote group?
707             $oprofile = Ostatus_profile::staticGet('uri', $recipient);
708             if ($oprofile) {
709                 if ($oprofile->isGroup()) {
710                     // Deliver to local members of this remote group.
711                     // @fixme sender verification?
712                     $groups[] = $oprofile->group_id;
713                 } else {
714                     common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
715                 }
716                 continue;
717             }
718
719             // Is the recipient a local group?
720             // @fixme we need a uri on user_group
721             // $group = User_group::staticGet('uri', $recipient);
722             $template = common_local_url('groupbyid', array('id' => '31337'));
723             $template = preg_quote($template, '/');
724             $template = str_replace('31337', '(\d+)', $template);
725             if (preg_match("/$template/", $recipient, $matches)) {
726                 $id = $matches[1];
727                 $group = User_group::staticGet('id', $id);
728                 if ($group) {
729                     // Deliver to all members of this local group if allowed.
730                     $profile = $sender->localProfile();
731                     if ($profile->isMember($group)) {
732                         $groups[] = $group->id;
733                     } else {
734                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
735                     }
736                     continue;
737                 } else {
738                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
739                 }
740             }
741
742             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
743
744         }
745         $attention_uris = $replies;
746         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
747         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
748         return $groups;
749     }
750
751     /**
752      * @param string $profile_url
753      * @return Ostatus_profile
754      * @throws FeedSubException
755      */
756     public static function ensureProfile($profile_uri, $hints=array())
757     {
758         // Get the canonical feed URI and check it
759         $discover = new FeedDiscovery();
760         $feeduri = $discover->discoverFromURL($profile_uri);
761
762         //$feedsub = FeedSub::ensureFeed($feeduri, $discover->feed);
763         $huburi = $discover->getAtomLink('hub');
764         $salmonuri = $discover->getAtomLink('salmon');
765
766         if (!$huburi) {
767             // We can only deal with folks with a PuSH hub
768             throw new FeedSubNoHubException();
769         }
770
771         // Try to get a profile from the feed activity:subject
772
773         $feedEl = $discover->feed->documentElement;
774
775         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
776
777         if (!empty($subject)) {
778             $subjObject = new ActivityObject($subject);
779             return self::ensureActivityObjectProfile($subjObject, $feeduri, $salmonuri, $hints);
780         }
781
782         // Otherwise, try the feed author
783
784         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
785
786         if (!empty($author)) {
787             $authorObject = new ActivityObject($author);
788             return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
789         }
790
791         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
792         // entries.
793
794         $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
795
796         if (!empty($entries) && $entries->length > 0) {
797
798             $entry = $entries->item(0);
799
800             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
801
802             if (!empty($actor)) {
803                 $actorObject = new ActivityObject($actor);
804                 return self::ensureActivityObjectProfile($actorObject, $feeduri, $salmonuri, $hints);
805
806             }
807
808             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
809
810             if (!empty($author)) {
811                 $authorObject = new ActivityObject($author);
812                 return self::ensureActivityObjectProfile($authorObject, $feeduri, $salmonuri, $hints);
813             }
814         }
815
816         // XXX: make some educated guesses here
817
818         throw new FeedSubException("Can't find enough profile information to make a feed.");
819     }
820
821     /**
822      *
823      * Download and update given avatar image
824      * @param string $url
825      * @throws Exception in various failure cases
826      */
827     protected function updateAvatar($url)
828     {
829         if ($url == $this->avatar) {
830             // We've already got this one.
831             return;
832         }
833
834         if ($this->isGroup()) {
835             $self = $this->localGroup();
836         } else {
837             $self = $this->localProfile();
838         }
839         if (!$self) {
840             throw new ServerException(sprintf(
841                 _m("Tried to update avatar for unsaved remote profile %s"),
842                 $this->uri));
843         }
844
845         // @fixme this should be better encapsulated
846         // ripped from oauthstore.php (for old OMB client)
847         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
848         if (!copy($url, $temp_filename)) {
849             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
850         }
851
852         if ($this->isGroup()) {
853             $id = $this->group_id;
854         } else {
855             $id = $this->profile_id;
856         }
857         // @fixme should we be using different ids?
858         $imagefile = new ImageFile($id, $temp_filename);
859         $filename = Avatar::filename($id,
860                                      image_type_to_extension($imagefile->type),
861                                      null,
862                                      common_timestamp());
863         rename($temp_filename, Avatar::path($filename));
864         $self->setOriginal($filename);
865
866         $orig = clone($this);
867         $this->avatar = $url;
868         $this->update($orig);
869     }
870
871     /**
872      * Pull avatar URL from ActivityObject or profile hints
873      *
874      * @param ActivityObject $object
875      * @param array $hints
876      * @return mixed URL string or false
877      */
878
879     protected static function getActivityObjectAvatar($object, $hints=array())
880     {
881         if ($object->avatar) {
882             return $object->avatar;
883         } else if (array_key_exists('avatar', $hints)) {
884             return $hints['avatar'];
885         }
886         return false;
887     }
888
889     /**
890      * Get an appropriate avatar image source URL, if available.
891      *
892      * @param ActivityObject $actor
893      * @param DOMElement $feed
894      * @return string
895      */
896
897     protected static function getAvatar($actor, $feed)
898     {
899         $url = '';
900         $icon = '';
901         if ($actor->avatar) {
902             $url = trim($actor->avatar);
903         }
904         if (!$url) {
905             // Check <atom:logo> and <atom:icon> on the feed
906             $els = $feed->childNodes();
907             if ($els && $els->length) {
908                 for ($i = 0; $i < $els->length; $i++) {
909                     $el = $els->item($i);
910                     if ($el->namespaceURI == Activity::ATOM) {
911                         if (empty($url) && $el->localName == 'logo') {
912                             $url = trim($el->textContent);
913                             break;
914                         }
915                         if (empty($icon) && $el->localName == 'icon') {
916                             // Use as a fallback
917                             $icon = trim($el->textContent);
918                         }
919                     }
920                 }
921             }
922             if ($icon && !$url) {
923                 $url = $icon;
924             }
925         }
926         if ($url) {
927             $opts = array('allowed_schemes' => array('http', 'https'));
928             if (Validate::uri($url, $opts)) {
929                 return $url;
930             }
931         }
932         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
933     }
934
935     /**
936      * Fetch, or build if necessary, an Ostatus_profile for the actor
937      * in a given Activity Streams activity.
938      *
939      * @param Activity $activity
940      * @param string $feeduri if we already know the canonical feed URI!
941      * @param string $salmonuri if we already know the salmon return channel URI
942      * @return Ostatus_profile
943      */
944
945     public static function ensureActorProfile($activity, $feeduri=null, $salmonuri=null)
946     {
947         return self::ensureActivityObjectProfile($activity->actor, $feeduri, $salmonuri);
948     }
949
950     public static function ensureActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
951     {
952         $profile = self::getActivityObjectProfile($object);
953         if ($profile) {
954             $profile->updateFromActivityObject($object, $hints);
955         } else {
956             $profile = self::createActivityObjectProfile($object, $feeduri, $salmonuri, $hints);
957         }
958         return $profile;
959     }
960
961     /**
962      * @param Activity $activity
963      * @return mixed matching Ostatus_profile or false if none known
964      */
965     protected static function getActorProfile($activity)
966     {
967         return self::getActivityObjectProfile($activity->actor);
968     }
969
970     protected static function getActivityObjectProfile($object)
971     {
972         $uri = self::getActivityObjectProfileURI($object);
973         return Ostatus_profile::staticGet('uri', $uri);
974     }
975
976     protected static function getActorProfileURI($activity)
977     {
978         return self::getActivityObjectProfileURI($activity->actor);
979     }
980
981     /**
982      * @param Activity $activity
983      * @return string
984      * @throws ServerException
985      */
986     protected static function getActivityObjectProfileURI($object)
987     {
988         $opts = array('allowed_schemes' => array('http', 'https'));
989         if ($object->id && Validate::uri($object->id, $opts)) {
990             return $object->id;
991         }
992         if ($object->link && Validate::uri($object->link, $opts)) {
993             return $object->link;
994         }
995         throw new ServerException("No author ID URI found");
996     }
997
998     /**
999      * @fixme validate stuff somewhere
1000      */
1001
1002     protected static function createActorProfile($activity, $feeduri=null, $salmonuri=null)
1003     {
1004         $actor = $activity->actor;
1005
1006         self::createActivityObjectProfile($actor, $feeduri, $salmonuri);
1007     }
1008
1009     /**
1010      * Create local ostatus_profile and profile/user_group entries for
1011      * the provided remote user or group.
1012      *
1013      * @param ActivityObject $object
1014      * @param string $feeduri
1015      * @param string $salmonuri
1016      * @param array $hints
1017      *
1018      * @fixme fold $feeduri/$salmonuri into $hints
1019      * @return Ostatus_profile
1020      */
1021     protected static function createActivityObjectProfile($object, $feeduri=null, $salmonuri=null, $hints=array())
1022     {
1023         $homeuri  = $object->id;
1024
1025         if (!$homeuri) {
1026             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1027             throw new ServerException("No profile URI");
1028         }
1029
1030         if (empty($feeduri)) {
1031             if (array_key_exists('feedurl', $hints)) {
1032                 $feeduri = $hints['feedurl'];
1033             }
1034         }
1035
1036         if (empty($salmonuri)) {
1037             if (array_key_exists('salmon', $hints)) {
1038                 $salmonuri = $hints['salmon'];
1039             }
1040         }
1041
1042         if (!$feeduri || !$salmonuri) {
1043             // Get the canonical feed URI and check it
1044             $discover = new FeedDiscovery();
1045             $feeduri = $discover->discoverFromURL($homeuri);
1046
1047             $huburi = $discover->getAtomLink('hub');
1048             $salmonuri = $discover->getAtomLink('salmon');
1049
1050             if (!$huburi) {
1051                 // We can only deal with folks with a PuSH hub
1052                 throw new FeedSubNoHubException();
1053             }
1054         }
1055
1056         $oprofile = new Ostatus_profile();
1057
1058         $oprofile->uri        = $homeuri;
1059         $oprofile->feeduri    = $feeduri;
1060         $oprofile->salmonuri  = $salmonuri;
1061
1062         $oprofile->created    = common_sql_now();
1063         $oprofile->modified   = common_sql_now();
1064
1065         if ($object->type == ActivityObject::PERSON) {
1066             $profile = new Profile();
1067             self::updateProfile($profile, $object, $hints);
1068             $profile->created  = common_sql_now();
1069
1070             $oprofile->profile_id = $profile->insert();
1071             if (!$oprofile->profile_id) {
1072                 throw new ServerException("Can't save local profile");
1073             }
1074         } else {
1075             $group = new User_group();
1076             $group->created = common_sql_now();
1077             self::updateGroup($group, $object, $hints);
1078
1079             $oprofile->group_id = $group->insert();
1080             if (!$oprofile->group_id) {
1081                 throw new ServerException("Can't save local profile");
1082             }
1083         }
1084
1085         $ok = $oprofile->insert();
1086
1087         if ($ok) {
1088             $avatar = self::getActivityObjectAvatar($object, $hints);
1089             if ($avatar) {
1090                 $oprofile->updateAvatar($avatar);
1091             }
1092             return $oprofile;
1093         } else {
1094             throw new ServerException("Can't save OStatus profile");
1095         }
1096     }
1097
1098     /**
1099      * Save any updated profile information to our local copy.
1100      * @param ActivityObject $object
1101      * @param array $hints
1102      */
1103     protected function updateFromActivityObject($object, $hints=array())
1104     {
1105         if ($this->isGroup()) {
1106             $group = $this->localGroup();
1107             self::updateGroup($group, $object, $hints);
1108         } else {
1109             $profile = $this->localProfile();
1110             self::updateProfile($profile, $object, $hints);
1111         }
1112         $avatar = self::getActivityObjectAvatar($object, $hints);
1113         if ($avatar) {
1114             $this->updateAvatar($avatar);
1115         }
1116     }
1117
1118     protected static function updateProfile($profile, $object, $hints=array())
1119     {
1120         $orig = clone($profile);
1121
1122         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1123         $profile->fullname = $object->title;
1124         if (!empty($object->link)) {
1125             $profile->profileurl = $object->link;
1126         } else if (array_key_exists('profileurl', $hints)) {
1127             $profile->profileurl = $hints['profileurl'];
1128         }
1129
1130         // @fixme bio
1131         // @fixme tags/categories
1132         // @fixme location?
1133         // @todo tags from categories
1134         // @todo lat/lon/location?
1135
1136         if ($profile->id) {
1137             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1138             $profile->update($orig);
1139         }
1140     }
1141
1142     protected static function updateGroup($group, $object, $hints=array())
1143     {
1144         $orig = clone($group);
1145
1146         // @fixme need to make nick unique etc *hack hack*
1147         $group->nickname = self::getActivityObjectNickname($object, $hints);
1148         $group->fullname = $object->title;
1149
1150         // @fixme no canonical profileurl; using homepage instead for now
1151         $group->homepage = $object->id;
1152
1153         // @fixme homepage
1154         // @fixme bio
1155         // @fixme tags/categories
1156         // @fixme location?
1157         // @todo tags from categories
1158         // @todo lat/lon/location?
1159
1160         if ($group->id) {
1161             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1162             $group->update($orig);
1163         }
1164     }
1165
1166
1167     protected static function getActivityObjectNickname($object, $hints=array())
1168     {
1169         if ($object->poco) {
1170             if (!empty($object->poco->preferredUsername)) {
1171                 return common_nicknamize($object->poco->preferredUsername);
1172             }
1173         }
1174         if (!empty($object->nickname)) {
1175             return common_nicknamize($object->nickname);
1176         }
1177
1178         // Try the definitive ID
1179
1180         $nickname = self::nicknameFromURI($object->id);
1181
1182         // Try a Webfinger if one was passed (way) down
1183
1184         if (empty($nickname)) {
1185             if (array_key_exists('webfinger', $hints)) {
1186                 $nickname = self::nicknameFromURI($hints['webfinger']);
1187             }
1188         }
1189
1190         // Try the name
1191
1192         if (empty($nickname)) {
1193             $nickname = common_nicknamize($object->title);
1194         }
1195
1196         return $nickname;
1197     }
1198
1199     protected static function nicknameFromURI($uri)
1200     {
1201         preg_match('/(\w+):/', $uri, $matches);
1202
1203         $protocol = $matches[1];
1204
1205         switch ($protocol) {
1206         case 'acct':
1207         case 'mailto':
1208             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1209                 return common_canonical_nickname($matches[1]);
1210             }
1211             return null;
1212         case 'http':
1213             return common_url_to_nickname($uri);
1214             break;
1215         default:
1216             return null;
1217         }
1218     }
1219
1220     public static function ensureWebfinger($addr)
1221     {
1222         // First, look it up
1223
1224         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1225
1226         if (!empty($oprofile)) {
1227             return $oprofile;
1228         }
1229
1230         // Now, try some discovery
1231
1232         $wf = new Webfinger();
1233
1234         $result = $wf->lookup($addr);
1235
1236         if (!$result) {
1237             return null;
1238         }
1239
1240         foreach ($result->links as $link) {
1241             switch ($link['rel']) {
1242             case Webfinger::PROFILEPAGE:
1243                 $profileUrl = $link['href'];
1244                 break;
1245             case 'salmon':
1246                 $salmonEndpoint = $link['href'];
1247                 break;
1248             case Webfinger::UPDATESFROM:
1249                 $feedUrl = $link['href'];
1250                 break;
1251             default:
1252                 common_log(LOG_NOTICE, "Don't know what to do with rel = '{$link['rel']}'");
1253                 break;
1254             }
1255         }
1256
1257         $hints = array('webfinger' => $addr,
1258                        'profileurl' => $profileUrl,
1259                        'feedurl' => $feedUrl,
1260                        'salmon' => $salmonEndpoint);
1261
1262         // If we got a feed URL, try that
1263
1264         if (isset($feedUrl)) {
1265             try {
1266                 $oprofile = self::ensureProfile($feedUrl, $hints);
1267                 return $oprofile;
1268             } catch (Exception $e) {
1269                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1270                 // keep looking
1271             }
1272         }
1273
1274         // If we got a profile page, try that!
1275
1276         if (isset($profileUrl)) {
1277             try {
1278                 $oprofile = self::ensureProfile($profileUrl, $hints);
1279                 return $oprofile;
1280             } catch (Exception $e) {
1281                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1282                 // keep looking
1283             }
1284         }
1285
1286         // XXX: try hcard
1287         // XXX: try FOAF
1288
1289         if (isset($salmonEndpoint)) {
1290
1291             // An account URL, a salmon endpoint, and a dream? Not much to go
1292             // on, but let's give it a try
1293
1294             $uri = 'acct:'.$addr;
1295
1296             $profile = new Profile();
1297
1298             $profile->nickname = self::nicknameFromUri($uri);
1299             $profile->created  = common_sql_now();
1300
1301             if (isset($profileUrl)) {
1302                 $profile->profileurl = $profileUrl;
1303             }
1304
1305             $profile_id = $profile->insert();
1306
1307             if (!$profile_id) {
1308                 common_log_db_error($profile, 'INSERT', __FILE__);
1309                 throw new Exception("Couldn't save profile for '$addr'");
1310             }
1311
1312             $oprofile = new Ostatus_profile();
1313
1314             $oprofile->uri        = $uri;
1315             $oprofile->salmonuri  = $salmonEndpoint;
1316             $oprofile->profile_id = $profile_id;
1317             $oprofile->created    = common_sql_now();
1318
1319             if (isset($feedUrl)) {
1320                 $profile->feeduri = $feedUrl;
1321             }
1322
1323             $result = $oprofile->insert();
1324
1325             if (!$result) {
1326                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1327                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1328             }
1329
1330             return $oprofile;
1331         }
1332
1333         return null;
1334     }
1335 }