]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - plugins/OStatus/classes/Ostatus_profile.php
Merge branch '0.9.x' into activityexport
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
index 31fba009ed0346c85788fbb7e74b53dfe8235176..047435f6687acf1cc0f517809c1d06921d92fb3c 100644 (file)
@@ -21,7 +21,6 @@
  * @package OStatusPlugin
  * @maintainer Brion Vibber <brion@status.net>
  */
-
 class Ostatus_profile extends Memcached_DataObject
 {
     public $__table = 'ostatus_profile';
@@ -51,7 +50,6 @@ class Ostatus_profile extends Memcached_DataObject
      *
      * @return array array of column definitions
      */
-
     function table()
     {
         return array('uri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL,
@@ -92,7 +90,6 @@ class Ostatus_profile extends Memcached_DataObject
      *
      * @return array key definitions
      */
-
     function keys()
     {
         return array_keys($this->keyTypes());
@@ -106,7 +103,6 @@ class Ostatus_profile extends Memcached_DataObject
      *
      * @return array key definitions
      */
-
     function keyTypes()
     {
         return array('uri' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U');
@@ -188,9 +184,11 @@ class Ostatus_profile extends Memcached_DataObject
         } else if ($this->group_id && !$this->profile_id) {
             return true;
         } else if ($this->group_id && $this->profile_id) {
-            throw new ServerException("Invalid ostatus_profile state: both group and profile IDs set for $this->uri");
+            // TRANS: Server exception.
+            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: both group and profile IDs set for %s.'),$this->uri));
         } else {
-            throw new ServerException("Invalid ostatus_profile state: both group and profile IDs empty for $this->uri");
+            // TRANS: Server exception.
+            throw new ServerException(sprintf(_m('Invalid ostatus_profile state: both group and profile IDs empty for %s.'),$this->uri));
         }
     }
 
@@ -204,34 +202,24 @@ class Ostatus_profile extends Memcached_DataObject
     public function subscribe()
     {
         $feedsub = FeedSub::ensureFeed($this->feeduri);
-        if ($feedsub->sub_state == 'active' || $feedsub->sub_state == 'subscribe') {
+        if ($feedsub->sub_state == 'active') {
+            // Active subscription, we don't need to do anything.
             return true;
-        } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
+        } else {
+            // Inactive or we got left in an inconsistent state.
+            // Run a subscription request to make sure we're current!
             return $feedsub->subscribe();
-        } else if ('unsubscribe') {
-            throw new FeedSubException("Unsub is pending, can't subscribe...");
         }
     }
 
     /**
-     * Send a PuSH unsubscription request to the hub for this feed.
-     * The hub will later send us a confirmation POST to /main/push/callback.
+     * Check if this remote profile has any active local subscriptions, and
+     * if not drop the PuSH subscription feed.
      *
      * @return bool true on success, false on failure
-     * @throws ServerException if feed state is not valid
      */
     public function unsubscribe() {
-        $feedsub = FeedSub::staticGet('uri', $this->feeduri);
-        if (!$feedsub) {
-            return true;
-        }
-        if ($feedsub->sub_state == 'active') {
-            return $feedsub->unsubscribe();
-        } else if ($feedsub->sub_state == '' || $feedsub->sub_state == 'inactive' || $feedsub->sub_state == 'unsubscribe') {
-            return true;
-        } else if ($feedsub->sub_state == 'subscribe') {
-            throw new FeedSubException("Feed is awaiting subscription, can't unsub...");
-        }
+        $this->garbageCollect();
     }
 
     /**
@@ -241,6 +229,21 @@ class Ostatus_profile extends Memcached_DataObject
      * @return boolean
      */
     public function garbageCollect()
+    {
+        $feedsub = FeedSub::staticGet('uri', $this->feeduri);
+        return $feedsub->garbageCollect();
+    }
+
+    /**
+     * Check if this remote profile has any active local subscriptions, so the
+     * PuSH subscription layer can decide if it can drop the feed.
+     *
+     * This gets called via the FeedSubSubscriberCount event when running
+     * FeedSub::garbageCollect().
+     *
+     * @return int
+     */
+    public function subscriberCount()
     {
         if ($this->isGroup()) {
             $members = $this->localGroup()->getMembers(0, 1);
@@ -248,13 +251,14 @@ class Ostatus_profile extends Memcached_DataObject
         } else {
             $count = $this->localProfile()->subscriberCount();
         }
-        if ($count == 0) {
-            common_log(LOG_INFO, "Unsubscribing from now-unused remote feed $this->feeduri");
-            $this->unsubscribe();
-            return true;
-        } else {
-            return false;
-        }
+        common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");
+
+        // Other plugins may be piggybacking on OStatus without having
+        // an active group or user-to-user subscription we know about.
+        Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
+        common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");
+
+        return $count;
     }
 
     /**
@@ -272,7 +276,9 @@ class Ostatus_profile extends Memcached_DataObject
             if ($type == 'object') {
                 $type = get_class($actor);
             }
-            throw new ServerException("Invalid actor passed to " . __METHOD__ . ": " . $type);
+            // TRANS: Server exception.
+            // TRANS: %1$s is the method name the exception occured in, %2$s is the actor type.
+            throw new ServerException(sprintf(_m('Invalid actor passed to %1$s: %2$s.'),__METHOD__,$type));
         }
         if ($object == null) {
             $object = $this;
@@ -364,7 +370,8 @@ class Ostatus_profile extends Memcached_DataObject
         } else if ($entry instanceof Notice) {
             return $preamble . $entry->asAtomEntry(true, true);
         } else {
-            throw new ServerException("Invalid type passed to Ostatus_profile::notify; must be XML string or Activity entry");
+            // TRANS: Server exception.
+            throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
         }
     }
 
@@ -389,11 +396,17 @@ class Ostatus_profile extends Memcached_DataObject
     {
         $feed = $doc->documentElement;
 
-        if ($feed->localName != 'feed' || $feed->namespaceURI != Activity::ATOM) {
-            common_log(LOG_ERR, __METHOD__ . ": not an Atom feed, ignoring");
-            return;
+        if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
+            $this->processAtomFeed($feed, $source);
+        } else if ($feed->localName == 'rss') { // @fixme check namespace
+            $this->processRssFeed($feed, $source);
+        } else {
+            throw new Exception(_m('Unknown feed format.'));
         }
+    }
 
+    public function processAtomFeed(DOMElement $feed, $source)
+    {
         $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
         if ($entries->length == 0) {
             common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
@@ -406,6 +419,26 @@ class Ostatus_profile extends Memcached_DataObject
         }
     }
 
+    public function processRssFeed(DOMElement $rss, $source)
+    {
+        $channels = $rss->getElementsByTagName('channel');
+
+        if ($channels->length == 0) {
+            throw new Exception(_m('RSS feed without a channel.'));
+        } else if ($channels->length > 1) {
+            common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
+        }
+
+        $channel = $channels->item(0);
+
+        $items = $channel->getElementsByTagName('item');
+
+        for ($i = 0; $i < $items->length; $i++) {
+            $item = $items->item($i);
+            $this->processEntry($item, $channel, $source);
+        }
+    }
+
     /**
      * Process a posted entry from this feed source.
      *
@@ -413,14 +446,33 @@ class Ostatus_profile extends Memcached_DataObject
      * @param DOMElement $feed for context
      * @param string $source identifier ("push" or "salmon")
      */
+
     public function processEntry($entry, $feed, $source)
     {
         $activity = new Activity($entry, $feed);
 
-        if ($activity->verb == ActivityVerb::POST) {
-            $this->processPost($activity, $source);
-        } else {
-            common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
+        if (Event::handle('StartHandleFeedEntry', array($activity))) {
+
+            // @todo process all activity objects
+            switch ($activity->objects[0]->type) {
+            case ActivityObject::ARTICLE:
+            case ActivityObject::BLOGENTRY:
+            case ActivityObject::NOTE:
+            case ActivityObject::STATUS:
+            case ActivityObject::COMMENT:
+                       case null:
+                if ($activity->verb == ActivityVerb::POST) {
+                    $this->processPost($activity, $source);
+                } else {
+                    common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
+                }
+                break;
+            default:
+                // TRANS: Client exception.
+                throw new ClientException(_m('Can\'t handle that kind of post.'));
+            }
+
+            Event::handle('EndHandleFeedEntry', array($activity));
         }
     }
 
@@ -443,24 +495,36 @@ class Ostatus_profile extends Memcached_DataObject
                 return false;
             }
         } else {
-            // Individual user feeds may contain only posts from themselves.
-            // Authorship is validated against the profile URI on upper layers,
-            // through PuSH setup or Salmon signature checks.
-            $actorUri = self::getActorProfileURI($activity);
-            if ($actorUri == $this->uri) {
-                // Check if profile info has changed and update it
-                $this->updateFromActivityObject($activity->actor);
+            $actor = $activity->actor;
+
+            if (empty($actor)) {
+                // OK here! assume the default
+            } else if ($actor->id == $this->uri || $actor->link == $this->uri) {
+                $this->updateFromActivityObject($actor);
+            } else if ($actor->id) {
+                // We have an ActivityStreams actor with an explicit ID that doesn't match the feed owner.
+                // This isn't what we expect from mainline OStatus person feeds!
+                // Group feeds go down another path, with different validation...
+                // Most likely this is a plain ol' blog feed of some kind which
+                // doesn't match our expectations. We'll take the entry, but ignore
+                // the <author> info.
+                common_log(LOG_WARNING, "Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
             } else {
-                common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
-                return false;
+                // Plain <author> without ActivityStreams actor info.
+                // We'll just ignore this info for now and save the update under the feed's identity.
             }
+
             $oprofile = $this;
         }
 
+        // It's not always an ActivityObject::NOTE, but... let's just say it is.
+
+        $note = $activity->objects[0];
+
         // The id URI will be used as a unique identifier for for the notice,
         // protecting against duplicate saves. It isn't required to be a URL;
         // tag: URIs for instance are found in Google Buzz feeds.
-        $sourceUri = $activity->object->id;
+        $sourceUri = $note->id;
         $dupe = Notice::staticGet('uri', $sourceUri);
         if ($dupe) {
             common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
@@ -469,16 +533,31 @@ class Ostatus_profile extends Memcached_DataObject
 
         // We'll also want to save a web link to the original notice, if provided.
         $sourceUrl = null;
-        if ($activity->object->link) {
-            $sourceUrl = $activity->object->link;
+        if ($note->link) {
+            $sourceUrl = $note->link;
         } else if ($activity->link) {
             $sourceUrl = $activity->link;
-        } else if (preg_match('!^https?://!', $activity->object->id)) {
-            $sourceUrl = $activity->object->id;
+        } else if (preg_match('!^https?://!', $note->id)) {
+            $sourceUrl = $note->id;
+        }
+
+        // Use summary as fallback for content
+
+        if (!empty($note->content)) {
+            $sourceContent = $note->content;
+        } else if (!empty($note->summary)) {
+            $sourceContent = $note->summary;
+        } else if (!empty($note->title)) {
+            $sourceContent = $note->title;
+        } else {
+            // @fixme fetch from $sourceUrl?
+            // TRANS: Client exception. %s is a source URL.
+            throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
         }
 
         // Get (safe!) HTML and text versions of the content
-        $rendered = $this->purify($activity->object->content);
+
+        $rendered = $this->purify($sourceContent);
         $content = html_entity_decode(strip_tags($rendered));
 
         $shortened = common_shorten_links($content);
@@ -489,21 +568,32 @@ class Ostatus_profile extends Memcached_DataObject
         $attachment = null;
 
         if (Notice::contentTooLong($shortened)) {
-            $attachment = $this->saveHTMLFile($activity->object->title, $rendered);
-            $summary = $activity->object->summary;
+            $attachment = $this->saveHTMLFile($note->title, $rendered);
+            $summary = html_entity_decode(strip_tags($note->summary));
             if (empty($summary)) {
                 $summary = $content;
             }
             $shortSummary = common_shorten_links($summary);
             if (Notice::contentTooLong($shortSummary)) {
-                $url = common_shorten_url(common_local_url('attachment',
-                                                           array('attachment' => $attachment->id)));
+                $url = common_shorten_url($sourceUrl);
                 $shortSummary = substr($shortSummary,
                                        0,
                                        Notice::maxContent() - (mb_strlen($url) + 2));
-                $shortSummary .= '… ' . $url;
-                $content = $shortSummary;
-                $rendered = common_render_text($content);
+                $content = $shortSummary . ' ' . $url;
+
+                // We mark up the attachment link specially for the HTML output
+                // so we can fold-out the full version inline.
+
+                // TRANS: Shown when a notice is longer than supported and/or when attachments are present.
+                $showMoreText = _m('Show more');
+                $attachUrl = common_local_url('attachment',
+                                              array('attachment' => $attachment->id));
+                $rendered = common_render_text($shortSummary) .
+                            '<a href="' . htmlspecialchars($attachUrl) .'"'.
+                            ' class="attachment more"' .
+                            ' title="'. htmlspecialchars($showMoreText) . '">' .
+                            '&#8230;' .
+                            '</a>';
             }
         }
 
@@ -606,7 +696,7 @@ class Ostatus_profile extends Memcached_DataObject
         common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', $attention_uris));
         $groups = array();
         $replies = array();
-        foreach ($attention_uris as $recipient) {
+        foreach (array_unique($attention_uris) as $recipient) {
             // Is the recipient a local user?
             $user = User::staticGet('uri', $recipient);
             if ($user) {
@@ -615,21 +705,7 @@ class Ostatus_profile extends Memcached_DataObject
                 continue;
             }
 
-            // Is the recipient a remote group?
-            $oprofile = Ostatus_profile::staticGet('uri', $recipient);
-            if ($oprofile) {
-                if ($oprofile->isGroup()) {
-                    // Deliver to local members of this remote group.
-                    // @fixme sender verification?
-                    $groups[] = $oprofile->group_id;
-                } else {
-                    common_log(LOG_DEBUG, "Skipping reply to remote profile $recipient");
-                }
-                continue;
-            }
-
             // Is the recipient a local group?
-            // @fixme uri on user_group isn't reliable yet
             // $group = User_group::staticGet('uri', $recipient);
             $id = OStatusPlugin::localGroupFromUrl($recipient);
             if ($id) {
@@ -648,7 +724,22 @@ class Ostatus_profile extends Memcached_DataObject
                 }
             }
 
-            common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient");
+            // Is the recipient a remote user or group?
+            try {
+                $oprofile = Ostatus_profile::ensureProfileURI($recipient);
+                if ($oprofile->isGroup()) {
+                    // Deliver to local members of this remote group.
+                    // @fixme sender verification?
+                    $groups[] = $oprofile->group_id;
+                } else {
+                    // may be canonicalized or something
+                    $replies[] = $oprofile->uri;
+                }
+                continue;
+            } catch (Exception $e) {
+                // Neither a recognizable local nor remote user!
+                common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
+            }
 
         }
         $attention_uris = $replies;
@@ -658,9 +749,14 @@ class Ostatus_profile extends Memcached_DataObject
     }
 
     /**
+     * Look up and if necessary create an Ostatus_profile for the remote entity
+     * with the given profile page URL. This should never return null -- you
+     * will either get an object or an exception will be thrown.
+     *
      * @param string $profile_url
      * @return Ostatus_profile
-     * @throws FeedSubException
+     * @throws Exception on various error conditions
+     * @throws OStatusShadowException if this reference would obscure a local user/group
      */
 
     public static function ensureProfileURL($profile_url, $hints=array())
@@ -681,7 +777,8 @@ class Ostatus_profile extends Memcached_DataObject
         $response = $client->get($profile_url);
 
         if (!$response->isOk()) {
-            return null;
+            // TRANS: Exception. %s is a profile URL.
+            throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
         }
 
         // Check if we have a non-canonical URL
@@ -735,11 +832,21 @@ class Ostatus_profile extends Memcached_DataObject
 
         if (!empty($feedurl)) {
             $hints['feedurl'] = $feedurl;
-
             return self::ensureFeedURL($feedurl, $hints);
         }
+
+        // TRANS: Exception.
+        throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
     }
 
+    /**
+     * Look up the Ostatus_profile, if present, for a remote entity with the
+     * given profile page URL. Will return null for both unknown and invalid
+     * remote profiles.
+     *
+     * @return mixed Ostatus_profile or null
+     * @throws OStatusShadowException for local profiles
+     */
     static function getFromProfileURL($profile_url)
     {
         $profile = Profile::staticGet('profileurl', $profile_url);
@@ -761,7 +868,8 @@ class Ostatus_profile extends Memcached_DataObject
         $user = User::staticGet('id', $profile->id);
 
         if (!empty($user)) {
-            throw new Exception("'$profile_url' is the profile for local user '{$user->nickname}'.");
+            // @todo i18n FIXME: use sprintf and add i18n (?)
+            throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
         }
 
         // Continue discovery; it's a remote profile
@@ -771,6 +879,14 @@ class Ostatus_profile extends Memcached_DataObject
         return null;
     }
 
+    /**
+     * Look up and if necessary create an Ostatus_profile for remote entity
+     * with the given update feed. This should never return null -- you will
+     * either get an object or an exception will be thrown.
+     *
+     * @return Ostatus_profile
+     * @throws Exception
+     */
     public static function ensureFeedURL($feed_url, $hints=array())
     {
         $discover = new FeedDiscovery();
@@ -778,12 +894,12 @@ class Ostatus_profile extends Memcached_DataObject
         $feeduri = $discover->discoverFromFeedURL($feed_url);
         $hints['feedurl'] = $feeduri;
 
-        $huburi = $discover->getAtomLink('hub');
+        $huburi = $discover->getHubLink();
         $hints['hub'] = $huburi;
         $salmonuri = $discover->getAtomLink(Salmon::NS_REPLIES);
         $hints['salmon'] = $salmonuri;
 
-        if (!$huburi) {
+        if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
             // We can only deal with folks with a PuSH hub
             throw new FeedSubNoHubException();
         }
@@ -799,6 +915,18 @@ class Ostatus_profile extends Memcached_DataObject
         }
     }
 
+    /**
+     * Look up and, if necessary, create an Ostatus_profile for the remote
+     * profile with the given Atom feed - actually loaded from the feed.
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
+     *
+     * @param DOMElement $feedEl root element of a loaded Atom feed
+     * @param array $hints additional discovery information passed from higher levels
+     * @fixme should this be marked public?
+     * @return Ostatus_profile
+     * @throws Exception
+     */
     public static function ensureAtomFeed($feedEl, $hints)
     {
         // Try to get a profile from the feed activity:subject
@@ -822,7 +950,7 @@ class Ostatus_profile extends Memcached_DataObject
         // Sheesh. Not a very nice feed! Let's try fingerpoken in the
         // entries.
 
-        $entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
+        $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
 
         if (!empty($entries) && $entries->length > 0) {
 
@@ -845,12 +973,43 @@ class Ostatus_profile extends Memcached_DataObject
         }
 
         // XXX: make some educated guesses here
-
-        throw new FeedSubException("Can't find enough profile information to make a feed.");
+        throw new FeedSubException(_m('Can\'t find enough profile information to make a feed.'));
     }
 
+    /**
+     * Look up and, if necessary, create an Ostatus_profile for the remote
+     * profile with the given RSS feed - actually loaded from the feed.
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
+     *
+     * @param DOMElement $feedEl root element of a loaded RSS feed
+     * @param array $hints additional discovery information passed from higher levels
+     * @fixme should this be marked public?
+     * @return Ostatus_profile
+     * @throws Exception
+     */
     public static function ensureRssChannel($feedEl, $hints)
     {
+        // Special-case for Posterous. They have some nice metadata in their
+        // posterous:author elements. We should use them instead of the channel.
+
+        $items = $feedEl->getElementsByTagName('item');
+
+        if ($items->length > 0) {
+            $item = $items->item(0);
+            $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
+            if (!empty($authorEl)) {
+                $obj = ActivityObject::fromPosterousAuthor($authorEl);
+                // Posterous has multiple authors per feed, and multiple feeds
+                // per author. We check if this is the "main" feed for this author.
+                if (array_key_exists('profileurl', $hints) &&
+                    !empty($obj->poco) &&
+                    common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
+                    return self::ensureActivityObjectProfile($obj, $hints);
+                }
+            }
+        }
+
         // @fixme we should check whether this feed has elements
         // with different <author> or <dc:creator> elements, and... I dunno.
         // Do something about that.
@@ -873,7 +1032,7 @@ class Ostatus_profile extends Memcached_DataObject
             return;
         }
         if (!common_valid_http_url($url)) {
-            throw new ServerException(_m("Invalid avatar URL %s"), $url);
+            throw new ServerException(sprintf(_m("Invalid avatar URL %s."), $url));
         }
 
         if ($this->isGroup()) {
@@ -883,7 +1042,7 @@ class Ostatus_profile extends Memcached_DataObject
         }
         if (!$self) {
             throw new ServerException(sprintf(
-                _m("Tried to update avatar for unsaved remote profile %s"),
+                _m("Tried to update avatar for unsaved remote profile %s."),
                 $this->uri));
         }
 
@@ -891,7 +1050,7 @@ class Ostatus_profile extends Memcached_DataObject
         // ripped from oauthstore.php (for old OMB client)
         $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
         if (!copy($url, $temp_filename)) {
-            throw new ServerException(sprintf(_m("Unable to fetch avatar from %s"), $url));
+            throw new ServerException(sprintf(_m("Unable to fetch avatar from %s."), $url));
         }
 
         if ($this->isGroup()) {
@@ -906,6 +1065,14 @@ class Ostatus_profile extends Memcached_DataObject
                                      null,
                                      common_timestamp());
         rename($temp_filename, Avatar::path($filename));
+        // @fixme hardcoded chmod is lame, but seems to be necessary to
+        // keep from accidentally saving images from command-line (queues)
+        // that can't be read from web server, which causes hard-to-notice
+        // problems later on:
+        //
+        // http://status.net/open-source/issues/2663
+        chmod(Avatar::path($filename), 0644);
+
         $self->setOriginal($filename);
 
         $orig = clone($this);
@@ -921,7 +1088,7 @@ class Ostatus_profile extends Memcached_DataObject
      * @return mixed URL string or false
      */
 
-    protected static function getActivityObjectAvatar($object, $hints=array())
+    public static function getActivityObjectAvatar($object, $hints=array())
     {
         if ($object->avatarLinks) {
             $best = false;
@@ -992,11 +1159,14 @@ class Ostatus_profile extends Memcached_DataObject
     /**
      * Fetch, or build if necessary, an Ostatus_profile for the actor
      * in a given Activity Streams activity.
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
      *
      * @param Activity $activity
      * @param string $feeduri if we already know the canonical feed URI!
      * @param string $salmonuri if we already know the salmon return channel URI
      * @return Ostatus_profile
+     * @throws Exception
      */
 
     public static function ensureActorProfile($activity, $hints=array())
@@ -1004,6 +1174,18 @@ class Ostatus_profile extends Memcached_DataObject
         return self::ensureActivityObjectProfile($activity->actor, $hints);
     }
 
+    /**
+     * Fetch, or build if necessary, an Ostatus_profile for the profile
+     * in a given Activity Streams object (can be subject, actor, or object).
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
+     *
+     * @param ActivityObject $object
+     * @param array $hints additional discovery information passed from higher levels
+     * @return Ostatus_profile
+     * @throws Exception
+     */
+
     public static function ensureActivityObjectProfile($object, $hints=array())
     {
         $profile = self::getActivityObjectProfile($object);
@@ -1018,38 +1200,48 @@ class Ostatus_profile extends Memcached_DataObject
     /**
      * @param Activity $activity
      * @return mixed matching Ostatus_profile or false if none known
+     * @throws ServerException if feed info invalid
      */
     public static function getActorProfile($activity)
     {
         return self::getActivityObjectProfile($activity->actor);
     }
 
+    /**
+     * @param ActivityObject $activity
+     * @return mixed matching Ostatus_profile or false if none known
+     * @throws ServerException if feed info invalid
+     */
     protected static function getActivityObjectProfile($object)
     {
         $uri = self::getActivityObjectProfileURI($object);
         return Ostatus_profile::staticGet('uri', $uri);
     }
 
-    protected static function getActorProfileURI($activity)
-    {
-        return self::getActivityObjectProfileURI($activity->actor);
-    }
-
     /**
-     * @param Activity $activity
+     * Get the identifier URI for the remote entity described
+     * by this ActivityObject. This URI is *not* guaranteed to be
+     * a resolvable HTTP/HTTPS URL.
+     *
+     * @param ActivityObject $object
      * @return string
-     * @throws ServerException
+     * @throws ServerException if feed info invalid
      */
     protected static function getActivityObjectProfileURI($object)
     {
-        $opts = array('allowed_schemes' => array('http', 'https'));
-        if ($object->id && Validate::uri($object->id, $opts)) {
-            return $object->id;
+        if ($object->id) {
+            if (ActivityUtils::validateUri($object->id)) {
+                return $object->id;
+            }
         }
-        if ($object->link && Validate::uri($object->link, $opts)) {
+
+        // If the id is missing or invalid (we've seen feeds mistakenly listing
+        // things like local usernames in that field) then we'll use the profile
+        // page link, if valid.
+        if ($object->link && common_valid_http_url($object->link)) {
             return $object->link;
         }
-        throw new ServerException("No author ID URI found");
+        throw new ServerException("No author ID URI found.");
     }
 
     /**
@@ -1059,6 +1251,8 @@ class Ostatus_profile extends Memcached_DataObject
     /**
      * Create local ostatus_profile and profile/user_group entries for
      * the provided remote user or group.
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
      *
      * @param ActivityObject $object
      * @param array $hints
@@ -1075,12 +1269,15 @@ class Ostatus_profile extends Memcached_DataObject
             throw new Exception("No profile URI");
         }
 
-        if (OStatusPlugin::localProfileFromUrl($homeuri)) {
-            throw new Exception("Local user can't be referenced as remote.");
+        $user = User::staticGet('uri', $homeuri);
+        if ($user) {
+            // TRANS: Exception.
+            throw new Exception(_m('Local user can\'t be referenced as remote.'));
         }
 
         if (OStatusPlugin::localGroupFromUrl($homeuri)) {
-            throw new Exception("Local group can't be referenced as remote.");
+            // TRANS: Exception.
+            throw new Exception(_m('Local group can\'t be referenced as remote.'));
         }
 
         if (array_key_exists('feedurl', $hints)) {
@@ -1107,10 +1304,10 @@ class Ostatus_profile extends Memcached_DataObject
                 $discover = new FeedDiscovery();
                 $discover->discoverFromFeedURL($hints['feedurl']);
             }
-            $huburi = $discover->getAtomLink('hub');
+            $huburi = $discover->getHubLink();
         }
 
-        if (!$huburi) {
+        if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
             // We can only deal with folks with a PuSH hub
             throw new FeedSubNoHubException();
         }
@@ -1131,7 +1328,8 @@ class Ostatus_profile extends Memcached_DataObject
 
             $oprofile->profile_id = $profile->insert();
             if (!$oprofile->profile_id) {
-                throw new ServerException("Can't save local profile");
+            // TRANS: Exception.
+                throw new ServerException(_m('Can\'t save local profile.'));
             }
         } else {
             $group = new User_group();
@@ -1141,21 +1339,31 @@ class Ostatus_profile extends Memcached_DataObject
 
             $oprofile->group_id = $group->insert();
             if (!$oprofile->group_id) {
-                throw new ServerException("Can't save local profile");
+                // TRANS: Exception.
+                throw new ServerException(_m('Can\'t save local profile.'));
             }
         }
 
         $ok = $oprofile->insert();
 
-        if ($ok) {
-            $avatar = self::getActivityObjectAvatar($object, $hints);
-            if ($avatar) {
+        if (!$ok) {
+            // TRANS: Exception.
+            throw new ServerException(_m('Can\'t save OStatus profile.'));
+        }
+
+        $avatar = self::getActivityObjectAvatar($object, $hints);
+
+        if ($avatar) {
+            try {
                 $oprofile->updateAvatar($avatar);
+            } catch (Exception $ex) {
+                // Profile is saved, but Avatar is messed up. We're
+                // just going to continue.
+                common_log(LOG_WARNING, "Exception saving OStatus profile avatar: ". $ex->getMessage());
             }
-            return $oprofile;
-        } else {
-            throw new ServerException("Can't save OStatus profile");
         }
+
+        return $oprofile;
     }
 
     /**
@@ -1174,11 +1382,15 @@ class Ostatus_profile extends Memcached_DataObject
         }
         $avatar = self::getActivityObjectAvatar($object, $hints);
         if ($avatar) {
-            $this->updateAvatar($avatar);
+            try {
+                $this->updateAvatar($avatar);
+            } catch (Exception $ex) {
+                common_log(LOG_WARNING, "Exception saving OStatus profile avatar: " . $ex->getMessage());
+            }
         }
     }
 
-    protected static function updateProfile($profile, $object, $hints=array())
+    public static function updateProfile($profile, $object, $hints=array())
     {
         $orig = clone($profile);
 
@@ -1306,7 +1518,7 @@ class Ostatus_profile extends Memcached_DataObject
         return $bio;
     }
 
-    protected static function getActivityObjectNickname($object, $hints=array())
+    public static function getActivityObjectNickname($object, $hints=array())
     {
         if ($object->poco) {
             if (!empty($object->poco->preferredUsername)) {
@@ -1375,9 +1587,15 @@ class Ostatus_profile extends Memcached_DataObject
     }
 
     /**
+     * Look up, and if necessary create, an Ostatus_profile for the remote
+     * entity with the given webfinger address.
+     * This should never return null -- you will either get an object or
+     * an exception will be thrown.
+     *
      * @param string $addr webfinger address
      * @return Ostatus_profile
      * @throws Exception on error conditions
+     * @throws OStatusShadowException if this reference would obscure a local user/group
      */
     public static function ensureWebfinger($addr)
     {
@@ -1388,7 +1606,8 @@ class Ostatus_profile extends Memcached_DataObject
         if ($uri !== false) {
             if (is_null($uri)) {
                 // Negative cache entry
-                throw new Exception('Not a valid webfinger address.');
+                // TRANS: Exception.
+                throw new Exception(_m('Not a valid webfinger address.'));
             }
             $oprofile = Ostatus_profile::staticGet('uri', $uri);
             if (!empty($oprofile)) {
@@ -1415,7 +1634,8 @@ class Ostatus_profile extends Memcached_DataObject
             // Save negative cache entry so we don't waste time looking it up again.
             // @fixme distinguish temporary failures?
             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), null);
-            throw new Exception('Not a valid webfinger address.');
+                // TRANS: Exception.
+            throw new Exception(_m('Not a valid webfinger address.'));
         }
 
         $hints = array('webfinger' => $addr);
@@ -1456,9 +1676,18 @@ class Ostatus_profile extends Memcached_DataObject
                 $oprofile = self::ensureProfileURL($hints['profileurl'], $hints);
                 self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
                 return $oprofile;
+            } catch (OStatusShadowException $e) {
+                // We've ended up with a remote reference to a local user or group.
+                // @fixme ideally we should be able to say who it was so we can
+                // go back and refer to it the regular way
+                throw $e;
             } catch (Exception $e) {
                 common_log(LOG_WARNING, "Failed creating profile from profile URL '$profileUrl': " . $e->getMessage());
                 // keep looking
+                //
+                // @fixme this means an error discovering from profile page
+                // may give us a corrupt entry using the webfinger URI, which
+                // will obscure the correct page-keyed profile later on.
             }
         }
 
@@ -1487,7 +1716,8 @@ class Ostatus_profile extends Memcached_DataObject
 
             if (!$profile_id) {
                 common_log_db_error($profile, 'INSERT', __FILE__);
-                throw new Exception("Couldn't save profile for '$addr'");
+                // TRANS: Exception. %s is a webfinger address.
+                throw new Exception(sprintf(_m('Couldn\'t save profile for "%s".'),$addr));
             }
 
             $oprofile = new Ostatus_profile();
@@ -1505,20 +1735,34 @@ class Ostatus_profile extends Memcached_DataObject
 
             if (!$result) {
                 common_log_db_error($oprofile, 'INSERT', __FILE__);
-                throw new Exception("Couldn't save ostatus_profile for '$addr'");
+                // TRANS: Exception. %s is a webfinger address.
+                throw new Exception(sprintf(_m('Couldn\'t save ostatus_profile for "%s".'),$addr));
             }
 
             self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->uri);
             return $oprofile;
         }
 
-        throw new Exception("Couldn't find a valid profile for '$addr'");
+        // TRANS: Exception. %s is a webfinger address.
+        throw new Exception(sprintf(_m('Couldn\'t find a valid profile for "%s".'),$addr));
     }
 
+    /**
+     * Store the full-length scrubbed HTML of a remote notice to an attachment
+     * file on our server. We'll link to this at the end of the cropped version.
+     *
+     * @param string $title plaintext for HTML page's title
+     * @param string $rendered HTML fragment for HTML page's body
+     * @return File
+     */
     function saveHTMLFile($title, $rendered)
     {
-        $final = sprintf("<!DOCTYPE html>\n<html><head><title>%s</title></head>".
-                         '<body><div>%s</div></body></html>',
+        $final = sprintf("<!DOCTYPE html>\n" .
+                         '<html><head>' .
+                         '<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">' .
+                         '<title>%s</title>' .
+                         '</head>' .
+                         '<body>%s</body></html>',
                          htmlspecialchars($title),
                          $rendered);
 
@@ -1542,9 +1786,60 @@ class Ostatus_profile extends Memcached_DataObject
 
         if ($file_id === false) {
             common_log_db_error($file, "INSERT", __FILE__);
-            throw new ServerException(_('Could not store HTML content of long post as file.'));
+            throw new ServerException(_m('Could not store HTML content of long post as file.'));
         }
 
         return $file;
     }
+
+    static function ensureProfileURI($uri)
+    {
+        $oprofile = null;
+
+        // First, try to query it
+
+        $oprofile = Ostatus_profile::staticGet('uri', $uri);
+
+        // If unfound, do discovery stuff
+
+        if (empty($oprofile)) {
+            if (preg_match("/^(\w+)\:(.*)/", $uri, $match)) {
+                $protocol = $match[1];
+                switch ($protocol) {
+                case 'http':
+                case 'https':
+                    $oprofile = Ostatus_profile::ensureProfileURL($uri);
+                    break;
+                case 'acct':
+                case 'mailto':
+                    $rest = $match[2];
+                    $oprofile = Ostatus_profile::ensureWebfinger($rest);
+                default:
+                    common_log("Unrecognized URI protocol for profile: $protocol ($uri)");
+                    break;
+                }
+            }
+        }
+        return $oprofile;
+    }
+}
+
+/**
+ * Exception indicating we've got a remote reference to a local user,
+ * not a remote user!
+ *
+ * If we can ue a local profile after all, it's available as $e->profile.
+ */
+class OStatusShadowException extends Exception
+{
+    public $profile;
+
+    /**
+     * @param Profile $profile
+     * @param string $message
+     */
+    function __construct($profile, $message) {
+        $this->profile = $profile;
+        parent::__construct($message);
+    }
 }