]> git.mxchange.org Git - quix0rs-gnu-social.git/blobdiff - plugins/OStatus/classes/Ostatus_profile.php
Include meta charset header in saved HTML file for long OStatus messages; without...
[quix0rs-gnu-social.git] / plugins / OStatus / classes / Ostatus_profile.php
index 80b980aba4dac360156c6ed36698993e9bc6b43b..8ba2ce0c313fffe4f30d9b8a3c32160bdba1c4df 100644 (file)
@@ -204,12 +204,13 @@ 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...");
         }
     }
 
@@ -222,15 +223,13 @@ class Ostatus_profile extends Memcached_DataObject
      */
     public function unsubscribe() {
         $feedsub = FeedSub::staticGet('uri', $this->feeduri);
-        if (!$feedsub) {
+        if (!$feedsub || $feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') {
+            // No active PuSH subscription, we can just leave it be.
             return true;
-        }
-        if ($feedsub->sub_state == 'active') {
+        } else {
+            // PuSH subscription is either active or in an indeterminate state.
+            // Send an unsubscribe.
             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...");
         }
     }
 
@@ -389,11 +388,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("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 +411,26 @@ class Ostatus_profile extends Memcached_DataObject
         }
     }
 
+    public function processRssFeed(DOMElement $rss, $source)
+    {
+        $channels = $rss->getElementsByTagName('channel');
+
+        if ($channels->length == 0) {
+            throw new Exception("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.
      *
@@ -417,6 +442,18 @@ class Ostatus_profile extends Memcached_DataObject
     {
         $activity = new Activity($entry, $feed);
 
+        // @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:
+            break;
+        default:
+            throw new ClientException("Can't handle that kind of post.");
+        }
+
         if ($activity->verb == ActivityVerb::POST) {
             $this->processPost($activity, $source);
         } else {
@@ -443,24 +480,27 @@ 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 {
-                common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri");
-                return false;
+                throw new Exception("Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
             }
+
             $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 +509,30 @@ 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?
+            throw new ClientException("No content for notice {$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 +543,29 @@ 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.
+                $attachUrl = common_local_url('attachment',
+                                              array('attachment' => $attachment->id));
+                $rendered = common_render_text($shortSummary) .
+                            '<a href="' . htmlspecialchars($attachUrl) .'"'.
+                            ' class="attachment more"' .
+                            ' title="'. htmlspecialchars(_m('Show more')) . '">' .
+                            '&#8230;' .
+                            '</a>';
             }
         }
 
@@ -658,9 +720,13 @@ 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
      */
 
     public static function ensureProfileURL($profile_url, $hints=array())
@@ -681,7 +747,7 @@ class Ostatus_profile extends Memcached_DataObject
         $response = $client->get($profile_url);
 
         if (!$response->isOk()) {
-            return null;
+            throw new Exception("Could not reach profile page: " . $profile_url);
         }
 
         // Check if we have a non-canonical URL
@@ -735,11 +801,20 @@ class Ostatus_profile extends Memcached_DataObject
 
         if (!empty($feedurl)) {
             $hints['feedurl'] = $feedurl;
-
             return self::ensureFeedURL($feedurl, $hints);
         }
+
+        throw new Exception("Could not find a feed URL for profile page " . $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 Exception for local profiles
+     */
     static function getFromProfileURL($profile_url)
     {
         $profile = Profile::staticGet('profileurl', $profile_url);
@@ -771,6 +846,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();
@@ -799,6 +882,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 +917,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) {
 
@@ -849,8 +944,40 @@ class Ostatus_profile extends Memcached_DataObject
         throw new FeedSubException("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.
@@ -992,11 +1119,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 +1134,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,35 +1160,45 @@ 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");
@@ -1059,6 +1211,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,7 +1229,8 @@ class Ostatus_profile extends Memcached_DataObject
             throw new Exception("No profile URI");
         }
 
-        if (OStatusPlugin::localProfileFromUrl($homeuri)) {
+        $user = User::staticGet('uri', $homeuri);
+        if ($user) {
             throw new Exception("Local user can't be referenced as remote.");
         }
 
@@ -1322,9 +1477,19 @@ class Ostatus_profile extends Memcached_DataObject
             return $hints['nickname'];
         }
 
-        // Try the definitive ID
+        // Try the profile url (like foo.example.com or example.com/user/foo)
 
-        $nickname = self::nicknameFromURI($object->id);
+        $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
+
+        if (!empty($profileUrl)) {
+            $nickname = self::nicknameFromURI($profileUrl);
+        }
+
+        // Try the URI (may be a tag:, http:, acct:, ...
+
+        if (empty($nickname)) {
+            $nickname = self::nicknameFromURI($object->id);
+        }
 
         // Try a Webfinger if one was passed (way) down
 
@@ -1365,6 +1530,11 @@ 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
@@ -1505,10 +1675,22 @@ class Ostatus_profile extends Memcached_DataObject
         throw new Exception("Couldn't find a valid profile for '$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);