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