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