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