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