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