]> git.mxchange.org Git - quix0rs-gnu-social.git/blob - plugins/OStatus/classes/Ostatus_profile.php
Merge branch '0.9.x' of gitorious.org:statusnet/mainline 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
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 remote group?
707             $oprofile = Ostatus_profile::ensureProfileURI($recipient);
708
709             if ($oprofile) {
710                 if ($oprofile->isGroup()) {
711                     // Deliver to local members of this remote group.
712                     // @fixme sender verification?
713                     $groups[] = $oprofile->group_id;
714                 } else {
715                     // may be canonicalized or something
716                     $replies[] = $oprofile->uri;
717                 }
718                 continue;
719             }
720
721             // Is the recipient a local group?
722             // @fixme uri on user_group isn't reliable yet
723             // $group = User_group::staticGet('uri', $recipient);
724             $id = OStatusPlugin::localGroupFromUrl($recipient);
725             if ($id) {
726                 $group = User_group::staticGet('id', $id);
727                 if ($group) {
728                     // Deliver to all members of this local group if allowed.
729                     $profile = $sender->localProfile();
730                     if ($profile->isMember($group)) {
731                         $groups[] = $group->id;
732                     } else {
733                         common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
734                     }
735                     continue;
736                 } else {
737                     common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
738                 }
739             }
740
741             common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
742
743         }
744         $attention_uris = $replies;
745         common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
746         common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
747         return $groups;
748     }
749
750     /**
751      * Look up and if necessary create an Ostatus_profile for the remote entity
752      * with the given profile page URL. This should never return null -- you
753      * will either get an object or an exception will be thrown.
754      *
755      * @param string $profile_url
756      * @return Ostatus_profile
757      * @throws Exception on various error conditions
758      * @throws OStatusShadowException if this reference would obscure a local user/group
759      */
760
761     public static function ensureProfileURL($profile_url, $hints=array())
762     {
763         $oprofile = self::getFromProfileURL($profile_url);
764
765         if (!empty($oprofile)) {
766             return $oprofile;
767         }
768
769         $hints['profileurl'] = $profile_url;
770
771         // Fetch the URL
772         // XXX: HTTP caching
773
774         $client = new HTTPClient();
775         $client->setHeader('Accept', 'text/html,application/xhtml+xml');
776         $response = $client->get($profile_url);
777
778         if (!$response->isOk()) {
779             // @todo i18n FIXME: use sprintf and add i18n.
780             throw new Exception("Could not reach profile page: " . $profile_url);
781         }
782
783         // Check if we have a non-canonical URL
784
785         $finalUrl = $response->getUrl();
786
787         if ($finalUrl != $profile_url) {
788
789             $hints['profileurl'] = $finalUrl;
790
791             $oprofile = self::getFromProfileURL($finalUrl);
792
793             if (!empty($oprofile)) {
794                 return $oprofile;
795             }
796         }
797
798         // Try to get some hCard data
799
800         $body = $response->getBody();
801
802         $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
803
804         if (!empty($hcardHints)) {
805             $hints = array_merge($hints, $hcardHints);
806         }
807
808         // Check if they've got an LRDD header
809
810         $lrdd = LinkHeader::getLink($response, 'lrdd', 'application/xrd+xml');
811
812         if (!empty($lrdd)) {
813
814             $xrd = Discovery::fetchXrd($lrdd);
815             $xrdHints = DiscoveryHints::fromXRD($xrd);
816
817             $hints = array_merge($hints, $xrdHints);
818         }
819
820         // If discovery found a feedurl (probably from LRDD), use it.
821
822         if (array_key_exists('feedurl', $hints)) {
823             return self::ensureFeedURL($hints['feedurl'], $hints);
824         }
825
826         // Get the feed URL from HTML
827
828         $discover = new FeedDiscovery();
829
830         $feedurl = $discover->discoverFromHTML($finalUrl, $body);
831
832         if (!empty($feedurl)) {
833             $hints['feedurl'] = $feedurl;
834             return self::ensureFeedURL($feedurl, $hints);
835         }
836
837         // @todo i18n FIXME: use sprintf and add i18n.
838         throw new Exception("Could not find a feed URL for profile page " . $finalUrl);
839     }
840
841     /**
842      * Look up the Ostatus_profile, if present, for a remote entity with the
843      * given profile page URL. Will return null for both unknown and invalid
844      * remote profiles.
845      *
846      * @return mixed Ostatus_profile or null
847      * @throws OStatusShadowException for local profiles
848      */
849     static function getFromProfileURL($profile_url)
850     {
851         $profile = Profile::staticGet('profileurl', $profile_url);
852
853         if (empty($profile)) {
854             return null;
855         }
856
857         // Is it a known Ostatus profile?
858
859         $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id);
860
861         if (!empty($oprofile)) {
862             return $oprofile;
863         }
864
865         // Is it a local user?
866
867         $user = User::staticGet('id', $profile->id);
868
869         if (!empty($user)) {
870             // @todo i18n FIXME: use sprintf and add i18n.
871             throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
872         }
873
874         // Continue discovery; it's a remote profile
875         // for OMB or some other protocol, may also
876         // support OStatus
877
878         return null;
879     }
880
881     /**
882      * Look up and if necessary create an Ostatus_profile for remote entity
883      * with the given update feed. This should never return null -- you will
884      * either get an object or an exception will be thrown.
885      *
886      * @return Ostatus_profile
887      * @throws Exception
888      */
889     public static function ensureFeedURL($feed_url, $hints=array())
890     {
891         $discover = new FeedDiscovery();
892
893         $feeduri = $discover->discoverFromFeedURL($feed_url);
894         $hints['feedurl'] = $feeduri;
895
896         $huburi = $discover->getHubLink();
897         $hints['hub'] = $huburi;
898         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
899         $hints['salmon'] = $salmonuri;
900
901         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
902             // We can only deal with folks with a PuSH hub
903             throw new FeedSubNoHubException();
904         }
905
906         $feedEl = $discover->root;
907
908         if ($feedEl->tagName == 'feed') {
909             return self::ensureAtomFeed($feedEl, $hints);
910         } else if ($feedEl->tagName == 'channel') {
911             return self::ensureRssChannel($feedEl, $hints);
912         } else {
913             throw new FeedSubBadXmlException($feeduri);
914         }
915     }
916
917     /**
918      * Look up and, if necessary, create an Ostatus_profile for the remote
919      * profile with the given Atom feed - actually loaded from the feed.
920      * This should never return null -- you will either get an object or
921      * an exception will be thrown.
922      *
923      * @param DOMElement $feedEl root element of a loaded Atom feed
924      * @param array $hints additional discovery information passed from higher levels
925      * @fixme should this be marked public?
926      * @return Ostatus_profile
927      * @throws Exception
928      */
929     public static function ensureAtomFeed($feedEl, $hints)
930     {
931         // Try to get a profile from the feed activity:subject
932
933         $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
934
935         if (!empty($subject)) {
936             $subjObject = new ActivityObject($subject);
937             return self::ensureActivityObjectProfile($subjObject, $hints);
938         }
939
940         // Otherwise, try the feed author
941
942         $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM);
943
944         if (!empty($author)) {
945             $authorObject = new ActivityObject($author);
946             return self::ensureActivityObjectProfile($authorObject, $hints);
947         }
948
949         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
950         // entries.
951
952         $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
953
954         if (!empty($entries) && $entries->length > 0) {
955
956             $entry = $entries->item(0);
957
958             $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC);
959
960             if (!empty($actor)) {
961                 $actorObject = new ActivityObject($actor);
962                 return self::ensureActivityObjectProfile($actorObject, $hints);
963
964             }
965
966             $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM);
967
968             if (!empty($author)) {
969                 $authorObject = new ActivityObject($author);
970                 return self::ensureActivityObjectProfile($authorObject, $hints);
971             }
972         }
973
974         // XXX: make some educated guesses here
975
976         throw new FeedSubException("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         $self->setOriginal($filename);
1069
1070         $orig = clone($this);
1071         $this->avatar = $url;
1072         $this->update($orig);
1073     }
1074
1075     /**
1076      * Pull avatar URL from ActivityObject or profile hints
1077      *
1078      * @param ActivityObject $object
1079      * @param array $hints
1080      * @return mixed URL string or false
1081      */
1082
1083     protected static function getActivityObjectAvatar($object, $hints=array())
1084     {
1085         if ($object->avatarLinks) {
1086             $best = false;
1087             // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
1088             foreach ($object->avatarLinks as $avatar) {
1089                 if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
1090                     // Exact match!
1091                     $best = $avatar;
1092                     break;
1093                 }
1094                 if (!$best || $avatar->width > $best->width) {
1095                     $best = $avatar;
1096                 }
1097             }
1098             return $best->url;
1099         } else if (array_key_exists('avatar', $hints)) {
1100             return $hints['avatar'];
1101         }
1102         return false;
1103     }
1104
1105     /**
1106      * Get an appropriate avatar image source URL, if available.
1107      *
1108      * @param ActivityObject $actor
1109      * @param DOMElement $feed
1110      * @return string
1111      */
1112
1113     protected static function getAvatar($actor, $feed)
1114     {
1115         $url = '';
1116         $icon = '';
1117         if ($actor->avatar) {
1118             $url = trim($actor->avatar);
1119         }
1120         if (!$url) {
1121             // Check <atom:logo> and <atom:icon> on the feed
1122             $els = $feed->childNodes();
1123             if ($els && $els->length) {
1124                 for ($i = 0; $i < $els->length; $i++) {
1125                     $el = $els->item($i);
1126                     if ($el->namespaceURI == Activity::ATOM) {
1127                         if (empty($url) && $el->localName == 'logo') {
1128                             $url = trim($el->textContent);
1129                             break;
1130                         }
1131                         if (empty($icon) && $el->localName == 'icon') {
1132                             // Use as a fallback
1133                             $icon = trim($el->textContent);
1134                         }
1135                     }
1136                 }
1137             }
1138             if ($icon && !$url) {
1139                 $url = $icon;
1140             }
1141         }
1142         if ($url) {
1143             $opts = array('allowed_schemes' => array('http', 'https'));
1144             if (Validate::uri($url, $opts)) {
1145                 return $url;
1146             }
1147         }
1148         return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
1149     }
1150
1151     /**
1152      * Fetch, or build if necessary, an Ostatus_profile for the actor
1153      * in a given Activity Streams activity.
1154      * This should never return null -- you will either get an object or
1155      * an exception will be thrown.
1156      *
1157      * @param Activity $activity
1158      * @param string $feeduri if we already know the canonical feed URI!
1159      * @param string $salmonuri if we already know the salmon return channel URI
1160      * @return Ostatus_profile
1161      * @throws Exception
1162      */
1163
1164     public static function ensureActorProfile($activity, $hints=array())
1165     {
1166         return self::ensureActivityObjectProfile($activity->actor, $hints);
1167     }
1168
1169     /**
1170      * Fetch, or build if necessary, an Ostatus_profile for the profile
1171      * in a given Activity Streams object (can be subject, actor, or object).
1172      * This should never return null -- you will either get an object or
1173      * an exception will be thrown.
1174      *
1175      * @param ActivityObject $object
1176      * @param array $hints additional discovery information passed from higher levels
1177      * @return Ostatus_profile
1178      * @throws Exception
1179      */
1180
1181     public static function ensureActivityObjectProfile($object, $hints=array())
1182     {
1183         $profile = self::getActivityObjectProfile($object);
1184         if ($profile) {
1185             $profile->updateFromActivityObject($object, $hints);
1186         } else {
1187             $profile = self::createActivityObjectProfile($object, $hints);
1188         }
1189         return $profile;
1190     }
1191
1192     /**
1193      * @param Activity $activity
1194      * @return mixed matching Ostatus_profile or false if none known
1195      * @throws ServerException if feed info invalid
1196      */
1197     public static function getActorProfile($activity)
1198     {
1199         return self::getActivityObjectProfile($activity->actor);
1200     }
1201
1202     /**
1203      * @param ActivityObject $activity
1204      * @return mixed matching Ostatus_profile or false if none known
1205      * @throws ServerException if feed info invalid
1206      */
1207     protected static function getActivityObjectProfile($object)
1208     {
1209         $uri = self::getActivityObjectProfileURI($object);
1210         return Ostatus_profile::staticGet('uri', $uri);
1211     }
1212
1213     /**
1214      * Get the identifier URI for the remote entity described
1215      * by this ActivityObject. This URI is *not* guaranteed to be
1216      * a resolvable HTTP/HTTPS URL.
1217      *
1218      * @param ActivityObject $object
1219      * @return string
1220      * @throws ServerException if feed info invalid
1221      */
1222     protected static function getActivityObjectProfileURI($object)
1223     {
1224         if ($object->id) {
1225             if (ActivityUtils::validateUri($object->id)) {
1226                 return $object->id;
1227             }
1228         }
1229
1230         // If the id is missing or invalid (we've seen feeds mistakenly listing
1231         // things like local usernames in that field) then we'll use the profile
1232         // page link, if valid.
1233         if ($object->link && common_valid_http_url($object->link)) {
1234             return $object->link;
1235         }
1236         throw new ServerException("No author ID URI found.");
1237     }
1238
1239     /**
1240      * @fixme validate stuff somewhere
1241      */
1242
1243     /**
1244      * Create local ostatus_profile and profile/user_group entries for
1245      * the provided remote user or group.
1246      * This should never return null -- you will either get an object or
1247      * an exception will be thrown.
1248      *
1249      * @param ActivityObject $object
1250      * @param array $hints
1251      *
1252      * @return Ostatus_profile
1253      */
1254     protected static function createActivityObjectProfile($object, $hints=array())
1255     {
1256         $homeuri = $object->id;
1257         $discover = false;
1258
1259         if (!$homeuri) {
1260             common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
1261             throw new Exception("No profile URI");
1262         }
1263
1264         $user = User::staticGet('uri', $homeuri);
1265         if ($user) {
1266             // @todo i18n FIXME: add i18n.
1267             throw new Exception("Local user can't be referenced as remote.");
1268         }
1269
1270         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
1271             // @todo i18n FIXME: add i18n.
1272             throw new Exception("Local group can't be referenced as remote.");
1273         }
1274
1275         if (array_key_exists('feedurl', $hints)) {
1276             $feeduri = $hints['feedurl'];
1277         } else {
1278             $discover = new FeedDiscovery();
1279             $feeduri = $discover->discoverFromURL($homeuri);
1280         }
1281
1282         if (array_key_exists('salmon', $hints)) {
1283             $salmonuri = $hints['salmon'];
1284         } else {
1285             if (!$discover) {
1286                 $discover = new FeedDiscovery();
1287                 $discover->discoverFromFeedURL($hints['feedurl']);
1288             }
1289             $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
1290         }
1291
1292         if (array_key_exists('hub', $hints)) {
1293             $huburi = $hints['hub'];
1294         } else {
1295             if (!$discover) {
1296                 $discover = new FeedDiscovery();
1297                 $discover->discoverFromFeedURL($hints['feedurl']);
1298             }
1299             $huburi = $discover->getHubLink();
1300         }
1301
1302         if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
1303             // We can only deal with folks with a PuSH hub
1304             throw new FeedSubNoHubException();
1305         }
1306
1307         $oprofile = new Ostatus_profile();
1308
1309         $oprofile->uri        = $homeuri;
1310         $oprofile->feeduri    = $feeduri;
1311         $oprofile->salmonuri  = $salmonuri;
1312
1313         $oprofile->created    = common_sql_now();
1314         $oprofile->modified   = common_sql_now();
1315
1316         if ($object->type == ActivityObject::PERSON) {
1317             $profile = new Profile();
1318             $profile->created = common_sql_now();
1319             self::updateProfile($profile, $object, $hints);
1320
1321             $oprofile->profile_id = $profile->insert();
1322             if (!$oprofile->profile_id) {
1323                 // @todo i18n FIXME: add i18n.
1324                 throw new ServerException("Can't save local profile.");
1325             }
1326         } else {
1327             $group = new User_group();
1328             $group->uri = $homeuri;
1329             $group->created = common_sql_now();
1330             self::updateGroup($group, $object, $hints);
1331
1332             $oprofile->group_id = $group->insert();
1333             if (!$oprofile->group_id) {
1334                 // @todo i18n FIXME: add i18n.
1335                 throw new ServerException("Can't save local profile.");
1336             }
1337         }
1338
1339         $ok = $oprofile->insert();
1340
1341         if (!$ok) {
1342             // @todo i18n FIXME: add i18n.
1343             throw new ServerException("Can't save OStatus profile.");
1344         }
1345
1346         $avatar = self::getActivityObjectAvatar($object, $hints);
1347
1348         if ($avatar) {
1349             try {
1350                 $oprofile->updateAvatar($avatar);
1351             } catch (Exception $ex) {
1352                 // Profile is saved, but Avatar is messed up. We're
1353                 // just going to continue.
1354                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
1355             }
1356         }
1357
1358         return $oprofile;
1359     }
1360
1361     /**
1362      * Save any updated profile information to our local copy.
1363      * @param ActivityObject $object
1364      * @param array $hints
1365      */
1366     public function updateFromActivityObject($object, $hints=array())
1367     {
1368         if ($this->isGroup()) {
1369             $group = $this->localGroup();
1370             self::updateGroup($group, $object, $hints);
1371         } else {
1372             $profile = $this->localProfile();
1373             self::updateProfile($profile, $object, $hints);
1374         }
1375         $avatar = self::getActivityObjectAvatar($object, $hints);
1376         if ($avatar) {
1377             try {
1378                 $this->updateAvatar($avatar);
1379             } catch (Exception $ex) {
1380                 common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
1381             }
1382         }
1383     }
1384
1385     protected static function updateProfile($profile, $object, $hints=array())
1386     {
1387         $orig = clone($profile);
1388
1389         $profile->nickname = self::getActivityObjectNickname($object, $hints);
1390
1391         if (!empty($object->title)) {
1392             $profile->fullname = $object->title;
1393         } else if (array_key_exists('fullname', $hints)) {
1394             $profile->fullname = $hints['fullname'];
1395         }
1396
1397         if (!empty($object->link)) {
1398             $profile->profileurl = $object->link;
1399         } else if (array_key_exists('profileurl', $hints)) {
1400             $profile->profileurl = $hints['profileurl'];
1401         } else if (Validate::uri($object->id, array('allowed_schemes' => array('http', 'https')))) {
1402             $profile->profileurl = $object->id;
1403         }
1404
1405         $profile->bio      = self::getActivityObjectBio($object, $hints);
1406         $profile->location = self::getActivityObjectLocation($object, $hints);
1407         $profile->homepage = self::getActivityObjectHomepage($object, $hints);
1408
1409         if (!empty($object->geopoint)) {
1410             $location = ActivityContext::locationFromPoint($object->geopoint);
1411             if (!empty($location)) {
1412                 $profile->lat = $location->lat;
1413                 $profile->lon = $location->lon;
1414             }
1415         }
1416
1417         // @fixme tags/categories
1418         // @todo tags from categories
1419
1420         if ($profile->id) {
1421             common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1422             $profile->update($orig);
1423         }
1424     }
1425
1426     protected static function updateGroup($group, $object, $hints=array())
1427     {
1428         $orig = clone($group);
1429
1430         $group->nickname = self::getActivityObjectNickname($object, $hints);
1431         $group->fullname = $object->title;
1432
1433         if (!empty($object->link)) {
1434             $group->mainpage = $object->link;
1435         } else if (array_key_exists('profileurl', $hints)) {
1436             $group->mainpage = $hints['profileurl'];
1437         }
1438
1439         // @todo tags from categories
1440         $group->description = self::getActivityObjectBio($object, $hints);
1441         $group->location = self::getActivityObjectLocation($object, $hints);
1442         $group->homepage = self::getActivityObjectHomepage($object, $hints);
1443
1444         if ($group->id) {
1445             common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
1446             $group->update($orig);
1447         }
1448     }
1449
1450     protected static function getActivityObjectHomepage($object, $hints=array())
1451     {
1452         $homepage = null;
1453         $poco     = $object->poco;
1454
1455         if (!empty($poco)) {
1456             $url = $poco->getPrimaryURL();
1457             if ($url && $url->type == 'homepage') {
1458                 $homepage = $url->value;
1459             }
1460         }
1461
1462         // @todo Try for a another PoCo URL?
1463
1464         return $homepage;
1465     }
1466
1467     protected static function getActivityObjectLocation($object, $hints=array())
1468     {
1469         $location = null;
1470
1471         if (!empty($object->poco) &&
1472             isset($object->poco->address->formatted)) {
1473             $location = $object->poco->address->formatted;
1474         } else if (array_key_exists('location', $hints)) {
1475             $location = $hints['location'];
1476         }
1477
1478         if (!empty($location)) {
1479             if (mb_strlen($location) > 255) {
1480                 $location = mb_substr($note, 0, 255 - 3) . ' â€¦ ';
1481             }
1482         }
1483
1484         // @todo Try to find location some othe way? Via goerss point?
1485
1486         return $location;
1487     }
1488
1489     protected static function getActivityObjectBio($object, $hints=array())
1490     {
1491         $bio  = null;
1492
1493         if (!empty($object->poco)) {
1494             $note = $object->poco->note;
1495         } else if (array_key_exists('bio', $hints)) {
1496             $note = $hints['bio'];
1497         }
1498
1499         if (!empty($note)) {
1500             if (Profile::bioTooLong($note)) {
1501                 // XXX: truncate ok?
1502                 $bio = mb_substr($note, 0, Profile::maxBio() - 3) . ' â€¦ ';
1503             } else {
1504                 $bio = $note;
1505             }
1506         }
1507
1508         // @todo Try to get bio info some other way?
1509
1510         return $bio;
1511     }
1512
1513     protected static function getActivityObjectNickname($object, $hints=array())
1514     {
1515         if ($object->poco) {
1516             if (!empty($object->poco->preferredUsername)) {
1517                 return common_nicknamize($object->poco->preferredUsername);
1518             }
1519         }
1520
1521         if (!empty($object->nickname)) {
1522             return common_nicknamize($object->nickname);
1523         }
1524
1525         if (array_key_exists('nickname', $hints)) {
1526             return $hints['nickname'];
1527         }
1528
1529         // Try the profile url (like foo.example.com or example.com/user/foo)
1530
1531         $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
1532
1533         if (!empty($profileUrl)) {
1534             $nickname = self::nicknameFromURI($profileUrl);
1535         }
1536
1537         // Try the URI (may be a tag:, http:, acct:, ...
1538
1539         if (empty($nickname)) {
1540             $nickname = self::nicknameFromURI($object->id);
1541         }
1542
1543         // Try a Webfinger if one was passed (way) down
1544
1545         if (empty($nickname)) {
1546             if (array_key_exists('webfinger', $hints)) {
1547                 $nickname = self::nicknameFromURI($hints['webfinger']);
1548             }
1549         }
1550
1551         // Try the name
1552
1553         if (empty($nickname)) {
1554             $nickname = common_nicknamize($object->title);
1555         }
1556
1557         return $nickname;
1558     }
1559
1560     protected static function nicknameFromURI($uri)
1561     {
1562         preg_match('/(\w+):/', $uri, $matches);
1563
1564         $protocol = $matches[1];
1565
1566         switch ($protocol) {
1567         case 'acct':
1568         case 'mailto':
1569             if (preg_match("/^$protocol:(.*)?@.*\$/", $uri, $matches)) {
1570                 return common_canonical_nickname($matches[1]);
1571             }
1572             return null;
1573         case 'http':
1574             return common_url_to_nickname($uri);
1575             break;
1576         default:
1577             return null;
1578         }
1579     }
1580
1581     /**
1582      * Look up, and if necessary create, an Ostatus_profile for the remote
1583      * entity with the given webfinger address.
1584      * This should never return null -- you will either get an object or
1585      * an exception will be thrown.
1586      *
1587      * @param string $addr webfinger address
1588      * @return Ostatus_profile
1589      * @throws Exception on error conditions
1590      * @throws OStatusShadowException if this reference would obscure a local user/group
1591      */
1592     public static function ensureWebfinger($addr)
1593     {
1594         // First, try the cache
1595
1596         $uri = self::cacheGet(sprintf('ostatus_profile:webfinger:%s', $addr));
1597
1598         if ($uri !== false) {
1599             if (is_null($uri)) {
1600                 // Negative cache entry
1601                 // @todo i18n FIXME: add i18n.
1602                 throw new Exception('Not a valid webfinger address.');
1603             }
1604             $oprofile = Ostatus_profile::staticGet('uri', $uri);
1605             if (!empty($oprofile)) {
1606                 return $oprofile;
1607             }
1608         }
1609
1610         // Try looking it up
1611
1612         $oprofile = Ostatus_profile::staticGet('uri', 'acct:'.$addr);
1613
1614         if (!empty($oprofile)) {
1615             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1616             return $oprofile;
1617         }
1618
1619         // Now, try some discovery
1620
1621         $disco = new Discovery();
1622
1623         try {
1624             $xrd = $disco->lookup($addr);
1625         } catch (Exception $e) {
1626             // Save negative cache entry so we don't waste time looking it up again.
1627             // @fixme distinguish temporary failures?
1628             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
1629             // @todo i18n FIXME: add i18n.
1630             throw new Exception('Not a valid webfinger address.');
1631         }
1632
1633         $hints = array('webfinger' => $addr);
1634
1635         $dhints = DiscoveryHints::fromXRD($xrd);
1636
1637         $hints = array_merge($hints, $dhints);
1638
1639         // If there's an Hcard, let's grab its info
1640
1641         if (array_key_exists('hcard', $hints)) {
1642             if (!array_key_exists('profileurl', $hints) ||
1643                 $hints['hcard'] != $hints['profileurl']) {
1644                 $hcardHints = DiscoveryHints::fromHcardUrl($hints['hcard']);
1645                 $hints = array_merge($hcardHints, $hints);
1646             }
1647         }
1648
1649         // If we got a feed URL, try that
1650
1651         if (array_key_exists('feedurl', $hints)) {
1652             try {
1653                 common_log(LOG_INFO, "Discovery on acct:$addr with feed URL " . $hints['feedurl']);
1654                 $oprofile = self::ensureFeedURL($hints['feedurl'], $hints);
1655                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1656                 return $oprofile;
1657             } catch (Exception $e) {
1658                 common_log(LOG_WARNING, "Failed creating profile from feed URL '$feedUrl': " . $e->getMessage());
1659                 // keep looking
1660             }
1661         }
1662
1663         // If we got a profile page, try that!
1664
1665         if (array_key_exists('profileurl', $hints)) {
1666             try {
1667                 common_log(LOG_INFO, "Discovery on acct:$addr with profile URL $profileUrl");
1668                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
1669                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1670                 return $oprofile;
1671             } catch (OStatusShadowException $e) {
1672                 // We've ended up with a remote reference to a local user or group.
1673                 // @fixme ideally we should be able to say who it was so we can
1674                 // go back and refer to it the regular way
1675                 throw $e;
1676             } catch (Exception $e) {
1677                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
1678                 // keep looking
1679                 //
1680                 // @fixme this means an error discovering from profile page
1681                 // may give us a corrupt entry using the webfinger URI, which
1682                 // will obscure the correct page-keyed profile later on.
1683             }
1684         }
1685
1686         // XXX: try hcard
1687         // XXX: try FOAF
1688
1689         if (array_key_exists('salmon', $hints)) {
1690
1691             $salmonEndpoint = $hints['salmon'];
1692
1693             // An account URL, a salmon endpoint, and a dream? Not much to go
1694             // on, but let's give it a try
1695
1696             $uri = 'acct:'.$addr;
1697
1698             $profile = new Profile();
1699
1700             $profile->nickname = self::nicknameFromUri($uri);
1701             $profile->created  = common_sql_now();
1702
1703             if (isset($profileUrl)) {
1704                 $profile->profileurl = $profileUrl;
1705             }
1706
1707             $profile_id = $profile->insert();
1708
1709             if (!$profile_id) {
1710                 common_log_db_error($profile, 'INSERT', __FILE__);
1711                 // @todo i18n FIXME: add i18n and use sprintf for parameter.
1712                 throw new Exception("Couldn't save profile for '$addr'.");
1713             }
1714
1715             $oprofile = new Ostatus_profile();
1716
1717             $oprofile->uri        = $uri;
1718             $oprofile->salmonuri  = $salmonEndpoint;
1719             $oprofile->profile_id = $profile_id;
1720             $oprofile->created    = common_sql_now();
1721
1722             if (isset($feedUrl)) {
1723                 $profile->feeduri = $feedUrl;
1724             }
1725
1726             $result = $oprofile->insert();
1727
1728             if (!$result) {
1729                 common_log_db_error($oprofile, 'INSERT', __FILE__);
1730                 // @todo i18n FIXME: add i18n and use sprintf for parameter.
1731                 throw new Exception("Couldn't save ostatus_profile for '$addr'.");
1732             }
1733
1734             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
1735             return $oprofile;
1736         }
1737
1738         // @todo i18n FIXME: add i18n and use sprintf for parameter.
1739         throw new Exception("Couldn't find a valid profile for '$addr'");
1740     }
1741
1742     /**
1743      * Store the full-length scrubbed HTML of a remote notice to an attachment
1744      * file on our server. We'll link to this at the end of the cropped version.
1745      *
1746      * @param string $title plaintext for HTML page's title
1747      * @param string $rendered HTML fragment for HTML page's body
1748      * @return File
1749      */
1750     function saveHTMLFile($title, $rendered)
1751     {
1752         $final = sprintf("<!DOCTYPE html>\n" .
1753                          '<html><head>' .
1754                          '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
1755                          '<title>%s</title>' .
1756                          '</head>' .
1757                          '<body>%s</body></html>',
1758                          htmlspecialchars($title),
1759                          $rendered);
1760
1761         $filename = File::filename($this->localProfile(),
1762                                    'ostatus', // ignored?
1763                                    'text/html');
1764
1765         $filepath = File::path($filename);
1766
1767         file_put_contents($filepath, $final);
1768
1769         $file = new File;
1770
1771         $file->filename = $filename;
1772         $file->url      = File::url($filename);
1773         $file->size     = filesize($filepath);
1774         $file->date     = time();
1775         $file->mimetype = 'text/html';
1776
1777         $file_id = $file->insert();
1778
1779         if ($file_id === false) {
1780             common_log_db_error($file, "INSERT", __FILE__);
1781             throw new ServerException(_('Could not store HTML content of long post as file.'));
1782         }
1783
1784         return $file;
1785     }
1786
1787     static function ensureProfileURI($uri)
1788     {
1789         $oprofile = null;
1790
1791         // First, try to query it
1792
1793         $oprofile = Ostatus_profile::staticGet('uri', $uri);
1794
1795         // If unfound, do discovery stuff
1796
1797         if (empty($oprofile)) {
1798             if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
1799                 $protocol = $match[1];
1800                 switch ($protocol) {
1801                 case 'http':
1802                 case 'https':
1803                     $oprofile = Ostatus_profile::ensureProfileURL($uri);
1804                     break;
1805                 case 'acct':
1806                 case 'mailto':
1807                     $rest = $match[2];
1808                     $oprofile = Ostatus_profile::ensureWebfinger($rest);
1809                 default:
1810                     common_log("Unrecognized URI protocol for profile: $protocol ($uri)");
1811                     break;
1812                 }
1813             }
1814         }
1815         return $oprofile;
1816     }
1817 }
1818
1819 /**
1820  * Exception indicating we've got a remote reference to a local user,
1821  * not a remote user!
1822  *
1823  * If we can ue a local profile after all, it's available as $e->profile.
1824  */
1825 class OStatusShadowException extends Exception
1826 {
1827     public $profile;
1828
1829     /**
1830      * @param Profile $profile
1831      * @param string $message
1832      */
1833     function __construct($profile, $message) {
1834         $this->profile = $profile;
1835         parent::__construct($message);
1836     }
1837 }