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