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