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