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