]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge remote branch 'gitorious/0.9.x' into 0.9.x
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
1 <?php
2 /*
3  * StatusNet - the distributed open-source microblogging tool
4  * Copyright (C) 2009-2010, StatusNet, Inc.
5  *
6  * This program is free software: you can redistribute it and/or modify
7  * it under the terms of the GNU Affero General Public License as published by
8  * the Free Software Foundation, either version 3 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14  * GNU Affero General Public License for more details.
15  *
16  * You should have received a copy of the GNU Affero General Public License
17  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
18  */
19
20 if (!defined('STATUSNET')) {
21     exit(1);
22 }
23
24 /**
25  * @package OStatusPlugin
26  * @maintainer Brion Vibber <brion@status.net>
27  */
28 class Ostatus_profile extends Memcached_DataObject
29 {
30     public $__table = 'ostatus_profile';
31
32     public $uri;
33
34     public $profile_id;
35     public $group_id;
36
37     public $feeduri;
38     public $salmonuri;
39     public $avatar; // remote URL of the last avatar we saved
40
41     public $created;
42     public $modified;
43
44     public /*static*/ function staticGet($k, $v=null)
45     {
46         return parent::staticGet(__CLASS__, $k, $v);
47     }
48
49     /**
50      * return table definition for DB_DataObject
51      *
52      * DB_DataObject needs to know something about the table to manipulate
53      * instances. This method provides all the DB_DataObject needs to know.
54      *
55      * @return array array of column definitions
56      */
57     function table()
58     {
59         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
60                      'profile_id' => DB_DATAOBJECT_INT,
61                      'group_id' => DB_DATAOBJECT_INT,
62                      'feeduri' => DB_DATAOBJECT_STR,
63                      'salmonuri' =>  DB_DATAOBJECT_STR,
64                      'avatar' =>  DB_DATAOBJECT_STR,
65                      'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL,
66                      'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL);
67     }
68
69     static function schemaDef()
70     {
71         return array(new ColumnDef('uri', 'varchar',
72                                    255, false, 'PRI'),
73                      new ColumnDef('profile_id', 'integer',
74                                    null, true, 'UNI'),
75                      new ColumnDef('group_id', 'integer',
76                                    null, true, 'UNI'),
77                      new ColumnDef('feeduri', 'varchar',
78                                    255, true, 'UNI'),
79                      new ColumnDef('salmonuri', 'text',
80                                    null, true),
81                      new ColumnDef('avatar', 'text',
82                                    null, true),
83                      new ColumnDef('created', 'datetime',
84                                    null, false),
85                      new ColumnDef('modified', 'datetime',
86                                    null, false));
87     }
88
89     /**
90      * return key definitions for DB_DataObject
91      *
92      * DB_DataObject needs to know about keys that the table has; this function
93      * defines them.
94      *
95      * @return array key definitions
96      */
97     function keys()
98     {
99         return array_keys($this->keyTypes());
100     }
101
102     /**
103      * return key definitions for Memcached_DataObject
104      *
105      * Our caching system uses the same key definitions, but uses a different
106      * method to get them.
107      *
108      * @return array key definitions
109      */
110     function keyTypes()
111     {
112         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
113     }
114
115     function sequenceKey()
116     {
117         return array(false, false, false);
118     }
119
120     /**
121      * Fetch the StatusNet-side profile for this feed
122      * @return Profile
123      */
124     public function localProfile()
125     {
126         if ($this->profile_id) {
127             return Profile::staticGet('id', $this->profile_id);
128         }
129         return null;
130     }
131
132     /**
133      * Fetch the StatusNet-side profile for this feed
134      * @return Profile
135      */
136     public function localGroup()
137     {
138         if ($this->group_id) {
139             return User_group::staticGet('id', $this->group_id);
140         }
141         return null;
142     }
143
144     /**
145      * Returns an ActivityObject describing this remote user or group profile.
146      * Can then be used to generate Atom chunks.
147      *
148      * @return ActivityObject
149      */
150     function asActivityObject()
151     {
152         if ($this->isGroup()) {
153             return ActivityObject::fromGroup($this->localGroup());
154         } else {
155             return ActivityObject::fromProfile($this->localProfile());
156         }
157     }
158
159     /**
160      * Returns an XML string fragment with profile information as an
161      * Activity Streams noun object with the given element type.
162      *
163      * Assumes that 'activity' namespace has been previously defined.
164      *
165      * @fixme replace with wrappers on asActivityObject when it's got everything.
166      *
167      * @param string $element one of 'actor', 'subject', 'object', 'target'
168      * @return string
169      */
170     function asActivityNoun($element)
171     {
172         if ($this->isGroup()) {
173             $noun = ActivityObject::fromGroup($this->localGroup());
174             return $noun->asString('activity:' . $element);
175         } else {
176             $noun = ActivityObject::fromProfile($this->localProfile());
177             return $noun->asString('activity:' . $element);
178         }
179     }
180
181     /**
182      * @return boolean true if this is a remote group
183      */
184     function isGroup()
185     {
186         if ($this->profile_id && !$this->group_id) {
187             return false;
188         } else if ($this->group_id && !$this->profile_id) {
189             return true;
190         } else if ($this->group_id && $this->profile_id) {
191             // TRANS: Server exception.
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                 // TRANS: Shown when a notice is longer than supported and/or when attachments are present.
592                 $showMoreText = _m('Show more');
593                 $attachUrl = common_local_url('attachment',
594                                               array('attachment' => $attachment->id));
595                 $rendered = common_render_text($shortSummary) .
596                             '<a href="' . htmlspecialchars($attachUrl) .'"'.
597                             ' class="attachment more"' .
598                             ' title="'. htmlspecialchars($showMoreText) . '">' .
599                             '&#8230;' .
600                             '</a>';
601             }
602         }
603
604         $options = array('is_local' => Notice::REMOTE_OMB,
605                         'url' => $sourceUrl,
606                         'uri' => $sourceUri,
607                         'rendered' => $rendered,
608                         'replies' => array(),
609                         'groups' => array(),
610                         'tags' => array(),
611                         'urls' => array());
612
613         // Check for optional attributes...
614
615         if (!empty($activity->time)) {
616             $options['created'] = common_sql_date($activity->time);
617         }
618
619         if ($activity->context) {
620             // Any individual or group attn: targets?
621             $replies = $activity->context->attention;
622             $options['groups'] = $this->filterReplies($oprofile, $replies);
623             $options['replies'] = $replies;
624
625             // Maintain direct reply associations
626             // @fixme what about conversation ID?
627             if (!empty($activity->context->replyToID)) {
628                 $orig = Notice::staticGet('uri',
629                                           $activity->context->replyToID);
630                 if (!empty($orig)) {
631                     $options['reply_to'] = $orig->id;
632                 }
633             }
634
635             $location = $activity->context->location;
636             if ($location) {
637                 $options['lat'] = $location->lat;
638                 $options['lon'] = $location->lon;
639                 if ($location->location_id) {
640                     $options['location_ns'] = $location->location_ns;
641                     $options['location_id'] = $location->location_id;
642                 }
643             }
644         }
645
646         // Atom categories <-> hashtags
647         foreach ($activity->categories as $cat) {
648             if ($cat->term) {
649                 $term = common_canonical_tag($cat->term);
650                 if ($term) {
651                     $options['tags'][] = $term;
652                 }
653             }
654         }
655
656         // Atom enclosures -> attachment URLs
657         foreach ($activity->enclosures as $href) {
658             // @fixme save these locally or....?
659             $options['urls'][] = $href;
660         }
661
662         try {
663             $saved = Notice::saveNew($oprofile->profile_id,
664                                      $content,
665                                      'ostatus',
666                                      $options);
667             if ($saved) {
668                 Ostatus_source::saveNew($saved, $this, $method);
669                 if (!empty($attachment)) {
670                     File_to_post::processNew($attachment->id, $saved->id);
671                 }
672             }
673         } catch (Exception $e) {
674             common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
675             throw $e;
676         }
677         common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
678         return $saved;
679     }
680
681     /**
682      * Clean up HTML
683      */
684     protected function purify($html)
685     {
686         require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
687         $config = array('safe' => 1,
688                         'deny_attribute' => 'id,style,on*');
689         return htmLawed($html, $config);
690     }
691
692     /**
693      * Filters a list of recipient ID URIs to just those for local delivery.
694      * @param Ostatus_profile local profile of sender
695      * @param array in/out &$attention_uris set of URIs, will be pruned on output
696      * @return array of group IDs
697      */
698     protected function filterReplies($sender, &$attention_uris)
699     {
700         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
701         $groups = array();
702         $replies = array();
703         foreach (array_unique($attention_uris) as $recipient) {
704             // Is the recipient a local user?
705             $user = User::staticGet('uri', $recipient);
706             if ($user) {
707                 // @fixme sender verification, spam etc?
708                 $replies[] = $recipient;
709                 continue;
710             }
711
712             // Is the recipient a local group?
713             // $group = User_group::staticGet('uri', $recipient);
714             $id = OStatusPlugin::localGroupFromUrl($recipient);
715             if ($id) {
716                 $group = User_group::staticGet('id', $id);
717                 if ($group) {
718                     // Deliver to all members of this local group if allowed.
719                     $profile = $sender->localProfile();
720                     if ($profile->isMember($group)) {
721                         $groups[] = $group->id;
722                     } else {
723                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
724                     }
725                     continue;
726                 } else {
727                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
728                 }
729             }
730
731             // Is the recipient a remote user or group?
732             try {
733                 $oprofile = Ostatus_profile::ensureProfileURI($recipient);
734                 if ($oprofile->isGroup()) {
735                     // Deliver to local members of this remote group.
736                     // @fixme sender verification?
737                     $groups[] = $oprofile->group_id;
738                 } else {
739                     // may be canonicalized or something
740                     $replies[] = $oprofile->uri;
741                 }
742                 continue;
743             } catch (Exception $e) {
744                 // Neither a recognizable local nor remote user!
745                 common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
746             }
747
748         }
749         $attention_uris = $replies;
750         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
751         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
752         return $groups;
753     }
754
755     /**
756      * Look up and if necessary create an Ostatus_profile for the remote entity
757      * with the given profile page URL. This should never return null -- you
758      * will either get an object or an exception will be thrown.
759      *
760      * @param string $profile_url
761      * @return Ostatus_profile
762      * @throws Exception on various error conditions
763      * @throws OStatusShadowException if this reference would obscure a local user/group
764      */
765
766     public static function ensureProfileURL($profile_url, $hints=array())
767     {
768         $oprofile = self::getFromProfileURL($profile_url);
769
770         if (!empty($oprofile)) {
771             return $oprofile;
772         }
773
774         $hints['profileurl'] = $profile_url;
775
776         // Fetch the URL
777         // XXX: HTTP caching
778
779         $client = new HTTPClient();
780         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
781         $response = $client->get($profile_url);
782
783         if (!$response->isOk()) {
784             // TRANS: Exception. %s is a profile URL.
785             throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
786         }
787
788         // Check if we have a non-canonical URL
789
790         $finalUrl = $response->getUrl();
791
792         if ($finalUrl != $profile_url) {
793
794             $hints['profileurl'] = $finalUrl;
795
796             $oprofile = self::getFromProfileURL($finalUrl);
797
798             if (!empty($oprofile)) {
799                 return $oprofile;
800             }
801         }
802
803         // Try to get some hCard data
804
805         $body = $response->getBody();
806
807         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
808
809         if (!empty($hcardHints)) {
810             $hints = array_merge($hints, $hcardHints);
811         }
812
813         // Check if they've got an LRDD header
814
815         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
816
817         if (!empty($lrdd)) {
818
819             $xrd = Discovery::fetchXrd($lrdd);
820             $xrdHints = DiscoveryHints::fromXRD($xrd);
821
822             $hints = array_merge($hints, $xrdHints);
823         }
824
825         // If discovery found a feedurl (probably from LRDD), use it.
826
827         if (array_key_exists('feedurl', $hints)) {
828             return self::ensureFeedURL($hints['feedurl'], $hints);
829         }
830
831         // Get the feed URL from HTML
832
833         $discover = new FeedDiscovery();
834
835         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
836
837         if (!empty($feedurl)) {
838             $hints['feedurl'] = $feedurl;
839             return self::ensureFeedURL($feedurl, $hints);
840         }
841
842         // TRANS: Exception.
843         throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
844     }
845
846     /**
847      * Look up the Ostatus_profile, if present, for a remote entity with the
848      * given profile page URL. Will return null for both unknown and invalid
849      * remote profiles.
850      *
851      * @return mixed Ostatus_profile or null
852      * @throws OStatusShadowException for local profiles
853      */
854     static function getFromProfileURL($profile_url)
855     {
856         $profile = Profile::staticGet('profileurl', $profile_url);
857
858         if (empty($profile)) {
859             return null;
860         }
861
862         // Is it a known Ostatus profile?
863
864         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
865
866         if (!empty($oprofile)) {
867             return $oprofile;
868         }
869
870         // Is it a local user?
871
872         $user = User::staticGet('id', $profile->id);
873
874         if (!empty($user)) {
875             // @todo i18n FIXME: use sprintf and add i18n (?)
876             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
877         }
878
879         // Continue discovery; it's a remote profile
880         // for OMB or some other protocol, may also
881         // support OStatus
882
883         return null;
884     }
885
886     /**
887      * Look up and if necessary create an Ostatus_profile for remote entity
888      * with the given update feed. This should never return null -- you will
889      * either get an object or an exception will be thrown.
890      *
891      * @return Ostatus_profile
892      * @throws Exception
893      */
894     public static function ensureFeedURL($feed_url, $hints=array())
895     {
896         $discover = new FeedDiscovery();
897
898         $feeduri = $discover->discoverFromFeedURL($feed_url);
899         $hints['feedurl'] = $feeduri;
900
901         $huburi = $discover->getHubLink();
902         $hints['hub'] = $huburi;
903         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
904         $hints['salmon'] = $salmonuri;
905
906         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
907             // We can only deal with folks with a PuSH hub
908             throw new FeedSubNoHubException();
909         }
910
911         $feedEl = $discover->root;
912
913         if ($feedEl->tagName == 'feed') {
914             return self::ensureAtomFeed($feedEl, $hints);
915         } else if ($feedEl->tagName == 'channel') {
916             return self::ensureRssChannel($feedEl, $hints);
917         } else {
918             throw new FeedSubBadXmlException($feeduri);
919         }
920     }
921
922     /**
923      * Look up and, if necessary, create an Ostatus_profile for the remote
924      * profile with the given Atom feed - actually loaded from the feed.
925      * This should never return null -- you will either get an object or
926      * an exception will be thrown.
927      *
928      * @param DOMElement $feedEl root element of a loaded Atom feed
929      * @param array $hints additional discovery information passed from higher levels
930      * @fixme should this be marked public?
931      * @return Ostatus_profile
932      * @throws Exception
933      */
934     public static function ensureAtomFeed($feedEl, $hints)
935     {
936         // Try to get a profile from the feed activity:subject
937
938         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
939
940         if (!empty($subject)) {
941             $subjObject = new ActivityObject($subject);
942             return self::ensureActivityObjectProfile($subjObject, $hints);
943         }
944
945         // Otherwise, try the feed author
946
947         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
948
949         if (!empty($author)) {
950             $authorObject = new ActivityObject($author);
951             return self::ensureActivityObjectProfile($authorObject, $hints);
952         }
953
954         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
955         // entries.
956
957         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
958
959         if (!empty($entries) && $entries->length > 0) {
960
961             $entry = $entries->item(0);
962
963             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
964
965             if (!empty($actor)) {
966                 $actorObject = new ActivityObject($actor);
967                 return self::ensureActivityObjectProfile($actorObject, $hints);
968
969             }
970
971             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
972
973             if (!empty($author)) {
974                 $authorObject = new ActivityObject($author);
975                 return self::ensureActivityObjectProfile($authorObject, $hints);
976             }
977         }
978
979         // XXX: make some educated guesses here
980         throw new FeedSubException(_m('Can\'t find enough profile information to make a feed.'));
981     }
982
983     /**
984      * Look up and, if necessary, create an Ostatus_profile for the remote
985      * profile with the given RSS feed - actually loaded from the feed.
986      * This should never return null -- you will either get an object or
987      * an exception will be thrown.
988      *
989      * @param DOMElement $feedEl root element of a loaded RSS feed
990      * @param array $hints additional discovery information passed from higher levels
991      * @fixme should this be marked public?
992      * @return Ostatus_profile
993      * @throws Exception
994      */
995     public static function ensureRssChannel($feedEl, $hints)
996     {
997         // Special-case for Posterous. They have some nice metadata in their
998         // posterous:author elements. We should use them instead of the channel.
999
1000         $items = $feedEl->getElementsByTagName('item');
1001
1002         if ($items->length > 0) {
1003             $item = $items->item(0);
1004             $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
1005             if (!empty($authorEl)) {
1006                 $obj = ActivityObject::fromPosterousAuthor($authorEl);
1007                 // Posterous has multiple authors per feed, and multiple feeds
1008                 // per author. We check if this is the "main" feed for this author.
1009                 if (array_key_exists('profileurl', $hints) &&
1010                     !empty($obj->poco) &&
1011                     common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
1012                     return self::ensureActivityObjectProfile($obj, $hints);
1013                 }
1014             }
1015         }
1016
1017         // @fixme we should check whether this feed has elements
1018         // with different <author> or <dc:creator> elements, and... I dunno.
1019         // Do something about that.
1020
1021         $obj = ActivityObject::fromRssChannel($feedEl);
1022
1023         return self::ensureActivityObjectProfile($obj, $hints);
1024     }
1025
1026     /**
1027      * Download and update given avatar image
1028      *
1029      * @param string $url
1030      * @throws Exception in various failure cases
1031      */
1032     protected function updateAvatar($url)
1033     {
1034         if ($url == $this->avatar) {
1035             // We've already got this one.
1036             return;
1037         }
1038         if (!common_valid_http_url($url)) {
1039             throw new ServerException(sprintf(_m("Invalid avatar URL %s."), $url));
1040         }
1041
1042         if ($this->isGroup()) {
1043             $self = $this->localGroup();
1044         } else {
1045             $self = $this->localProfile();
1046         }
1047         if (!$self) {
1048             throw new ServerException(sprintf(
1049                 _m("Tried to update avatar for unsaved remote profile %s."),
1050                 $this->uri));
1051         }
1052
1053         // @fixme this should be better encapsulated
1054         // ripped from oauthstore.php (for old OMB client)
1055         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
1056         try {
1057             if (!copy($url, $temp_filename)) {
1058                 throw new ServerException(sprintf(_m("Unable to fetch avatar from %s."), $url));
1059             }
1060
1061             if ($this->isGroup()) {
1062                 $id = $this->group_id;
1063             } else {
1064                 $id = $this->profile_id;
1065             }
1066             // @fixme should we be using different ids?
1067             $imagefile = new ImageFile($id, $temp_filename);
1068             $filename = Avatar::filename($id,
1069                                          image_type_to_extension($imagefile->type),
1070                                          null,
1071                                          common_timestamp());
1072             rename($temp_filename, Avatar::path($filename));
1073         } catch (Exception $e) {
1074             unlink($temp_filename);
1075             throw $e;
1076         }
1077         // @fixme hardcoded chmod is lame, but seems to be necessary to
1078         // keep from accidentally saving images from command-line (queues)
1079         // that can't be read from web server, which causes hard-to-notice
1080         // problems later on:
1081         //
1082         // http://status.net/open-source/issues/2663
1083         chmod(Avatar::path($filename), 0644);
1084
1085         $self->setOriginal($filename);
1086
1087         $orig = clone($this);
1088         $this->avatar = $url;
1089         $this->update($orig);
1090     }
1091
1092     /**
1093      * Pull avatar URL from ActivityObject or profile hints
1094      *
1095      * @param ActivityObject $object
1096      * @param array $hints
1097      * @return mixed URL string or false
1098      */
1099
1100     public static function getActivityObjectAvatar($object, $hints=array())
1101     {
1102         if ($object->avatarLinks) {
1103             $best = false;
1104             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1105             foreach ($object->avatarLinks as $avatar) {
1106                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1107                     // Exact match!
1108                     $best = $avatar;
1109                     break;
1110                 }
1111                 if (!$best || $avatar->width > $best->width) {
1112                     $best = $avatar;
1113                 }
1114             }
1115             return $best->url;
1116         } else if (array_key_exists('avatar', $hints)) {
1117             return $hints['avatar'];
1118         }
1119         return false;
1120     }
1121
1122     /**
1123      * Get an appropriate avatar image source URL, if available.
1124      *
1125      * @param ActivityObject $actor
1126      * @param DOMElement $feed
1127      * @return string
1128      */
1129
1130     protected static function getAvatar($actor, $feed)
1131     {
1132         $url = '';
1133         $icon = '';
1134         if ($actor->avatar) {
1135             $url = trim($actor->avatar);
1136         }
1137         if (!$url) {
1138             // Check <atom:logo> and <atom:icon> on the feed
1139             $els = $feed->childNodes();
1140             if ($els && $els->length) {
1141                 for ($i = 0; $i < $els->length; $i++) {
1142                     $el = $els->item($i);
1143                     if ($el->namespaceURI == Activity::ATOM) {
1144                         if (empty($url) && $el->localName == 'logo') {
1145                             $url = trim($el->textContent);
1146                             break;
1147                         }
1148                         if (empty($icon) && $el->localName == 'icon') {
1149                             // Use as a fallback
1150                             $icon = trim($el->textContent);
1151                         }
1152                     }
1153                 }
1154             }
1155             if ($icon && !$url) {
1156                 $url = $icon;
1157             }
1158         }
1159         if ($url) {
1160             $opts = array('allowed_schemes' => array('http', 'https'));
1161             if (Validate::uri($url, $opts)) {
1162                 return $url;
1163             }
1164         }
1165         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1166     }
1167
1168     /**
1169      * Fetch, or build if necessary, an Ostatus_profile for the actor
1170      * in a given Activity Streams activity.
1171      * This should never return null -- you will either get an object or
1172      * an exception will be thrown.
1173      *
1174      * @param Activity $activity
1175      * @param string $feeduri if we already know the canonical feed URI!
1176      * @param string $salmonuri if we already know the salmon return channel URI
1177      * @return Ostatus_profile
1178      * @throws Exception
1179      */
1180
1181     public static function ensureActorProfile($activity, $hints=array())
1182     {
1183         return self::ensureActivityObjectProfile($activity->actor, $hints);
1184     }
1185
1186     /**
1187      * Fetch, or build if necessary, an Ostatus_profile for the profile
1188      * in a given Activity Streams object (can be subject, actor, or object).
1189      * This should never return null -- you will either get an object or
1190      * an exception will be thrown.
1191      *
1192      * @param ActivityObject $object
1193      * @param array $hints additional discovery information passed from higher levels
1194      * @return Ostatus_profile
1195      * @throws Exception
1196      */
1197
1198     public static function ensureActivityObjectProfile($object, $hints=array())
1199     {
1200         $profile = self::getActivityObjectProfile($object);
1201         if ($profile) {
1202             $profile->updateFromActivityObject($object, $hints);
1203         } else {
1204             $profile = self::createActivityObjectProfile($object, $hints);
1205         }
1206         return $profile;
1207     }
1208
1209     /**
1210      * @param Activity $activity
1211      * @return mixed matching Ostatus_profile or false if none known
1212      * @throws ServerException if feed info invalid
1213      */
1214     public static function getActorProfile($activity)
1215     {
1216         return self::getActivityObjectProfile($activity->actor);
1217     }
1218
1219     /**
1220      * @param ActivityObject $activity
1221      * @return mixed matching Ostatus_profile or false if none known
1222      * @throws ServerException if feed info invalid
1223      */
1224     protected static function getActivityObjectProfile($object)
1225     {
1226         $uri = self::getActivityObjectProfileURI($object);
1227         return Ostatus_profile::staticGet('uri', $uri);
1228     }
1229
1230     /**
1231      * Get the identifier URI for the remote entity described
1232      * by this ActivityObject. This URI is *not* guaranteed to be
1233      * a resolvable HTTP/HTTPS URL.
1234      *
1235      * @param ActivityObject $object
1236      * @return string
1237      * @throws ServerException if feed info invalid
1238      */
1239     protected static function getActivityObjectProfileURI($object)
1240     {
1241         if ($object->id) {
1242             if (ActivityUtils::validateUri($object->id)) {
1243                 return $object->id;
1244             }
1245         }
1246
1247         // If the id is missing or invalid (we've seen feeds mistakenly listing
1248         // things like local usernames in that field) then we'll use the profile
1249         // page link, if valid.
1250         if ($object->link && common_valid_http_url($object->link)) {
1251             return $object->link;
1252         }
1253         throw new ServerException("No author ID URI found.");
1254     }
1255
1256     /**
1257      * @fixme validate stuff somewhere
1258      */
1259
1260     /**
1261      * Create local ostatus_profile and profile/user_group entries for
1262      * the provided remote user or group.
1263      * This should never return null -- you will either get an object or
1264      * an exception will be thrown.
1265      *
1266      * @param ActivityObject $object
1267      * @param array $hints
1268      *
1269      * @return Ostatus_profile
1270      */
1271     protected static function createActivityObjectProfile($object, $hints=array())
1272     {
1273         $homeuri = $object->id;
1274         $discover = false;
1275
1276         if (!$homeuri) {
1277             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1278             throw new Exception("No profile URI");
1279         }
1280
1281         $user = User::staticGet('uri', $homeuri);
1282         if ($user) {
1283             // TRANS: Exception.
1284             throw new Exception(_m('Local user can\'t be referenced as remote.'));
1285         }
1286
1287         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1288             // TRANS: Exception.
1289             throw new Exception(_m('Local group can\'t be referenced as remote.'));
1290         }
1291
1292         if (array_key_exists('feedurl', $hints)) {
1293             $feeduri = $hints['feedurl'];
1294         } else {
1295             $discover = new FeedDiscovery();
1296             $feeduri = $discover->discoverFromURL($homeuri);
1297         }
1298
1299         if (array_key_exists('salmon', $hints)) {
1300             $salmonuri = $hints['salmon'];
1301         } else {
1302             if (!$discover) {
1303                 $discover = new FeedDiscovery();
1304                 $discover->discoverFromFeedURL($hints['feedurl']);
1305             }
1306             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1307         }
1308
1309         if (array_key_exists('hub', $hints)) {
1310             $huburi = $hints['hub'];
1311         } else {
1312             if (!$discover) {
1313                 $discover = new FeedDiscovery();
1314                 $discover->discoverFromFeedURL($hints['feedurl']);
1315             }
1316             $huburi = $discover->getHubLink();
1317         }
1318
1319         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1320             // We can only deal with folks with a PuSH hub
1321             throw new FeedSubNoHubException();
1322         }
1323
1324         $oprofile = new Ostatus_profile();
1325
1326         $oprofile->uri        = $homeuri;
1327         $oprofile->feeduri    = $feeduri;
1328         $oprofile->salmonuri  = $salmonuri;
1329
1330         $oprofile->created    = common_sql_now();
1331         $oprofile->modified   = common_sql_now();
1332
1333         if ($object->type == ActivityObject::PERSON) {
1334             $profile = new Profile();
1335             $profile->created = common_sql_now();
1336             self::updateProfile($profile, $object, $hints);
1337
1338             $oprofile->profile_id = $profile->insert();
1339             if (!$oprofile->profile_id) {
1340             // TRANS: Exception.
1341                 throw new ServerException(_m('Can\'t save local profile.'));
1342             }
1343         } else {
1344             $group = new User_group();
1345             $group->uri = $homeuri;
1346             $group->created = common_sql_now();
1347             self::updateGroup($group, $object, $hints);
1348
1349             $oprofile->group_id = $group->insert();
1350             if (!$oprofile->group_id) {
1351                 // TRANS: Exception.
1352                 throw new ServerException(_m('Can\'t save local profile.'));
1353             }
1354         }
1355
1356         $ok = $oprofile->insert();
1357
1358         if (!$ok) {
1359             // TRANS: Exception.
1360             throw new ServerException(_m('Can\'t save OStatus profile.'));
1361         }
1362
1363         $avatar = self::getActivityObjectAvatar($object, $hints);
1364
1365         if ($avatar) {
1366             try {
1367                 $oprofile->updateAvatar($avatar);
1368             } catch (Exception $ex) {
1369                 // Profile is saved, but Avatar is messed up. We're
1370                 // just going to continue.
1371                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1372             }
1373         }
1374
1375         return $oprofile;
1376     }
1377
1378     /**
1379      * Save any updated profile information to our local copy.
1380      * @param ActivityObject $object
1381      * @param array $hints
1382      */
1383     public function updateFromActivityObject($object, $hints=array())
1384     {
1385         if ($this->isGroup()) {
1386             $group = $this->localGroup();
1387             self::updateGroup($group, $object, $hints);
1388         } else {
1389             $profile = $this->localProfile();
1390             self::updateProfile($profile, $object, $hints);
1391         }
1392         $avatar = self::getActivityObjectAvatar($object, $hints);
1393         if ($avatar) {
1394             try {
1395                 $this->updateAvatar($avatar);
1396             } catch (Exception $ex) {
1397                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1398             }
1399         }
1400     }
1401
1402     public static function updateProfile($profile, $object, $hints=array())
1403     {
1404         $orig = clone($profile);
1405
1406         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1407
1408         if (!empty($object->title)) {
1409             $profile->fullname = $object->title;
1410         } else if (array_key_exists('fullname', $hints)) {
1411             $profile->fullname = $hints['fullname'];
1412         }
1413
1414         if (!empty($object->link)) {
1415             $profile->profileurl = $object->link;
1416         } else if (array_key_exists('profileurl', $hints)) {
1417             $profile->profileurl = $hints['profileurl'];
1418         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1419             $profile->profileurl = $object->id;
1420         }
1421
1422         $profile->bio      = self::getActivityObjectBio($object, $hints);
1423         $profile->location = self::getActivityObjectLocation($object, $hints);
1424         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1425
1426         if (!empty($object->geopoint)) {
1427             $location = ActivityContext::locationFromPoint($object->geopoint);
1428             if (!empty($location)) {
1429                 $profile->lat = $location->lat;
1430                 $profile->lon = $location->lon;
1431             }
1432         }
1433
1434         // @fixme tags/categories
1435         // @todo tags from categories
1436
1437         if ($profile->id) {
1438             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1439             $profile->update($orig);
1440         }
1441     }
1442
1443     protected static function updateGroup($group, $object, $hints=array())
1444     {
1445         $orig = clone($group);
1446
1447         $group->nickname = self::getActivityObjectNickname($object, $hints);
1448         $group->fullname = $object->title;
1449
1450         if (!empty($object->link)) {
1451             $group->mainpage = $object->link;
1452         } else if (array_key_exists('profileurl', $hints)) {
1453             $group->mainpage = $hints['profileurl'];
1454         }
1455
1456         // @todo tags from categories
1457         $group->description = self::getActivityObjectBio($object, $hints);
1458         $group->location = self::getActivityObjectLocation($object, $hints);
1459         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1460
1461         if ($group->id) {
1462             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1463             $group->update($orig);
1464         }
1465     }
1466
1467     protected static function getActivityObjectHomepage($object, $hints=array())
1468     {
1469         $homepage = null;
1470         $poco     = $object->poco;
1471
1472         if (!empty($poco)) {
1473             $url = $poco->getPrimaryURL();
1474             if ($url && $url->type == 'homepage') {
1475                 $homepage = $url->value;
1476             }
1477         }
1478
1479         // @todo Try for a another PoCo URL?
1480
1481         return $homepage;
1482     }
1483
1484     protected static function getActivityObjectLocation($object, $hints=array())
1485     {
1486         $location = null;
1487
1488         if (!empty($object->poco) &&
1489             isset($object->poco->address->formatted)) {
1490             $location = $object->poco->address->formatted;
1491         } else if (array_key_exists('location', $hints)) {
1492             $location = $hints['location'];
1493         }
1494
1495         if (!empty($location)) {
1496             if (mb_strlen($location) > 255) {
1497                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1498             }
1499         }
1500
1501         // @todo Try to find location some othe way? Via goerss point?
1502
1503         return $location;
1504     }
1505
1506     protected static function getActivityObjectBio($object, $hints=array())
1507     {
1508         $bio  = null;
1509
1510         if (!empty($object->poco)) {
1511             $note = $object->poco->note;
1512         } else if (array_key_exists('bio', $hints)) {
1513             $note = $hints['bio'];
1514         }
1515
1516         if (!empty($note)) {
1517             if (Profile::bioTooLong($note)) {
1518                 // XXX: truncate ok?
1519                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1520             } else {
1521                 $bio = $note;
1522             }
1523         }
1524
1525         // @todo Try to get bio info some other way?
1526
1527         return $bio;
1528     }
1529
1530     public static function getActivityObjectNickname($object, $hints=array())
1531     {
1532         if ($object->poco) {
1533             if (!empty($object->poco->preferredUsername)) {
1534                 return common_nicknamize($object->poco->preferredUsername);
1535             }
1536         }
1537
1538         if (!empty($object->nickname)) {
1539             return common_nicknamize($object->nickname);
1540         }
1541
1542         if (array_key_exists('nickname', $hints)) {
1543             return $hints['nickname'];
1544         }
1545
1546         // Try the profile url (like foo.example.com or example.com/user/foo)
1547
1548         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1549
1550         if (!empty($profileUrl)) {
1551             $nickname = self::nicknameFromURI($profileUrl);
1552         }
1553
1554         // Try the URI (may be a tag:, http:, acct:, ...
1555
1556         if (empty($nickname)) {
1557             $nickname = self::nicknameFromURI($object->id);
1558         }
1559
1560         // Try a Webfinger if one was passed (way) down
1561
1562         if (empty($nickname)) {
1563             if (array_key_exists('webfinger', $hints)) {
1564                 $nickname = self::nicknameFromURI($hints['webfinger']);
1565             }
1566         }
1567
1568         // Try the name
1569
1570         if (empty($nickname)) {
1571             $nickname = common_nicknamize($object->title);
1572         }
1573
1574         return $nickname;
1575     }
1576
1577     protected static function nicknameFromURI($uri)
1578     {
1579         preg_match('/(\w+):/', $uri, $matches);
1580
1581         $protocol = $matches[1];
1582
1583         switch ($protocol) {
1584         case 'acct':
1585         case 'mailto':
1586             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1587                 return common_canonical_nickname($matches[1]);
1588             }
1589             return null;
1590         case 'http':
1591             return common_url_to_nickname($uri);
1592             break;
1593         default:
1594             return null;
1595         }
1596     }
1597
1598     /**
1599      * Look up, and if necessary create, an Ostatus_profile for the remote
1600      * entity with the given webfinger address.
1601      * This should never return null -- you will either get an object or
1602      * an exception will be thrown.
1603      *
1604      * @param string $addr webfinger address
1605      * @return Ostatus_profile
1606      * @throws Exception on error conditions
1607      * @throws OStatusShadowException if this reference would obscure a local user/group
1608      */
1609     public static function ensureWebfinger($addr)
1610     {
1611         // First, try the cache
1612
1613         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1614
1615         if ($uri !== false) {
1616             if (is_null($uri)) {
1617                 // Negative cache entry
1618                 // TRANS: Exception.
1619                 throw new Exception(_m('Not a valid webfinger address.'));
1620             }
1621             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1622             if (!empty($oprofile)) {
1623                 return $oprofile;
1624             }
1625         }
1626
1627         // Try looking it up
1628
1629         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1630
1631         if (!empty($oprofile)) {
1632             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1633             return $oprofile;
1634         }
1635
1636         // Now, try some discovery
1637
1638         $disco = new Discovery();
1639
1640         try {
1641             $xrd = $disco->lookup($addr);
1642         } catch (Exception $e) {
1643             // Save negative cache entry so we don't waste time looking it up again.
1644             // @fixme distinguish temporary failures?
1645             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1646                 // TRANS: Exception.
1647             throw new Exception(_m('Not a valid webfinger address.'));
1648         }
1649
1650         $hints = array('webfinger' => $addr);
1651
1652         $dhints = DiscoveryHints::fromXRD($xrd);
1653
1654         $hints = array_merge($hints, $dhints);
1655
1656         // If there's an Hcard, let's grab its info
1657
1658         if (array_key_exists('hcard', $hints)) {
1659             if (!array_key_exists('profileurl', $hints) ||
1660                 $hints['hcard'] != $hints['profileurl']) {
1661                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1662                 $hints = array_merge($hcardHints, $hints);
1663             }
1664         }
1665
1666         // If we got a feed URL, try that
1667
1668         if (array_key_exists('feedurl', $hints)) {
1669             try {
1670                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1671                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1672                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1673                 return $oprofile;
1674             } catch (Exception $e) {
1675                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1676                 // keep looking
1677             }
1678         }
1679
1680         // If we got a profile page, try that!
1681
1682         if (array_key_exists('profileurl', $hints)) {
1683             try {
1684                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1685                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1686                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1687                 return $oprofile;
1688             } catch (OStatusShadowException $e) {
1689                 // We've ended up with a remote reference to a local user or group.
1690                 // @fixme ideally we should be able to say who it was so we can
1691                 // go back and refer to it the regular way
1692                 throw $e;
1693             } catch (Exception $e) {
1694                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1695                 // keep looking
1696                 //
1697                 // @fixme this means an error discovering from profile page
1698                 // may give us a corrupt entry using the webfinger URI, which
1699                 // will obscure the correct page-keyed profile later on.
1700             }
1701         }
1702
1703         // XXX: try hcard
1704         // XXX: try FOAF
1705
1706         if (array_key_exists('salmon', $hints)) {
1707
1708             $salmonEndpoint = $hints['salmon'];
1709
1710             // An account URL, a salmon endpoint, and a dream? Not much to go
1711             // on, but let's give it a try
1712
1713             $uri = 'acct:'.$addr;
1714
1715             $profile = new Profile();
1716
1717             $profile->nickname = self::nicknameFromUri($uri);
1718             $profile->created  = common_sql_now();
1719
1720             if (isset($profileUrl)) {
1721                 $profile->profileurl = $profileUrl;
1722             }
1723
1724             $profile_id = $profile->insert();
1725
1726             if (!$profile_id) {
1727                 common_log_db_error($profile, 'INSERT', __FILE__);
1728                 // TRANS: Exception. %s is a webfinger address.
1729                 throw new Exception(sprintf(_m('Couldn\'t save profile for "%s".'),$addr));
1730             }
1731
1732             $oprofile = new Ostatus_profile();
1733
1734             $oprofile->uri        = $uri;
1735             $oprofile->salmonuri  = $salmonEndpoint;
1736             $oprofile->profile_id = $profile_id;
1737             $oprofile->created    = common_sql_now();
1738
1739             if (isset($feedUrl)) {
1740                 $profile->feeduri = $feedUrl;
1741             }
1742
1743             $result = $oprofile->insert();
1744
1745             if (!$result) {
1746                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1747                 // TRANS: Exception. %s is a webfinger address.
1748                 throw new Exception(sprintf(_m('Couldn\'t save ostatus_profile for "%s".'),$addr));
1749             }
1750
1751             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1752             return $oprofile;
1753         }
1754
1755         // TRANS: Exception. %s is a webfinger address.
1756         throw new Exception(sprintf(_m('Couldn\'t find a valid profile for "%s".'),$addr));
1757     }
1758
1759     /**
1760      * Store the full-length scrubbed HTML of a remote notice to an attachment
1761      * file on our server. We'll link to this at the end of the cropped version.
1762      *
1763      * @param string $title plaintext for HTML page's title
1764      * @param string $rendered HTML fragment for HTML page's body
1765      * @return File
1766      */
1767     function saveHTMLFile($title, $rendered)
1768     {
1769         $final = sprintf("<!DOCTYPE html>\n" .
1770                          '<html><head>' .
1771                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
1772                          '<title>%s</title>' .
1773                          '</head>' .
1774                          '<body>%s</body></html>',
1775                          htmlspecialchars($title),
1776                          $rendered);
1777
1778         $filename = File::filename($this->localProfile(),
1779                                    'ostatus', // ignored?
1780                                    'text/html');
1781
1782         $filepath = File::path($filename);
1783
1784         file_put_contents($filepath, $final);
1785
1786         $file = new File;
1787
1788         $file->filename = $filename;
1789         $file->url      = File::url($filename);
1790         $file->size     = filesize($filepath);
1791         $file->date     = time();
1792         $file->mimetype = 'text/html';
1793
1794         $file_id = $file->insert();
1795
1796         if ($file_id === false) {
1797             common_log_db_error($file, "INSERT", __FILE__);
1798             throw new ServerException(_m('Could not store HTML content of long post as file.'));
1799         }
1800
1801         return $file;
1802     }
1803
1804     static function ensureProfileURI($uri)
1805     {
1806         $oprofile = null;
1807
1808         // First, try to query it
1809
1810         $oprofile = Ostatus_profile::staticGet('uri', $uri);
1811
1812         // If unfound, do discovery stuff
1813
1814         if (empty($oprofile)) {
1815             if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
1816                 $protocol = $match[1];
1817                 switch ($protocol) {
1818                 case 'http':
1819                 case 'https':
1820                     $oprofile = Ostatus_profile::ensureProfileURL($uri);
1821                     break;
1822                 case 'acct':
1823                 case 'mailto':
1824                     $rest = $match[2];
1825                     $oprofile = Ostatus_profile::ensureWebfinger($rest);
1826                 default:
1827                     common_log("Unrecognized URI protocol for profile: $protocol ($uri)");
1828                     break;
1829                 }
1830             }
1831         }
1832         return $oprofile;
1833     }
1834 }
1835
1836 /**
1837  * Exception indicating we've got a remote reference to a local user,
1838  * not a remote user!
1839  *
1840  * If we can ue a local profile after all, it's available as $e->profile.
1841  */
1842 class OStatusShadowException extends Exception
1843 {
1844     public $profile;
1845
1846     /**
1847      * @param Profile $profile
1848      * @param string $message
1849      */
1850     function __construct($profile, $message) {
1851         $this->profile = $profile;
1852         parent::__construct($message);
1853     }
1854 }