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