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