]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch 'swat0' 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::ensureProfileURI($recipient);
704
705             if ($oprofile) {
706                 if ($oprofile->isGroup()) {
707                     // Deliver to local members of this remote group.
708                     // @fixme sender verification?
709                     $groups[] = $oprofile->group_id;
710                 } else {
711                     // may be canonicalized or something
712                     $replies[] = $oprofile->uri;
713                 }
714                 continue;
715             }
716
717             // Is the recipient a local group?
718             // @fixme uri on user_group isn't reliable yet
719             // $group = User_group::staticGet('uri', $recipient);
720             $id = OStatusPlugin::localGroupFromUrl($recipient);
721             if ($id) {
722                 $group = User_group::staticGet('id', $id);
723                 if ($group) {
724                     // Deliver to all members of this local group if allowed.
725                     $profile = $sender->localProfile();
726                     if ($profile->isMember($group)) {
727                         $groups[] = $group->id;
728                     } else {
729                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
730                     }
731                     continue;
732                 } else {
733                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
734                 }
735             }
736
737             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
738
739         }
740         $attention_uris = $replies;
741         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
742         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
743         return $groups;
744     }
745
746     /**
747      * Look up and if necessary create an Ostatus_profile for the remote entity
748      * with the given profile page URL. This should never return null -- you
749      * will either get an object or an exception will be thrown.
750      *
751      * @param string $profile_url
752      * @return Ostatus_profile
753      * @throws Exception on various error conditions
754      * @throws OStatusShadowException if this reference would obscure a local user/group
755      */
756
757     public static function ensureProfileURL($profile_url, $hints=array())
758     {
759         $oprofile = self::getFromProfileURL($profile_url);
760
761         if (!empty($oprofile)) {
762             return $oprofile;
763         }
764
765         $hints['profileurl'] = $profile_url;
766
767         // Fetch the URL
768         // XXX: HTTP caching
769
770         $client = new HTTPClient();
771         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
772         $response = $client->get($profile_url);
773
774         if (!$response->isOk()) {
775             throw new Exception("Could not reach profile page: " . $profile_url);
776         }
777
778         // Check if we have a non-canonical URL
779
780         $finalUrl = $response->getUrl();
781
782         if ($finalUrl != $profile_url) {
783
784             $hints['profileurl'] = $finalUrl;
785
786             $oprofile = self::getFromProfileURL($finalUrl);
787
788             if (!empty($oprofile)) {
789                 return $oprofile;
790             }
791         }
792
793         // Try to get some hCard data
794
795         $body = $response->getBody();
796
797         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
798
799         if (!empty($hcardHints)) {
800             $hints = array_merge($hints, $hcardHints);
801         }
802
803         // Check if they've got an LRDD header
804
805         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
806
807         if (!empty($lrdd)) {
808
809             $xrd = Discovery::fetchXrd($lrdd);
810             $xrdHints = DiscoveryHints::fromXRD($xrd);
811
812             $hints = array_merge($hints, $xrdHints);
813         }
814
815         // If discovery found a feedurl (probably from LRDD), use it.
816
817         if (array_key_exists('feedurl', $hints)) {
818             return self::ensureFeedURL($hints['feedurl'], $hints);
819         }
820
821         // Get the feed URL from HTML
822
823         $discover = new FeedDiscovery();
824
825         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
826
827         if (!empty($feedurl)) {
828             $hints['feedurl'] = $feedurl;
829             return self::ensureFeedURL($feedurl, $hints);
830         }
831
832         throw new Exception("Could not find a feed URL for profile page " . $finalUrl);
833     }
834
835     /**
836      * Look up the Ostatus_profile, if present, for a remote entity with the
837      * given profile page URL. Will return null for both unknown and invalid
838      * remote profiles.
839      *
840      * @return mixed Ostatus_profile or null
841      * @throws OStatusShadowException for local profiles
842      */
843     static function getFromProfileURL($profile_url)
844     {
845         $profile = Profile::staticGet('profileurl', $profile_url);
846
847         if (empty($profile)) {
848             return null;
849         }
850
851         // Is it a known Ostatus profile?
852
853         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
854
855         if (!empty($oprofile)) {
856             return $oprofile;
857         }
858
859         // Is it a local user?
860
861         $user = User::staticGet('id', $profile->id);
862
863         if (!empty($user)) {
864             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
865         }
866
867         // Continue discovery; it's a remote profile
868         // for OMB or some other protocol, may also
869         // support OStatus
870
871         return null;
872     }
873
874     /**
875      * Look up and if necessary create an Ostatus_profile for remote entity
876      * with the given update feed. This should never return null -- you will
877      * either get an object or an exception will be thrown.
878      *
879      * @return Ostatus_profile
880      * @throws Exception
881      */
882     public static function ensureFeedURL($feed_url, $hints=array())
883     {
884         $discover = new FeedDiscovery();
885
886         $feeduri = $discover->discoverFromFeedURL($feed_url);
887         $hints['feedurl'] = $feeduri;
888
889         $huburi = $discover->getHubLink();
890         $hints['hub'] = $huburi;
891         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
892         $hints['salmon'] = $salmonuri;
893
894         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
895             // We can only deal with folks with a PuSH hub
896             throw new FeedSubNoHubException();
897         }
898
899         $feedEl = $discover->root;
900
901         if ($feedEl->tagName == 'feed') {
902             return self::ensureAtomFeed($feedEl, $hints);
903         } else if ($feedEl->tagName == 'channel') {
904             return self::ensureRssChannel($feedEl, $hints);
905         } else {
906             throw new FeedSubBadXmlException($feeduri);
907         }
908     }
909
910     /**
911      * Look up and, if necessary, create an Ostatus_profile for the remote
912      * profile with the given Atom feed - actually loaded from the feed.
913      * This should never return null -- you will either get an object or
914      * an exception will be thrown.
915      *
916      * @param DOMElement $feedEl root element of a loaded Atom feed
917      * @param array $hints additional discovery information passed from higher levels
918      * @fixme should this be marked public?
919      * @return Ostatus_profile
920      * @throws Exception
921      */
922     public static function ensureAtomFeed($feedEl, $hints)
923     {
924         // Try to get a profile from the feed activity:subject
925
926         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
927
928         if (!empty($subject)) {
929             $subjObject = new ActivityObject($subject);
930             return self::ensureActivityObjectProfile($subjObject, $hints);
931         }
932
933         // Otherwise, try the feed author
934
935         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
936
937         if (!empty($author)) {
938             $authorObject = new ActivityObject($author);
939             return self::ensureActivityObjectProfile($authorObject, $hints);
940         }
941
942         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
943         // entries.
944
945         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
946
947         if (!empty($entries) && $entries->length > 0) {
948
949             $entry = $entries->item(0);
950
951             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
952
953             if (!empty($actor)) {
954                 $actorObject = new ActivityObject($actor);
955                 return self::ensureActivityObjectProfile($actorObject, $hints);
956
957             }
958
959             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
960
961             if (!empty($author)) {
962                 $authorObject = new ActivityObject($author);
963                 return self::ensureActivityObjectProfile($authorObject, $hints);
964             }
965         }
966
967         // XXX: make some educated guesses here
968
969         throw new FeedSubException("Can't find enough profile information to make a feed.");
970     }
971
972     /**
973      * Look up and, if necessary, create an Ostatus_profile for the remote
974      * profile with the given RSS feed - actually loaded from the feed.
975      * This should never return null -- you will either get an object or
976      * an exception will be thrown.
977      *
978      * @param DOMElement $feedEl root element of a loaded RSS feed
979      * @param array $hints additional discovery information passed from higher levels
980      * @fixme should this be marked public?
981      * @return Ostatus_profile
982      * @throws Exception
983      */
984     public static function ensureRssChannel($feedEl, $hints)
985     {
986         // Special-case for Posterous. They have some nice metadata in their
987         // posterous:author elements. We should use them instead of the channel.
988
989         $items = $feedEl->getElementsByTagName('item');
990
991         if ($items->length > 0) {
992             $item = $items->item(0);
993             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
994             if (!empty($authorEl)) {
995                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
996                 // Posterous has multiple authors per feed, and multiple feeds
997                 // per author. We check if this is the "main" feed for this author.
998                 if (array_key_exists('profileurl', $hints) &&
999                     !empty($obj->poco) &&
1000                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
1001                     return self::ensureActivityObjectProfile($obj, $hints);
1002                 }
1003             }
1004         }
1005
1006         // @fixme we should check whether this feed has elements
1007         // with different <author> or <dc:creator> elements, and... I dunno.
1008         // Do something about that.
1009
1010         $obj = ActivityObject::fromRssChannel($feedEl);
1011
1012         return self::ensureActivityObjectProfile($obj, $hints);
1013     }
1014
1015     /**
1016      * Download and update given avatar image
1017      *
1018      * @param string $url
1019      * @throws Exception in various failure cases
1020      */
1021     protected function updateAvatar($url)
1022     {
1023         if ($url == $this->avatar) {
1024             // We've already got this one.
1025             return;
1026         }
1027         if (!common_valid_http_url($url)) {
1028             throw new ServerException(sprintf(_m("Invalid avatar URL %s"), $url));
1029         }
1030
1031         if ($this->isGroup()) {
1032             $self = $this->localGroup();
1033         } else {
1034             $self = $this->localProfile();
1035         }
1036         if (!$self) {
1037             throw new ServerException(sprintf(
1038                 _m("Tried to update avatar for unsaved remote profile %s"),
1039                 $this->uri));
1040         }
1041
1042         // @fixme this should be better encapsulated
1043         // ripped from oauthstore.php (for old OMB client)
1044         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1045         if (!copy($url, $temp_filename)) {
1046             throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
1047         }
1048
1049         if ($this->isGroup()) {
1050             $id = $this->group_id;
1051         } else {
1052             $id = $this->profile_id;
1053         }
1054         // @fixme should we be using different ids?
1055         $imagefile = new ImageFile($id, $temp_filename);
1056         $filename = Avatar::filename($id,
1057                                      image_type_to_extension($imagefile->type),
1058                                      null,
1059                                      common_timestamp());
1060         rename($temp_filename, Avatar::path($filename));
1061         $self->setOriginal($filename);
1062
1063         $orig = clone($this);
1064         $this->avatar = $url;
1065         $this->update($orig);
1066     }
1067
1068     /**
1069      * Pull avatar URL from ActivityObject or profile hints
1070      *
1071      * @param ActivityObject $object
1072      * @param array $hints
1073      * @return mixed URL string or false
1074      */
1075
1076     protected static function getActivityObjectAvatar($object, $hints=array())
1077     {
1078         if ($object->avatarLinks) {
1079             $best = false;
1080             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1081             foreach ($object->avatarLinks as $avatar) {
1082                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1083                     // Exact match!
1084                     $best = $avatar;
1085                     break;
1086                 }
1087                 if (!$best || $avatar->width > $best->width) {
1088                     $best = $avatar;
1089                 }
1090             }
1091             return $best->url;
1092         } else if (array_key_exists('avatar', $hints)) {
1093             return $hints['avatar'];
1094         }
1095         return false;
1096     }
1097
1098     /**
1099      * Get an appropriate avatar image source URL, if available.
1100      *
1101      * @param ActivityObject $actor
1102      * @param DOMElement $feed
1103      * @return string
1104      */
1105
1106     protected static function getAvatar($actor, $feed)
1107     {
1108         $url = '';
1109         $icon = '';
1110         if ($actor->avatar) {
1111             $url = trim($actor->avatar);
1112         }
1113         if (!$url) {
1114             // Check <atom:logo> and <atom:icon> on the feed
1115             $els = $feed->childNodes();
1116             if ($els && $els->length) {
1117                 for ($i = 0; $i < $els->length; $i++) {
1118                     $el = $els->item($i);
1119                     if ($el->namespaceURI == Activity::ATOM) {
1120                         if (empty($url) && $el->localName == 'logo') {
1121                             $url = trim($el->textContent);
1122                             break;
1123                         }
1124                         if (empty($icon) && $el->localName == 'icon') {
1125                             // Use as a fallback
1126                             $icon = trim($el->textContent);
1127                         }
1128                     }
1129                 }
1130             }
1131             if ($icon && !$url) {
1132                 $url = $icon;
1133             }
1134         }
1135         if ($url) {
1136             $opts = array('allowed_schemes' => array('http', 'https'));
1137             if (Validate::uri($url, $opts)) {
1138                 return $url;
1139             }
1140         }
1141         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1142     }
1143
1144     /**
1145      * Fetch, or build if necessary, an Ostatus_profile for the actor
1146      * in a given Activity Streams activity.
1147      * This should never return null -- you will either get an object or
1148      * an exception will be thrown.
1149      *
1150      * @param Activity $activity
1151      * @param string $feeduri if we already know the canonical feed URI!
1152      * @param string $salmonuri if we already know the salmon return channel URI
1153      * @return Ostatus_profile
1154      * @throws Exception
1155      */
1156
1157     public static function ensureActorProfile($activity, $hints=array())
1158     {
1159         return self::ensureActivityObjectProfile($activity->actor, $hints);
1160     }
1161
1162     /**
1163      * Fetch, or build if necessary, an Ostatus_profile for the profile
1164      * in a given Activity Streams object (can be subject, actor, or object).
1165      * This should never return null -- you will either get an object or
1166      * an exception will be thrown.
1167      *
1168      * @param ActivityObject $object
1169      * @param array $hints additional discovery information passed from higher levels
1170      * @return Ostatus_profile
1171      * @throws Exception
1172      */
1173
1174     public static function ensureActivityObjectProfile($object, $hints=array())
1175     {
1176         $profile = self::getActivityObjectProfile($object);
1177         if ($profile) {
1178             $profile->updateFromActivityObject($object, $hints);
1179         } else {
1180             $profile = self::createActivityObjectProfile($object, $hints);
1181         }
1182         return $profile;
1183     }
1184
1185     /**
1186      * @param Activity $activity
1187      * @return mixed matching Ostatus_profile or false if none known
1188      * @throws ServerException if feed info invalid
1189      */
1190     public static function getActorProfile($activity)
1191     {
1192         return self::getActivityObjectProfile($activity->actor);
1193     }
1194
1195     /**
1196      * @param ActivityObject $activity
1197      * @return mixed matching Ostatus_profile or false if none known
1198      * @throws ServerException if feed info invalid
1199      */
1200     protected static function getActivityObjectProfile($object)
1201     {
1202         $uri = self::getActivityObjectProfileURI($object);
1203         return Ostatus_profile::staticGet('uri', $uri);
1204     }
1205
1206     /**
1207      * Get the identifier URI for the remote entity described
1208      * by this ActivityObject. This URI is *not* guaranteed to be
1209      * a resolvable HTTP/HTTPS URL.
1210      *
1211      * @param ActivityObject $object
1212      * @return string
1213      * @throws ServerException if feed info invalid
1214      */
1215     protected static function getActivityObjectProfileURI($object)
1216     {
1217         if ($object->id) {
1218             if (ActivityUtils::validateUri($object->id)) {
1219                 return $object->id;
1220             }
1221         }
1222
1223         // If the id is missing or invalid (we've seen feeds mistakenly listing
1224         // things like local usernames in that field) then we'll use the profile
1225         // page link, if valid.
1226         if ($object->link && common_valid_http_url($object->link)) {
1227             return $object->link;
1228         }
1229         throw new ServerException("No author ID URI found");
1230     }
1231
1232     /**
1233      * @fixme validate stuff somewhere
1234      */
1235
1236     /**
1237      * Create local ostatus_profile and profile/user_group entries for
1238      * the provided remote user or group.
1239      * This should never return null -- you will either get an object or
1240      * an exception will be thrown.
1241      *
1242      * @param ActivityObject $object
1243      * @param array $hints
1244      *
1245      * @return Ostatus_profile
1246      */
1247     protected static function createActivityObjectProfile($object, $hints=array())
1248     {
1249         $homeuri = $object->id;
1250         $discover = false;
1251
1252         if (!$homeuri) {
1253             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1254             throw new Exception("No profile URI");
1255         }
1256
1257         $user = User::staticGet('uri', $homeuri);
1258         if ($user) {
1259             throw new Exception("Local user can't be referenced as remote.");
1260         }
1261
1262         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1263             throw new Exception("Local group can't be referenced as remote.");
1264         }
1265
1266         if (array_key_exists('feedurl', $hints)) {
1267             $feeduri = $hints['feedurl'];
1268         } else {
1269             $discover = new FeedDiscovery();
1270             $feeduri = $discover->discoverFromURL($homeuri);
1271         }
1272
1273         if (array_key_exists('salmon', $hints)) {
1274             $salmonuri = $hints['salmon'];
1275         } else {
1276             if (!$discover) {
1277                 $discover = new FeedDiscovery();
1278                 $discover->discoverFromFeedURL($hints['feedurl']);
1279             }
1280             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1281         }
1282
1283         if (array_key_exists('hub', $hints)) {
1284             $huburi = $hints['hub'];
1285         } else {
1286             if (!$discover) {
1287                 $discover = new FeedDiscovery();
1288                 $discover->discoverFromFeedURL($hints['feedurl']);
1289             }
1290             $huburi = $discover->getHubLink();
1291         }
1292
1293         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1294             // We can only deal with folks with a PuSH hub
1295             throw new FeedSubNoHubException();
1296         }
1297
1298         $oprofile = new Ostatus_profile();
1299
1300         $oprofile->uri        = $homeuri;
1301         $oprofile->feeduri    = $feeduri;
1302         $oprofile->salmonuri  = $salmonuri;
1303
1304         $oprofile->created    = common_sql_now();
1305         $oprofile->modified   = common_sql_now();
1306
1307         if ($object->type == ActivityObject::PERSON) {
1308             $profile = new Profile();
1309             $profile->created = common_sql_now();
1310             self::updateProfile($profile, $object, $hints);
1311
1312             $oprofile->profile_id = $profile->insert();
1313             if (!$oprofile->profile_id) {
1314                 throw new ServerException("Can't save local profile");
1315             }
1316         } else {
1317             $group = new User_group();
1318             $group->uri = $homeuri;
1319             $group->created = common_sql_now();
1320             self::updateGroup($group, $object, $hints);
1321
1322             $oprofile->group_id = $group->insert();
1323             if (!$oprofile->group_id) {
1324                 throw new ServerException("Can't save local profile");
1325             }
1326         }
1327
1328         $ok = $oprofile->insert();
1329
1330         if (!$ok) {
1331             throw new ServerException("Can't save OStatus profile");
1332         }
1333
1334         $avatar = self::getActivityObjectAvatar($object, $hints);
1335
1336         if ($avatar) {
1337             try {
1338                 $oprofile->updateAvatar($avatar);
1339             } catch (Exception $ex) {
1340                 // Profile is saved, but Avatar is messed up. We're
1341                 // just going to continue.
1342                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1343             }
1344         }
1345
1346         return $oprofile;
1347     }
1348
1349     /**
1350      * Save any updated profile information to our local copy.
1351      * @param ActivityObject $object
1352      * @param array $hints
1353      */
1354     public function updateFromActivityObject($object, $hints=array())
1355     {
1356         if ($this->isGroup()) {
1357             $group = $this->localGroup();
1358             self::updateGroup($group, $object, $hints);
1359         } else {
1360             $profile = $this->localProfile();
1361             self::updateProfile($profile, $object, $hints);
1362         }
1363         $avatar = self::getActivityObjectAvatar($object, $hints);
1364         if ($avatar) {
1365             try {
1366                 $this->updateAvatar($avatar);
1367             } catch (Exception $ex) {
1368                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1369             }
1370         }
1371     }
1372
1373     protected static function updateProfile($profile, $object, $hints=array())
1374     {
1375         $orig = clone($profile);
1376
1377         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1378
1379         if (!empty($object->title)) {
1380             $profile->fullname = $object->title;
1381         } else if (array_key_exists('fullname', $hints)) {
1382             $profile->fullname = $hints['fullname'];
1383         }
1384
1385         if (!empty($object->link)) {
1386             $profile->profileurl = $object->link;
1387         } else if (array_key_exists('profileurl', $hints)) {
1388             $profile->profileurl = $hints['profileurl'];
1389         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1390             $profile->profileurl = $object->id;
1391         }
1392
1393         $profile->bio      = self::getActivityObjectBio($object, $hints);
1394         $profile->location = self::getActivityObjectLocation($object, $hints);
1395         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1396
1397         if (!empty($object->geopoint)) {
1398             $location = ActivityContext::locationFromPoint($object->geopoint);
1399             if (!empty($location)) {
1400                 $profile->lat = $location->lat;
1401                 $profile->lon = $location->lon;
1402             }
1403         }
1404
1405         // @fixme tags/categories
1406         // @todo tags from categories
1407
1408         if ($profile->id) {
1409             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1410             $profile->update($orig);
1411         }
1412     }
1413
1414     protected static function updateGroup($group, $object, $hints=array())
1415     {
1416         $orig = clone($group);
1417
1418         $group->nickname = self::getActivityObjectNickname($object, $hints);
1419         $group->fullname = $object->title;
1420
1421         if (!empty($object->link)) {
1422             $group->mainpage = $object->link;
1423         } else if (array_key_exists('profileurl', $hints)) {
1424             $group->mainpage = $hints['profileurl'];
1425         }
1426
1427         // @todo tags from categories
1428         $group->description = self::getActivityObjectBio($object, $hints);
1429         $group->location = self::getActivityObjectLocation($object, $hints);
1430         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1431
1432         if ($group->id) {
1433             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1434             $group->update($orig);
1435         }
1436     }
1437
1438     protected static function getActivityObjectHomepage($object, $hints=array())
1439     {
1440         $homepage = null;
1441         $poco     = $object->poco;
1442
1443         if (!empty($poco)) {
1444             $url = $poco->getPrimaryURL();
1445             if ($url && $url->type == 'homepage') {
1446                 $homepage = $url->value;
1447             }
1448         }
1449
1450         // @todo Try for a another PoCo URL?
1451
1452         return $homepage;
1453     }
1454
1455     protected static function getActivityObjectLocation($object, $hints=array())
1456     {
1457         $location = null;
1458
1459         if (!empty($object->poco) &&
1460             isset($object->poco->address->formatted)) {
1461             $location = $object->poco->address->formatted;
1462         } else if (array_key_exists('location', $hints)) {
1463             $location = $hints['location'];
1464         }
1465
1466         if (!empty($location)) {
1467             if (mb_strlen($location) > 255) {
1468                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1469             }
1470         }
1471
1472         // @todo Try to find location some othe way? Via goerss point?
1473
1474         return $location;
1475     }
1476
1477     protected static function getActivityObjectBio($object, $hints=array())
1478     {
1479         $bio  = null;
1480
1481         if (!empty($object->poco)) {
1482             $note = $object->poco->note;
1483         } else if (array_key_exists('bio', $hints)) {
1484             $note = $hints['bio'];
1485         }
1486
1487         if (!empty($note)) {
1488             if (Profile::bioTooLong($note)) {
1489                 // XXX: truncate ok?
1490                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1491             } else {
1492                 $bio = $note;
1493             }
1494         }
1495
1496         // @todo Try to get bio info some other way?
1497
1498         return $bio;
1499     }
1500
1501     protected static function getActivityObjectNickname($object, $hints=array())
1502     {
1503         if ($object->poco) {
1504             if (!empty($object->poco->preferredUsername)) {
1505                 return common_nicknamize($object->poco->preferredUsername);
1506             }
1507         }
1508
1509         if (!empty($object->nickname)) {
1510             return common_nicknamize($object->nickname);
1511         }
1512
1513         if (array_key_exists('nickname', $hints)) {
1514             return $hints['nickname'];
1515         }
1516
1517         // Try the profile url (like foo.example.com or example.com/user/foo)
1518
1519         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1520
1521         if (!empty($profileUrl)) {
1522             $nickname = self::nicknameFromURI($profileUrl);
1523         }
1524
1525         // Try the URI (may be a tag:, http:, acct:, ...
1526
1527         if (empty($nickname)) {
1528             $nickname = self::nicknameFromURI($object->id);
1529         }
1530
1531         // Try a Webfinger if one was passed (way) down
1532
1533         if (empty($nickname)) {
1534             if (array_key_exists('webfinger', $hints)) {
1535                 $nickname = self::nicknameFromURI($hints['webfinger']);
1536             }
1537         }
1538
1539         // Try the name
1540
1541         if (empty($nickname)) {
1542             $nickname = common_nicknamize($object->title);
1543         }
1544
1545         return $nickname;
1546     }
1547
1548     protected static function nicknameFromURI($uri)
1549     {
1550         preg_match('/(\w+):/', $uri, $matches);
1551
1552         $protocol = $matches[1];
1553
1554         switch ($protocol) {
1555         case 'acct':
1556         case 'mailto':
1557             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1558                 return common_canonical_nickname($matches[1]);
1559             }
1560             return null;
1561         case 'http':
1562             return common_url_to_nickname($uri);
1563             break;
1564         default:
1565             return null;
1566         }
1567     }
1568
1569     /**
1570      * Look up, and if necessary create, an Ostatus_profile for the remote
1571      * entity with the given webfinger address.
1572      * This should never return null -- you will either get an object or
1573      * an exception will be thrown.
1574      *
1575      * @param string $addr webfinger address
1576      * @return Ostatus_profile
1577      * @throws Exception on error conditions
1578      * @throws OStatusShadowException if this reference would obscure a local user/group
1579      */
1580     public static function ensureWebfinger($addr)
1581     {
1582         // First, try the cache
1583
1584         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1585
1586         if ($uri !== false) {
1587             if (is_null($uri)) {
1588                 // Negative cache entry
1589                 throw new Exception('Not a valid webfinger address.');
1590             }
1591             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1592             if (!empty($oprofile)) {
1593                 return $oprofile;
1594             }
1595         }
1596
1597         // Try looking it up
1598
1599         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1600
1601         if (!empty($oprofile)) {
1602             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1603             return $oprofile;
1604         }
1605
1606         // Now, try some discovery
1607
1608         $disco = new Discovery();
1609
1610         try {
1611             $xrd = $disco->lookup($addr);
1612         } catch (Exception $e) {
1613             // Save negative cache entry so we don't waste time looking it up again.
1614             // @fixme distinguish temporary failures?
1615             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1616             throw new Exception('Not a valid webfinger address.');
1617         }
1618
1619         $hints = array('webfinger' => $addr);
1620
1621         $dhints = DiscoveryHints::fromXRD($xrd);
1622
1623         $hints = array_merge($hints, $dhints);
1624
1625         // If there's an Hcard, let's grab its info
1626
1627         if (array_key_exists('hcard', $hints)) {
1628             if (!array_key_exists('profileurl', $hints) ||
1629                 $hints['hcard'] != $hints['profileurl']) {
1630                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1631                 $hints = array_merge($hcardHints, $hints);
1632             }
1633         }
1634
1635         // If we got a feed URL, try that
1636
1637         if (array_key_exists('feedurl', $hints)) {
1638             try {
1639                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1640                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1641                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1642                 return $oprofile;
1643             } catch (Exception $e) {
1644                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1645                 // keep looking
1646             }
1647         }
1648
1649         // If we got a profile page, try that!
1650
1651         if (array_key_exists('profileurl', $hints)) {
1652             try {
1653                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1654                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1655                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1656                 return $oprofile;
1657             } catch (OStatusShadowException $e) {
1658                 // We've ended up with a remote reference to a local user or group.
1659                 // @fixme ideally we should be able to say who it was so we can
1660                 // go back and refer to it the regular way
1661                 throw $e;
1662             } catch (Exception $e) {
1663                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1664                 // keep looking
1665                 //
1666                 // @fixme this means an error discovering from profile page
1667                 // may give us a corrupt entry using the webfinger URI, which
1668                 // will obscure the correct page-keyed profile later on.
1669             }
1670         }
1671
1672         // XXX: try hcard
1673         // XXX: try FOAF
1674
1675         if (array_key_exists('salmon', $hints)) {
1676
1677             $salmonEndpoint = $hints['salmon'];
1678
1679             // An account URL, a salmon endpoint, and a dream? Not much to go
1680             // on, but let's give it a try
1681
1682             $uri = 'acct:'.$addr;
1683
1684             $profile = new Profile();
1685
1686             $profile->nickname = self::nicknameFromUri($uri);
1687             $profile->created  = common_sql_now();
1688
1689             if (isset($profileUrl)) {
1690                 $profile->profileurl = $profileUrl;
1691             }
1692
1693             $profile_id = $profile->insert();
1694
1695             if (!$profile_id) {
1696                 common_log_db_error($profile, 'INSERT', __FILE__);
1697                 throw new Exception("Couldn't save profile for '$addr'");
1698             }
1699
1700             $oprofile = new Ostatus_profile();
1701
1702             $oprofile->uri        = $uri;
1703             $oprofile->salmonuri  = $salmonEndpoint;
1704             $oprofile->profile_id = $profile_id;
1705             $oprofile->created    = common_sql_now();
1706
1707             if (isset($feedUrl)) {
1708                 $profile->feeduri = $feedUrl;
1709             }
1710
1711             $result = $oprofile->insert();
1712
1713             if (!$result) {
1714                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1715                 throw new Exception("Couldn't save ostatus_profile for '$addr'");
1716             }
1717
1718             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1719             return $oprofile;
1720         }
1721
1722         throw new Exception("Couldn't find a valid profile for '$addr'");
1723     }
1724
1725     /**
1726      * Store the full-length scrubbed HTML of a remote notice to an attachment
1727      * file on our server. We'll link to this at the end of the cropped version.
1728      *
1729      * @param string $title plaintext for HTML page's title
1730      * @param string $rendered HTML fragment for HTML page's body
1731      * @return File
1732      */
1733     function saveHTMLFile($title, $rendered)
1734     {
1735         $final = sprintf("<!DOCTYPE html>\n" .
1736                          '<html><head>' .
1737                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
1738                          '<title>%s</title>' .
1739                          '</head>' .
1740                          '<body>%s</body></html>',
1741                          htmlspecialchars($title),
1742                          $rendered);
1743
1744         $filename = File::filename($this->localProfile(),
1745                                    'ostatus', // ignored?
1746                                    'text/html');
1747
1748         $filepath = File::path($filename);
1749
1750         file_put_contents($filepath, $final);
1751
1752         $file = new File;
1753
1754         $file->filename = $filename;
1755         $file->url      = File::url($filename);
1756         $file->size     = filesize($filepath);
1757         $file->date     = time();
1758         $file->mimetype = 'text/html';
1759
1760         $file_id = $file->insert();
1761
1762         if ($file_id === false) {
1763             common_log_db_error($file, "INSERT", __FILE__);
1764             throw new ServerException(_('Could not store HTML content of long post as file.'));
1765         }
1766
1767         return $file;
1768     }
1769
1770     static function ensureProfileURI($uri)
1771     {
1772         $oprofile = null;
1773
1774         // First, try to query it
1775
1776         $oprofile = Ostatus_profile::staticGet('uri', $uri);
1777
1778         // If unfound, do discovery stuff
1779
1780         if (empty($oprofile)) {
1781             if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
1782                 $protocol = $match[1];
1783                 switch ($protocol) {
1784                 case 'http':
1785                 case 'https':
1786                     $oprofile = Ostatus_profile::ensureProfileURL($uri);
1787                     break;
1788                 case 'acct':
1789                 case 'mailto':
1790                     $rest = $match[2];
1791                     $oprofile = Ostatus_profile::ensureWebfinger($rest);
1792                 default:
1793                     common_log("Unrecognized URI protocol for profile: $protocol ($uri)");
1794                     break;
1795                 }
1796             }
1797         }
1798         return $oprofile;
1799     }
1800 }
1801
1802 /**
1803  * Exception indicating we've got a remote reference to a local user,
1804  * not a remote user!
1805  *
1806  * If we can ue a local profile after all, it's available as $e->profile.
1807  */
1808 class OStatusShadowException extends Exception
1809 {
1810     public $profile;
1811
1812     /**
1813      * @param Profile $profile
1814      * @param string $message
1815      */
1816     function __construct($profile, $message) {
1817         $this->profile = $profile;
1818         parent::__construct($message);
1819     }
1820 }
1821