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