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