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