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