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