]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
save URIs of remote profiles marked for attention
[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             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
192         } else {
193             throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
194         }
195     }
196
197     /**
198      * Send a subscription request to the hub for this feed.
199      * The hub will later send us a confirmation POST to /main/push/callback.
200      *
201      * @return bool true on success, false on failure
202      * @throws ServerException if feed state is not valid
203      */
204     public function subscribe()
205     {
206         $feedsub = FeedSub::ensureFeed($this->feeduri);
207         if ($feedsub->sub_state == 'active') {
208             // Active subscription, we don't need to do anything.
209             return true;
210         } else {
211             // Inactive or we got left in an inconsistent state.
212             // Run a subscription request to make sure we're current!
213             return $feedsub->subscribe();
214         }
215     }
216
217     /**
218      * Check if this remote profile has any active local subscriptions, and
219      * if not drop the PuSH subscription feed.
220      *
221      * @return bool true on success, false on failure
222      */
223     public function unsubscribe() {
224         $this->garbageCollect();
225     }
226
227     /**
228      * Check if this remote profile has any active local subscriptions, and
229      * if not drop the PuSH subscription feed.
230      *
231      * @return boolean
232      */
233     public function garbageCollect()
234     {
235         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
236         return $feedsub->garbageCollect();
237     }
238
239     /**
240      * Check if this remote profile has any active local subscriptions, so the
241      * PuSH subscription layer can decide if it can drop the feed.
242      *
243      * This gets called via the FeedSubSubscriberCount event when running
244      * FeedSub::garbageCollect().
245      *
246      * @return int
247      */
248     public function subscriberCount()
249     {
250         if ($this->isGroup()) {
251             $members = $this->localGroup()->getMembers(0, 1);
252             $count = $members->N;
253         } else {
254             $count = $this->localProfile()->subscriberCount();
255         }
256         common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");
257
258         // Other plugins may be piggybacking on OStatus without having
259         // an active group or user-to-user subscription we know about.
260         Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
261         common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");
262
263         return $count;
264     }
265
266     /**
267      * Send an Activity Streams notification to the remote Salmon endpoint,
268      * if so configured.
269      *
270      * @param Profile $actor  Actor who did the activity
271      * @param string  $verb   Activity::SUBSCRIBE or Activity::JOIN
272      * @param Object  $object object of the action; must define asActivityNoun($tag)
273      */
274     public function notify($actor, $verb, $object=null)
275     {
276         if (!($actor instanceof Profile)) {
277             $type = gettype($actor);
278             if ($type == 'object') {
279                 $type = get_class($actor);
280             }
281             throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
282         }
283         if ($object == null) {
284             $object = $this;
285         }
286         if ($this->salmonuri) {
287
288             $text = 'update';
289             $id = TagURI::mint('%s:%s:%s',
290                                $verb,
291                                $actor->getURI(),
292                                common_date_iso8601(time()));
293
294             // @fixme consolidate all these NS settings somewhere
295             $attributes = array('xmlns' => Activity::ATOM,
296                                 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
297                                 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
298                                 'xmlns:georss' => 'http://www.georss.org/georss',
299                                 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
300                                 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
301                                 'xmlns:media' => 'http://purl.org/syndication/atommedia');
302
303             $entry = new XMLStringer();
304             $entry->elementStart('entry', $attributes);
305             $entry->element('id', null, $id);
306             $entry->element('title', null, $text);
307             $entry->element('summary', null, $text);
308             $entry->element('published', null, common_date_w3dtf(common_sql_now()));
309
310             $entry->element('activity:verb', null, $verb);
311             $entry->raw($actor->asAtomAuthor());
312             $entry->raw($actor->asActivityActor());
313             $entry->raw($object->asActivityNoun('object'));
314             $entry->elementEnd('entry');
315
316             $xml = $entry->getString();
317             common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
318
319             $salmon = new Salmon(); // ?
320             return $salmon->post($this->salmonuri, $xml, $actor);
321         }
322         return false;
323     }
324
325     /**
326      * Send a Salmon notification ping immediately, and confirm that we got
327      * an acceptable response from the remote site.
328      *
329      * @param mixed $entry XML string, Notice, or Activity
330      * @return boolean success
331      */
332     public function notifyActivity($entry, $actor)
333     {
334         if ($this->salmonuri) {
335             $salmon = new Salmon();
336             return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
337         }
338
339         return false;
340     }
341
342     /**
343      * Queue a Salmon notification for later. If queues are disabled we'll
344      * send immediately but won't get the return value.
345      *
346      * @param mixed $entry XML string, Notice, or Activity
347      * @return boolean success
348      */
349     public function notifyDeferred($entry, $actor)
350     {
351         if ($this->salmonuri) {
352             $data = array('salmonuri' => $this->salmonuri,
353                           'entry' => $this->notifyPrepXml($entry),
354                           'actor' => $actor->id);
355
356             $qm = QueueManager::get();
357             return $qm->enqueue($data, 'salmon');
358         }
359
360         return false;
361     }
362
363     protected function notifyPrepXml($entry)
364     {
365         $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
366         if (is_string($entry)) {
367             return $entry;
368         } else if ($entry instanceof Activity) {
369             return $preamble . $entry->asString(true);
370         } else if ($entry instanceof Notice) {
371             return $preamble . $entry->asAtomEntry(true, true);
372         } else {
373             throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
374         }
375     }
376
377     function getBestName()
378     {
379         if ($this->isGroup()) {
380             return $this->localGroup()->getBestName();
381         } else {
382             return $this->localProfile()->getBestName();
383         }
384     }
385
386     /**
387      * Read and post notices for updates from the feed.
388      * Currently assumes that all items in the feed are new,
389      * coming from a PuSH hub.
390      *
391      * @param DOMDocument $doc
392      * @param string $source identifier ("push")
393      */
394     public function processFeed(DOMDocument $doc, $source)
395     {
396         $feed = $doc->documentElement;
397
398         if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
399             $this->processAtomFeed($feed, $source);
400         } else if ($feed->localName == 'rss') { // @fixme check namespace
401             $this->processRssFeed($feed, $source);
402         } else {
403             throw new Exception("Unknown feed format.");
404         }
405     }
406
407     public function processAtomFeed(DOMElement $feed, $source)
408     {
409         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
410         if ($entries->length == 0) {
411             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
412             return;
413         }
414
415         for ($i = 0; $i < $entries->length; $i++) {
416             $entry = $entries->item($i);
417             $this->processEntry($entry, $feed, $source);
418         }
419     }
420
421     public function processRssFeed(DOMElement $rss, $source)
422     {
423         $channels = $rss->getElementsByTagName('channel');
424
425         if ($channels->length == 0) {
426             throw new Exception("RSS feed without a channel.");
427         } else if ($channels->length > 1) {
428             common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
429         }
430
431         $channel = $channels->item(0);
432
433         $items = $channel->getElementsByTagName('item');
434
435         for ($i = 0; $i < $items->length; $i++) {
436             $item = $items->item($i);
437             $this->processEntry($item, $channel, $source);
438         }
439     }
440
441     /**
442      * Process a posted entry from this feed source.
443      *
444      * @param DOMElement $entry
445      * @param DOMElement $feed for context
446      * @param string $source identifier ("push" or "salmon")
447      */
448
449     public function processEntry($entry, $feed, $source)
450     {
451         $activity = new Activity($entry, $feed);
452
453         if (Event::handle('StartHandleFeedEntry', array($activity))) {
454
455             // @todo process all activity objects
456             switch ($activity->objects[0]->type) {
457             case ActivityObject::ARTICLE:
458             case ActivityObject::BLOGENTRY:
459             case ActivityObject::NOTE:
460             case ActivityObject::STATUS:
461             case ActivityObject::COMMENT:
462                 if ($activity->verb == ActivityVerb::POST) {
463                     $this->processPost($activity, $source);
464                 } else {
465                     common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
466                 }
467                 break;
468             default:
469                 throw new ClientException("Can't handle that kind of post.");
470             }
471
472             Event::handle('EndHandleFeedEntry', array($activity));
473         }
474     }
475
476     /**
477      * Process an incoming post activity from this remote feed.
478      * @param Activity $activity
479      * @param string $method 'push' or 'salmon'
480      * @return mixed saved Notice or false
481      * @fixme break up this function, it's getting nasty long
482      */
483     public function processPost($activity, $method)
484     {
485         if ($this->isGroup()) {
486             // A group feed will contain posts from multiple authors.
487             // @fixme validate these profiles in some way!
488             $oprofile = self::ensureActorProfile($activity);
489             if ($oprofile->isGroup()) {
490                 // Groups can't post notices in StatusNet.
491                 common_log(LOG_WARNING, "OStatus: skipping post with group listed as author: $oprofile->uri in feed from $this->uri");
492                 return false;
493             }
494         } else {
495             $actor = $activity->actor;
496
497             if (empty($actor)) {
498                 // OK here! assume the default
499             } else if ($actor->id == $this->uri || $actor->link == $this->uri) {
500                 $this->updateFromActivityObject($actor);
501             } else if ($actor->id) {
502                 // We have an ActivityStreams actor with an explicit ID that doesn't match the feed owner.
503                 // This isn't what we expect from mainline OStatus person feeds!
504                 // Group feeds go down another path, with different validation...
505                 // Most likely this is a plain ol' blog feed of some kind which
506                 // doesn't match our expectations. We'll take the entry, but ignore
507                 // the <author> info.
508                 common_log(LOG_WARNING, "Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
509             } else {
510                 // Plain <author> without ActivityStreams actor info.
511                 // We'll just ignore this info for now and save the update under the feed's identity.
512             }
513
514             $oprofile = $this;
515         }
516
517         // It's not always an ActivityObject::NOTE, but... let's just say it is.
518
519         $note = $activity->objects[0];
520
521         // The id URI will be used as a unique identifier for for the notice,
522         // protecting against duplicate saves. It isn't required to be a URL;
523         // tag: URIs for instance are found in Google Buzz feeds.
524         $sourceUri = $note->id;
525         $dupe = Notice::staticGet('uri', $sourceUri);
526         if ($dupe) {
527             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
528             return false;
529         }
530
531         // We'll also want to save a web link to the original notice, if provided.
532         $sourceUrl = null;
533         if ($note->link) {
534             $sourceUrl = $note->link;
535         } else if ($activity->link) {
536             $sourceUrl = $activity->link;
537         } else if (preg_match('!^https?://!', $note->id)) {
538             $sourceUrl = $note->id;
539         }
540
541         // Use summary as fallback for content
542
543         if (!empty($note->content)) {
544             $sourceContent = $note->content;
545         } else if (!empty($note->summary)) {
546             $sourceContent = $note->summary;
547         } else if (!empty($note->title)) {
548             $sourceContent = $note->title;
549         } else {
550             // @fixme fetch from $sourceUrl?
551             throw new ClientException("No content for notice {$sourceUri}");
552         }
553
554         // Get (safe!) HTML and text versions of the content
555
556         $rendered = $this->purify($sourceContent);
557         $content = html_entity_decode(strip_tags($rendered));
558
559         $shortened = common_shorten_links($content);
560
561         // If it's too long, try using the summary, and make the
562         // HTML an attachment.
563
564         $attachment = null;
565
566         if (Notice::contentTooLong($shortened)) {
567             $attachment = $this->saveHTMLFile($note->title, $rendered);
568             $summary = html_entity_decode(strip_tags($note->summary));
569             if (empty($summary)) {
570                 $summary = $content;
571             }
572             $shortSummary = common_shorten_links($summary);
573             if (Notice::contentTooLong($shortSummary)) {
574                 $url = common_shorten_url($sourceUrl);
575                 $shortSummary = substr($shortSummary,
576                                        0,
577                                        Notice::maxContent() - (mb_strlen($url) + 2));
578                 $content = $shortSummary . ' ' . $url;
579
580                 // We mark up the attachment link specially for the HTML output
581                 // so we can fold-out the full version inline.
582                 $attachUrl = common_local_url('attachment',
583                                               array('attachment' => $attachment->id));
584                 $rendered = common_render_text($shortSummary) .
585                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
586                             ' class="attachment more"' .
587                             ' title="'. htmlspecialchars(_m('Show more')) . '">' .
588                             '&#8230;' .
589                             '</a>';
590             }
591         }
592
593         $options = array('is_local' => Notice::REMOTE_OMB,
594                         'url' => $sourceUrl,
595                         'uri' => $sourceUri,
596                         'rendered' => $rendered,
597                         'replies' => array(),
598                         'groups' => array(),
599                         'tags' => array(),
600                         'urls' => array());
601
602         // Check for optional attributes...
603
604         if (!empty($activity->time)) {
605             $options['created'] = common_sql_date($activity->time);
606         }
607
608         if ($activity->context) {
609             // Any individual or group attn: targets?
610             $replies = $activity->context->attention;
611             $options['groups'] = $this->filterReplies($oprofile, $replies);
612             $options['replies'] = $replies;
613
614             // Maintain direct reply associations
615             // @fixme what about conversation ID?
616             if (!empty($activity->context->replyToID)) {
617                 $orig = Notice::staticGet('uri',
618                                           $activity->context->replyToID);
619                 if (!empty($orig)) {
620                     $options['reply_to'] = $orig->id;
621                 }
622             }
623
624             $location = $activity->context->location;
625             if ($location) {
626                 $options['lat'] = $location->lat;
627                 $options['lon'] = $location->lon;
628                 if ($location->location_id) {
629                     $options['location_ns'] = $location->location_ns;
630                     $options['location_id'] = $location->location_id;
631                 }
632             }
633         }
634
635         // Atom categories <-> hashtags
636         foreach ($activity->categories as $cat) {
637             if ($cat->term) {
638                 $term = common_canonical_tag($cat->term);
639                 if ($term) {
640                     $options['tags'][] = $term;
641                 }
642             }
643         }
644
645         // Atom enclosures -> attachment URLs
646         foreach ($activity->enclosures as $href) {
647             // @fixme save these locally or....?
648             $options['urls'][] = $href;
649         }
650
651         try {
652             $saved = Notice::saveNew($oprofile->profile_id,
653                                      $content,
654                                      'ostatus',
655                                      $options);
656             if ($saved) {
657                 Ostatus_source::saveNew($saved, $this, $method);
658                 if (!empty($attachment)) {
659                     File_to_post::processNew($attachment->id, $saved->id);
660                 }
661             }
662         } catch (Exception $e) {
663             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
664             throw $e;
665         }
666         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
667         return $saved;
668     }
669
670     /**
671      * Clean up HTML
672      */
673     protected function purify($html)
674     {
675         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
676         $config = array('safe' => 1,
677                         'deny_attribute' => 'id,style,on*');
678         return htmLawed($html, $config);
679     }
680
681     /**
682      * Filters a list of recipient ID URIs to just those for local delivery.
683      * @param Ostatus_profile local profile of sender
684      * @param array in/out &$attention_uris set of URIs, will be pruned on output
685      * @return array of group IDs
686      */
687     protected function filterReplies($sender, &$attention_uris)
688     {
689         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
690         $groups = array();
691         $replies = array();
692         foreach (array_unique($attention_uris) as $recipient) {
693             // Is the recipient a local user?
694             $user = User::staticGet('uri', $recipient);
695             if ($user) {
696                 // @fixme sender verification, spam etc?
697                 $replies[] = $recipient;
698                 continue;
699             }
700
701             // Is the recipient a remote group?
702             $oprofile = Ostatus_profile::ensureProfileURI($recipient);
703
704             if ($oprofile) {
705                 if ($oprofile->isGroup()) {
706                     // Deliver to local members of this remote group.
707                     // @fixme sender verification?
708                     $groups[] = $oprofile->group_id;
709                 } else {
710                     // may be canonicalized or something
711                     $replies[] = $oprofile->uri;
712                 }
713                 continue;
714             }
715
716             // Is the recipient a local group?
717             // @fixme uri on user_group isn't reliable yet
718             // $group = User_group::staticGet('uri', $recipient);
719             $id = OStatusPlugin::localGroupFromUrl($recipient);
720             if ($id) {
721                 $group = User_group::staticGet('id', $id);
722                 if ($group) {
723                     // Deliver to all members of this local group if allowed.
724                     $profile = $sender->localProfile();
725                     if ($profile->isMember($group)) {
726                         $groups[] = $group->id;
727                     } else {
728                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
729                     }
730                     continue;
731                 } else {
732                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
733                 }
734             }
735
736             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
737
738         }
739         $attention_uris = $replies;
740         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
741         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
742         return $groups;
743     }
744
745     /**
746      * Look up and if necessary create an Ostatus_profile for the remote entity
747      * with the given profile page URL. This should never return null -- you
748      * will either get an object or an exception will be thrown.
749      *
750      * @param string $profile_url
751      * @return Ostatus_profile
752      * @throws Exception on various error conditions
753      * @throws OStatusShadowException if this reference would obscure a local user/group
754      */
755
756     public static function ensureProfileURL($profile_url, $hints=array())
757     {
758         $oprofile = self::getFromProfileURL($profile_url);
759
760         if (!empty($oprofile)) {
761             return $oprofile;
762         }
763
764         $hints['profileurl'] = $profile_url;
765
766         // Fetch the URL
767         // XXX: HTTP caching
768
769         $client = new HTTPClient();
770         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
771         $response = $client->get($profile_url);
772
773         if (!$response->isOk()) {
774             throw new Exception("Could not reach profile page: " . $profile_url);
775         }
776
777         // Check if we have a non-canonical URL
778
779         $finalUrl = $response->getUrl();
780
781         if ($finalUrl != $profile_url) {
782
783             $hints['profileurl'] = $finalUrl;
784
785             $oprofile = self::getFromProfileURL($finalUrl);
786
787             if (!empty($oprofile)) {
788                 return $oprofile;
789             }
790         }
791
792         // Try to get some hCard data
793
794         $body = $response->getBody();
795
796         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
797
798         if (!empty($hcardHints)) {
799             $hints = array_merge($hints, $hcardHints);
800         }
801
802         // Check if they've got an LRDD header
803
804         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
805
806         if (!empty($lrdd)) {
807
808             $xrd = Discovery::fetchXrd($lrdd);
809             $xrdHints = DiscoveryHints::fromXRD($xrd);
810
811             $hints = array_merge($hints, $xrdHints);
812         }
813
814         // If discovery found a feedurl (probably from LRDD), use it.
815
816         if (array_key_exists('feedurl', $hints)) {
817             return self::ensureFeedURL($hints['feedurl'], $hints);
818         }
819
820         // Get the feed URL from HTML
821
822         $discover = new FeedDiscovery();
823
824         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
825
826         if (!empty($feedurl)) {
827             $hints['feedurl'] = $feedurl;
828             return self::ensureFeedURL($feedurl, $hints);
829         }
830
831         throw new Exception("Could not find a feed URL for profile page " . $finalUrl);
832     }
833
834     /**
835      * Look up the Ostatus_profile, if present, for a remote entity with the
836      * given profile page URL. Will return null for both unknown and invalid
837      * remote profiles.
838      *
839      * @return mixed Ostatus_profile or null
840      * @throws OStatusShadowException for local profiles
841      */
842     static function getFromProfileURL($profile_url)
843     {
844         $profile = Profile::staticGet('profileurl', $profile_url);
845
846         if (empty($profile)) {
847             return null;
848         }
849
850         // Is it a known Ostatus profile?
851
852         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
853
854         if (!empty($oprofile)) {
855             return $oprofile;
856         }
857
858         // Is it a local user?
859
860         $user = User::staticGet('id', $profile->id);
861
862         if (!empty($user)) {
863             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
864         }
865
866         // Continue discovery; it's a remote profile
867         // for OMB or some other protocol, may also
868         // support OStatus
869
870         return null;
871     }
872
873     /**
874      * Look up and if necessary create an Ostatus_profile for remote entity
875      * with the given update feed. This should never return null -- you will
876      * either get an object or an exception will be thrown.
877      *
878      * @return Ostatus_profile
879      * @throws Exception
880      */
881     public static function ensureFeedURL($feed_url, $hints=array())
882     {
883         $discover = new FeedDiscovery();
884
885         $feeduri = $discover->discoverFromFeedURL($feed_url);
886         $hints['feedurl'] = $feeduri;
887
888         $huburi = $discover->getHubLink();
889         $hints['hub'] = $huburi;
890         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
891         $hints['salmon'] = $salmonuri;
892
893         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
894             // We can only deal with folks with a PuSH hub
895             throw new FeedSubNoHubException();
896         }
897
898         $feedEl = $discover->root;
899
900         if ($feedEl->tagName == 'feed') {
901             return self::ensureAtomFeed($feedEl, $hints);
902         } else if ($feedEl->tagName == 'channel') {
903             return self::ensureRssChannel($feedEl, $hints);
904         } else {
905             throw new FeedSubBadXmlException($feeduri);
906         }
907     }
908
909     /**
910      * Look up and, if necessary, create an Ostatus_profile for the remote
911      * profile with the given Atom feed - actually loaded from the feed.
912      * This should never return null -- you will either get an object or
913      * an exception will be thrown.
914      *
915      * @param DOMElement $feedEl root element of a loaded Atom feed
916      * @param array $hints additional discovery information passed from higher levels
917      * @fixme should this be marked public?
918      * @return Ostatus_profile
919      * @throws Exception
920      */
921     public static function ensureAtomFeed($feedEl, $hints)
922     {
923         // Try to get a profile from the feed activity:subject
924
925         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
926
927         if (!empty($subject)) {
928             $subjObject = new ActivityObject($subject);
929             return self::ensureActivityObjectProfile($subjObject, $hints);
930         }
931
932         // Otherwise, try the feed author
933
934         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
935
936         if (!empty($author)) {
937             $authorObject = new ActivityObject($author);
938             return self::ensureActivityObjectProfile($authorObject, $hints);
939         }
940
941         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
942         // entries.
943
944         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
945
946         if (!empty($entries) && $entries->length > 0) {
947
948             $entry = $entries->item(0);
949
950             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
951
952             if (!empty($actor)) {
953                 $actorObject = new ActivityObject($actor);
954                 return self::ensureActivityObjectProfile($actorObject, $hints);
955
956             }
957
958             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
959
960             if (!empty($author)) {
961                 $authorObject = new ActivityObject($author);
962                 return self::ensureActivityObjectProfile($authorObject, $hints);
963             }
964         }
965
966         // XXX: make some educated guesses here
967
968         throw new FeedSubException("Can't find enough profile information to make a feed.");
969     }
970
971     /**
972      * Look up and, if necessary, create an Ostatus_profile for the remote
973      * profile with the given RSS feed - actually loaded from the feed.
974      * This should never return null -- you will either get an object or
975      * an exception will be thrown.
976      *
977      * @param DOMElement $feedEl root element of a loaded RSS feed
978      * @param array $hints additional discovery information passed from higher levels
979      * @fixme should this be marked public?
980      * @return Ostatus_profile
981      * @throws Exception
982      */
983     public static function ensureRssChannel($feedEl, $hints)
984     {
985         // Special-case for Posterous. They have some nice metadata in their
986         // posterous:author elements. We should use them instead of the channel.
987
988         $items = $feedEl->getElementsByTagName('item');
989
990         if ($items->length > 0) {
991             $item = $items->item(0);
992             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
993             if (!empty($authorEl)) {
994                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
995                 // Posterous has multiple authors per feed, and multiple feeds
996                 // per author. We check if this is the "main" feed for this author.
997                 if (array_key_exists('profileurl', $hints) &&
998                     !empty($obj->poco) &&
999                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
1000                     return self::ensureActivityObjectProfile($obj, $hints);
1001                 }
1002             }
1003         }
1004
1005         // @fixme we should check whether this feed has elements
1006         // with different <author> or <dc:creator> elements, and... I dunno.
1007         // Do something about that.
1008
1009         $obj = ActivityObject::fromRssChannel($feedEl);
1010
1011         return self::ensureActivityObjectProfile($obj, $hints);
1012     }
1013
1014     /**
1015      * Download and update given avatar image
1016      *
1017      * @param string $url
1018      * @throws Exception in various failure cases
1019      */
1020     protected function updateAvatar($url)
1021     {
1022         if ($url == $this->avatar) {
1023             // We've already got this one.
1024             return;
1025         }
1026         if (!common_valid_http_url($url)) {
1027             throw new ServerException(sprintf(_m("Invalid avatar URL %s"), $url));
1028         }
1029
1030         if ($this->isGroup()) {
1031             $self = $this->localGroup();
1032         } else {
1033             $self = $this->localProfile();
1034         }
1035         if (!$self) {
1036             throw new ServerException(sprintf(
1037                 _m("Tried to update avatar for unsaved remote profile %s"),
1038                 $this->uri));
1039         }
1040
1041         // @fixme this should be better encapsulated
1042         // ripped from oauthstore.php (for old OMB client)
1043         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1044         if (!copy($url, $temp_filename)) {
1045             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
1046         }
1047
1048         if ($this->isGroup()) {
1049             $id = $this->group_id;
1050         } else {
1051             $id = $this->profile_id;
1052         }
1053         // @fixme should we be using different ids?
1054         $imagefile = new ImageFile($id, $temp_filename);
1055         $filename = Avatar::filename($id,
1056                                      image_type_to_extension($imagefile->type),
1057                                      null,
1058                                      common_timestamp());
1059         rename($temp_filename, Avatar::path($filename));
1060         $self->setOriginal($filename);
1061
1062         $orig = clone($this);
1063         $this->avatar = $url;
1064         $this->update($orig);
1065     }
1066
1067     /**
1068      * Pull avatar URL from ActivityObject or profile hints
1069      *
1070      * @param ActivityObject $object
1071      * @param array $hints
1072      * @return mixed URL string or false
1073      */
1074
1075     protected static function getActivityObjectAvatar($object, $hints=array())
1076     {
1077         if ($object->avatarLinks) {
1078             $best = false;
1079             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1080             foreach ($object->avatarLinks as $avatar) {
1081                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1082                     // Exact match!
1083                     $best = $avatar;
1084                     break;
1085                 }
1086                 if (!$best || $avatar->width > $best->width) {
1087                     $best = $avatar;
1088                 }
1089             }
1090             return $best->url;
1091         } else if (array_key_exists('avatar', $hints)) {
1092             return $hints['avatar'];
1093         }
1094         return false;
1095     }
1096
1097     /**
1098      * Get an appropriate avatar image source URL, if available.
1099      *
1100      * @param ActivityObject $actor
1101      * @param DOMElement $feed
1102      * @return string
1103      */
1104
1105     protected static function getAvatar($actor, $feed)
1106     {
1107         $url = '';
1108         $icon = '';
1109         if ($actor->avatar) {
1110             $url = trim($actor->avatar);
1111         }
1112         if (!$url) {
1113             // Check <atom:logo> and <atom:icon> on the feed
1114             $els = $feed->childNodes();
1115             if ($els && $els->length) {
1116                 for ($i = 0; $i < $els->length; $i++) {
1117                     $el = $els->item($i);
1118                     if ($el->namespaceURI == Activity::ATOM) {
1119                         if (empty($url) && $el->localName == 'logo') {
1120                             $url = trim($el->textContent);
1121                             break;
1122                         }
1123                         if (empty($icon) && $el->localName == 'icon') {
1124                             // Use as a fallback
1125                             $icon = trim($el->textContent);
1126                         }
1127                     }
1128                 }
1129             }
1130             if ($icon && !$url) {
1131                 $url = $icon;
1132             }
1133         }
1134         if ($url) {
1135             $opts = array('allowed_schemes' => array('http', 'https'));
1136             if (Validate::uri($url, $opts)) {
1137                 return $url;
1138             }
1139         }
1140         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1141     }
1142
1143     /**
1144      * Fetch, or build if necessary, an Ostatus_profile for the actor
1145      * in a given Activity Streams activity.
1146      * This should never return null -- you will either get an object or
1147      * an exception will be thrown.
1148      *
1149      * @param Activity $activity
1150      * @param string $feeduri if we already know the canonical feed URI!
1151      * @param string $salmonuri if we already know the salmon return channel URI
1152      * @return Ostatus_profile
1153      * @throws Exception
1154      */
1155
1156     public static function ensureActorProfile($activity, $hints=array())
1157     {
1158         return self::ensureActivityObjectProfile($activity->actor, $hints);
1159     }
1160
1161     /**
1162      * Fetch, or build if necessary, an Ostatus_profile for the profile
1163      * in a given Activity Streams object (can be subject, actor, or object).
1164      * This should never return null -- you will either get an object or
1165      * an exception will be thrown.
1166      *
1167      * @param ActivityObject $object
1168      * @param array $hints additional discovery information passed from higher levels
1169      * @return Ostatus_profile
1170      * @throws Exception
1171      */
1172
1173     public static function ensureActivityObjectProfile($object, $hints=array())
1174     {
1175         $profile = self::getActivityObjectProfile($object);
1176         if ($profile) {
1177             $profile->updateFromActivityObject($object, $hints);
1178         } else {
1179             $profile = self::createActivityObjectProfile($object, $hints);
1180         }
1181         return $profile;
1182     }
1183
1184     /**
1185      * @param Activity $activity
1186      * @return mixed matching Ostatus_profile or false if none known
1187      * @throws ServerException if feed info invalid
1188      */
1189     public static function getActorProfile($activity)
1190     {
1191         return self::getActivityObjectProfile($activity->actor);
1192     }
1193
1194     /**
1195      * @param ActivityObject $activity
1196      * @return mixed matching Ostatus_profile or false if none known
1197      * @throws ServerException if feed info invalid
1198      */
1199     protected static function getActivityObjectProfile($object)
1200     {
1201         $uri = self::getActivityObjectProfileURI($object);
1202         return Ostatus_profile::staticGet('uri', $uri);
1203     }
1204
1205     /**
1206      * Get the identifier URI for the remote entity described
1207      * by this ActivityObject. This URI is *not* guaranteed to be
1208      * a resolvable HTTP/HTTPS URL.
1209      *
1210      * @param ActivityObject $object
1211      * @return string
1212      * @throws ServerException if feed info invalid
1213      */
1214     protected static function getActivityObjectProfileURI($object)
1215     {
1216         if ($object->id) {
1217             if (ActivityUtils::validateUri($object->id)) {
1218                 return $object->id;
1219             }
1220         }
1221
1222         // If the id is missing or invalid (we've seen feeds mistakenly listing
1223         // things like local usernames in that field) then we'll use the profile
1224         // page link, if valid.
1225         if ($object->link && common_valid_http_url($object->link)) {
1226             return $object->link;
1227         }
1228         throw new ServerException("No author ID URI found");
1229     }
1230
1231     /**
1232      * @fixme validate stuff somewhere
1233      */
1234
1235     /**
1236      * Create local ostatus_profile and profile/user_group entries for
1237      * the provided remote user or group.
1238      * This should never return null -- you will either get an object or
1239      * an exception will be thrown.
1240      *
1241      * @param ActivityObject $object
1242      * @param array $hints
1243      *
1244      * @return Ostatus_profile
1245      */
1246     protected static function createActivityObjectProfile($object, $hints=array())
1247     {
1248         $homeuri = $object->id;
1249         $discover = false;
1250
1251         if (!$homeuri) {
1252             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1253             throw new Exception("No profile URI");
1254         }
1255
1256         $user = User::staticGet('uri', $homeuri);
1257         if ($user) {
1258             throw new Exception("Local user can't be referenced as remote.");
1259         }
1260
1261         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1262             throw new Exception("Local group can't be referenced as remote.");
1263         }
1264
1265         if (array_key_exists('feedurl', $hints)) {
1266             $feeduri = $hints['feedurl'];
1267         } else {
1268             $discover = new FeedDiscovery();
1269             $feeduri = $discover->discoverFromURL($homeuri);
1270         }
1271
1272         if (array_key_exists('salmon', $hints)) {
1273             $salmonuri = $hints['salmon'];
1274         } else {
1275             if (!$discover) {
1276                 $discover = new FeedDiscovery();
1277                 $discover->discoverFromFeedURL($hints['feedurl']);
1278             }
1279             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1280         }
1281
1282         if (array_key_exists('hub', $hints)) {
1283             $huburi = $hints['hub'];
1284         } else {
1285             if (!$discover) {
1286                 $discover = new FeedDiscovery();
1287                 $discover->discoverFromFeedURL($hints['feedurl']);
1288             }
1289             $huburi = $discover->getHubLink();
1290         }
1291
1292         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1293             // We can only deal with folks with a PuSH hub
1294             throw new FeedSubNoHubException();
1295         }
1296
1297         $oprofile = new Ostatus_profile();
1298
1299         $oprofile->uri        = $homeuri;
1300         $oprofile->feeduri    = $feeduri;
1301         $oprofile->salmonuri  = $salmonuri;
1302
1303         $oprofile->created    = common_sql_now();
1304         $oprofile->modified   = common_sql_now();
1305
1306         if ($object->type == ActivityObject::PERSON) {
1307             $profile = new Profile();
1308             $profile->created = common_sql_now();
1309             self::updateProfile($profile, $object, $hints);
1310
1311             $oprofile->profile_id = $profile->insert();
1312             if (!$oprofile->profile_id) {
1313                 throw new ServerException("Can't save local profile");
1314             }
1315         } else {
1316             $group = new User_group();
1317             $group->uri = $homeuri;
1318             $group->created = common_sql_now();
1319             self::updateGroup($group, $object, $hints);
1320
1321             $oprofile->group_id = $group->insert();
1322             if (!$oprofile->group_id) {
1323                 throw new ServerException("Can't save local profile");
1324             }
1325         }
1326
1327         $ok = $oprofile->insert();
1328
1329         if (!$ok) {
1330             throw new ServerException("Can't save OStatus profile");
1331         }
1332
1333         $avatar = self::getActivityObjectAvatar($object, $hints);
1334
1335         if ($avatar) {
1336             try {
1337                 $oprofile->updateAvatar($avatar);
1338             } catch (Exception $ex) {
1339                 // Profile is saved, but Avatar is messed up. We're
1340                 // just going to continue.
1341                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1342             }
1343         }
1344
1345         return $oprofile;
1346     }
1347
1348     /**
1349      * Save any updated profile information to our local copy.
1350      * @param ActivityObject $object
1351      * @param array $hints
1352      */
1353     public function updateFromActivityObject($object, $hints=array())
1354     {
1355         if ($this->isGroup()) {
1356             $group = $this->localGroup();
1357             self::updateGroup($group, $object, $hints);
1358         } else {
1359             $profile = $this->localProfile();
1360             self::updateProfile($profile, $object, $hints);
1361         }
1362         $avatar = self::getActivityObjectAvatar($object, $hints);
1363         if ($avatar) {
1364             try {
1365                 $this->updateAvatar($avatar);
1366             } catch (Exception $ex) {
1367                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1368             }
1369         }
1370     }
1371
1372     protected static function updateProfile($profile, $object, $hints=array())
1373     {
1374         $orig = clone($profile);
1375
1376         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1377
1378         if (!empty($object->title)) {
1379             $profile->fullname = $object->title;
1380         } else if (array_key_exists('fullname', $hints)) {
1381             $profile->fullname = $hints['fullname'];
1382         }
1383
1384         if (!empty($object->link)) {
1385             $profile->profileurl = $object->link;
1386         } else if (array_key_exists('profileurl', $hints)) {
1387             $profile->profileurl = $hints['profileurl'];
1388         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1389             $profile->profileurl = $object->id;
1390         }
1391
1392         $profile->bio      = self::getActivityObjectBio($object, $hints);
1393         $profile->location = self::getActivityObjectLocation($object, $hints);
1394         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1395
1396         if (!empty($object->geopoint)) {
1397             $location = ActivityContext::locationFromPoint($object->geopoint);
1398             if (!empty($location)) {
1399                 $profile->lat = $location->lat;
1400                 $profile->lon = $location->lon;
1401             }
1402         }
1403
1404         // @fixme tags/categories
1405         // @todo tags from categories
1406
1407         if ($profile->id) {
1408             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1409             $profile->update($orig);
1410         }
1411     }
1412
1413     protected static function updateGroup($group, $object, $hints=array())
1414     {
1415         $orig = clone($group);
1416
1417         $group->nickname = self::getActivityObjectNickname($object, $hints);
1418         $group->fullname = $object->title;
1419
1420         if (!empty($object->link)) {
1421             $group->mainpage = $object->link;
1422         } else if (array_key_exists('profileurl', $hints)) {
1423             $group->mainpage = $hints['profileurl'];
1424         }
1425
1426         // @todo tags from categories
1427         $group->description = self::getActivityObjectBio($object, $hints);
1428         $group->location = self::getActivityObjectLocation($object, $hints);
1429         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1430
1431         if ($group->id) {
1432             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1433             $group->update($orig);
1434         }
1435     }
1436
1437     protected static function getActivityObjectHomepage($object, $hints=array())
1438     {
1439         $homepage = null;
1440         $poco     = $object->poco;
1441
1442         if (!empty($poco)) {
1443             $url = $poco->getPrimaryURL();
1444             if ($url && $url->type == 'homepage') {
1445                 $homepage = $url->value;
1446             }
1447         }
1448
1449         // @todo Try for a another PoCo URL?
1450
1451         return $homepage;
1452     }
1453
1454     protected static function getActivityObjectLocation($object, $hints=array())
1455     {
1456         $location = null;
1457
1458         if (!empty($object->poco) &&
1459             isset($object->poco->address->formatted)) {
1460             $location = $object->poco->address->formatted;
1461         } else if (array_key_exists('location', $hints)) {
1462             $location = $hints['location'];
1463         }
1464
1465         if (!empty($location)) {
1466             if (mb_strlen($location) > 255) {
1467                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1468             }
1469         }
1470
1471         // @todo Try to find location some othe way? Via goerss point?
1472
1473         return $location;
1474     }
1475
1476     protected static function getActivityObjectBio($object, $hints=array())
1477     {
1478         $bio  = null;
1479
1480         if (!empty($object->poco)) {
1481             $note = $object->poco->note;
1482         } else if (array_key_exists('bio', $hints)) {
1483             $note = $hints['bio'];
1484         }
1485
1486         if (!empty($note)) {
1487             if (Profile::bioTooLong($note)) {
1488                 // XXX: truncate ok?
1489                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1490             } else {
1491                 $bio = $note;
1492             }
1493         }
1494
1495         // @todo Try to get bio info some other way?
1496
1497         return $bio;
1498     }
1499
1500     protected static function getActivityObjectNickname($object, $hints=array())
1501     {
1502         if ($object->poco) {
1503             if (!empty($object->poco->preferredUsername)) {
1504                 return common_nicknamize($object->poco->preferredUsername);
1505             }
1506         }
1507
1508         if (!empty($object->nickname)) {
1509             return common_nicknamize($object->nickname);
1510         }
1511
1512         if (array_key_exists('nickname', $hints)) {
1513             return $hints['nickname'];
1514         }
1515
1516         // Try the profile url (like foo.example.com or example.com/user/foo)
1517
1518         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1519
1520         if (!empty($profileUrl)) {
1521             $nickname = self::nicknameFromURI($profileUrl);
1522         }
1523
1524         // Try the URI (may be a tag:, http:, acct:, ...
1525
1526         if (empty($nickname)) {
1527             $nickname = self::nicknameFromURI($object->id);
1528         }
1529
1530         // Try a Webfinger if one was passed (way) down
1531
1532         if (empty($nickname)) {
1533             if (array_key_exists('webfinger', $hints)) {
1534                 $nickname = self::nicknameFromURI($hints['webfinger']);
1535             }
1536         }
1537
1538         // Try the name
1539
1540         if (empty($nickname)) {
1541             $nickname = common_nicknamize($object->title);
1542         }
1543
1544         return $nickname;
1545     }
1546
1547     protected static function nicknameFromURI($uri)
1548     {
1549         preg_match('/(\w+):/', $uri, $matches);
1550
1551         $protocol = $matches[1];
1552
1553         switch ($protocol) {
1554         case 'acct':
1555         case 'mailto':
1556             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1557                 return common_canonical_nickname($matches[1]);
1558             }
1559             return null;
1560         case 'http':
1561             return common_url_to_nickname($uri);
1562             break;
1563         default:
1564             return null;
1565         }
1566     }
1567
1568     /**
1569      * Look up, and if necessary create, an Ostatus_profile for the remote
1570      * entity with the given webfinger address.
1571      * This should never return null -- you will either get an object or
1572      * an exception will be thrown.
1573      *
1574      * @param string $addr webfinger address
1575      * @return Ostatus_profile
1576      * @throws Exception on error conditions
1577      * @throws OStatusShadowException if this reference would obscure a local user/group
1578      */
1579     public static function ensureWebfinger($addr)
1580     {
1581         // First, try the cache
1582
1583         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1584
1585         if ($uri !== false) {
1586             if (is_null($uri)) {
1587                 // Negative cache entry
1588                 throw new Exception('Not a valid webfinger address.');
1589             }
1590             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1591             if (!empty($oprofile)) {
1592                 return $oprofile;
1593             }
1594         }
1595
1596         // Try looking it up
1597
1598         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1599
1600         if (!empty($oprofile)) {
1601             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1602             return $oprofile;
1603         }
1604
1605         // Now, try some discovery
1606
1607         $disco = new Discovery();
1608
1609         try {
1610             $xrd = $disco->lookup($addr);
1611         } catch (Exception $e) {
1612             // Save negative cache entry so we don't waste time looking it up again.
1613             // @fixme distinguish temporary failures?
1614             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1615             throw new Exception('Not a valid webfinger address.');
1616         }
1617
1618         $hints = array('webfinger' => $addr);
1619
1620         $dhints = DiscoveryHints::fromXRD($xrd);
1621
1622         $hints = array_merge($hints, $dhints);
1623
1624         // If there's an Hcard, let's grab its info
1625
1626         if (array_key_exists('hcard', $hints)) {
1627             if (!array_key_exists('profileurl', $hints) ||
1628                 $hints['hcard'] != $hints['profileurl']) {
1629                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1630                 $hints = array_merge($hcardHints, $hints);
1631             }
1632         }
1633
1634         // If we got a feed URL, try that
1635
1636         if (array_key_exists('feedurl', $hints)) {
1637             try {
1638                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1639                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1640                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1641                 return $oprofile;
1642             } catch (Exception $e) {
1643                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1644                 // keep looking
1645             }
1646         }
1647
1648         // If we got a profile page, try that!
1649
1650         if (array_key_exists('profileurl', $hints)) {
1651             try {
1652                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1653                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1654                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1655                 return $oprofile;
1656             } catch (OStatusShadowException $e) {
1657                 // We've ended up with a remote reference to a local user or group.
1658                 // @fixme ideally we should be able to say who it was so we can
1659                 // go back and refer to it the regular way
1660                 throw $e;
1661             } catch (Exception $e) {
1662                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1663                 // keep looking
1664                 //
1665                 // @fixme this means an error discovering from profile page
1666                 // may give us a corrupt entry using the webfinger URI, which
1667                 // will obscure the correct page-keyed profile later on.
1668             }
1669         }
1670
1671         // XXX: try hcard
1672         // XXX: try FOAF
1673
1674         if (array_key_exists('salmon', $hints)) {
1675
1676             $salmonEndpoint = $hints['salmon'];
1677
1678             // An account URL, a salmon endpoint, and a dream? Not much to go
1679             // on, but let's give it a try
1680
1681             $uri = 'acct:'.$addr;
1682
1683             $profile = new Profile();
1684
1685             $profile->nickname = self::nicknameFromUri($uri);
1686             $profile->created  = common_sql_now();
1687
1688             if (isset($profileUrl)) {
1689                 $profile->profileurl = $profileUrl;
1690             }
1691
1692             $profile_id = $profile->insert();
1693
1694             if (!$profile_id) {
1695                 common_log_db_error($profile, 'INSERT', __FILE__);
1696                 throw new Exception("Couldn't save profile for '$addr'");
1697             }
1698
1699             $oprofile = new Ostatus_profile();
1700
1701             $oprofile->uri        = $uri;
1702             $oprofile->salmonuri  = $salmonEndpoint;
1703             $oprofile->profile_id = $profile_id;
1704             $oprofile->created    = common_sql_now();
1705
1706             if (isset($feedUrl)) {
1707                 $profile->feeduri = $feedUrl;
1708             }
1709
1710             $result = $oprofile->insert();
1711
1712             if (!$result) {
1713                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1714                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1715             }
1716
1717             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1718             return $oprofile;
1719         }
1720
1721         throw new Exception("Couldn't find a valid profile for '$addr'");
1722     }
1723
1724     /**
1725      * Store the full-length scrubbed HTML of a remote notice to an attachment
1726      * file on our server. We'll link to this at the end of the cropped version.
1727      *
1728      * @param string $title plaintext for HTML page's title
1729      * @param string $rendered HTML fragment for HTML page's body
1730      * @return File
1731      */
1732     function saveHTMLFile($title, $rendered)
1733     {
1734         $final = sprintf("<!DOCTYPE html>\n" .
1735                          '<html><head>' .
1736                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
1737                          '<title>%s</title>' .
1738                          '</head>' .
1739                          '<body>%s</body></html>',
1740                          htmlspecialchars($title),
1741                          $rendered);
1742
1743         $filename = File::filename($this->localProfile(),
1744                                    'ostatus', // ignored?
1745                                    'text/html');
1746
1747         $filepath = File::path($filename);
1748
1749         file_put_contents($filepath, $final);
1750
1751         $file = new File;
1752
1753         $file->filename = $filename;
1754         $file->url      = File::url($filename);
1755         $file->size     = filesize($filepath);
1756         $file->date     = time();
1757         $file->mimetype = 'text/html';
1758
1759         $file_id = $file->insert();
1760
1761         if ($file_id === false) {
1762             common_log_db_error($file, "INSERT", __FILE__);
1763             throw new ServerException(_('Could not store HTML content of long post as file.'));
1764         }
1765
1766         return $file;
1767     }
1768
1769     static function ensureProfileURI($uri)
1770     {
1771         $oprofile = null;
1772
1773         if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
1774             $protocol = $match[1];
1775             switch ($protocol) {
1776             case 'http':
1777             case 'https':
1778                 $oprofile = Ostatus_profile::ensureProfileURL($uri);
1779                 break;
1780             case 'acct':
1781             case 'mailto':
1782                 $rest = $match[2];
1783                 $oprofile = Ostatus_profile::ensureWebfinger($rest);
1784             default:
1785                 common_log("Unrecognized URI protocol for profile: $protocol ($uri)");
1786                 break;
1787             }
1788         }
1789     }
1790 }
1791
1792 /**
1793  * Exception indicating we've got a remote reference to a local user,
1794  * not a remote user!
1795  *
1796  * If we can ue a local profile after all, it's available as $e->profile.
1797  */
1798 class OStatusShadowException extends Exception
1799 {
1800     public $profile;
1801
1802     /**
1803      * @param Profile $profile
1804      * @param string $message
1805      */
1806     function __construct($profile, $message) {
1807         $this->profile = $profile;
1808         parent::__construct($message);
1809     }
1810 }
1811