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