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