Commit 0703410b authored by Bhuvan Krishna's avatar Bhuvan Krishna

Merge branch 'upstream' into dfsg

parents 59636169 5ad2f287
......@@ -313,6 +313,21 @@ class Notice extends Managed_DataObject
}
}
public function getSelfLink()
{
if ($this->isLocal()) {
return common_local_url('ApiStatusesShow', array('id' => $this->getID(), 'format' => 'atom'));
}
$selfLink = $this->getPref('ostatus', 'self');
if (!common_valid_http_url($selfLink)) {
throw new InvalidUrlException($selfLink);
}
return $selfLink;
}
public function getObjectType($canonical=false) {
if (is_null($this->object_type) || $this->object_type==='') {
throw new NoObjectTypeException($this);
......@@ -445,6 +460,7 @@ class Notice extends Managed_DataObject
static function saveNew($profile_id, $content, $source, array $options=null) {
$defaults = array('uri' => null,
'url' => null,
'self' => null,
'conversation' => null, // URI of conversation
'reply_to' => null, // This will override convo URI if the parent is known
'repeat_of' => null, // This will override convo URI if the repeated notice is known
......@@ -717,6 +733,10 @@ class Notice extends Managed_DataObject
}
}
if ($self && common_valid_http_url($self)) {
$notice->setPref('ostatus', 'self', $self);
}
// Only save 'attention' and metadata stuff (URLs, tags...) stuff if
// the activityverb is a POST (since stuff like repeat, favorite etc.
// reasonably handle notifications themselves.
......@@ -776,6 +796,9 @@ class Notice extends Managed_DataObject
// implied object
$options['uri'] = $act->id;
$options['url'] = $act->link;
if ($act->selfLink) {
$options['self'] = $act->selfLink;
}
} else {
$actobj = count($act->objects)===1 ? $act->objects[0] : null;
if (!is_null($actobj) && !empty($actobj->id)) {
......@@ -786,6 +809,9 @@ class Notice extends Managed_DataObject
$options['url'] = $actobj->id;
}
}
if ($actobj->selfLink) {
$options['self'] = $actobj->selfLink;
}
}
$defaults = array(
......@@ -795,6 +821,7 @@ class Notice extends Managed_DataObject
'reply_to' => null,
'repeat_of' => null,
'scope' => null,
'self' => null,
'source' => 'unknown',
'tags' => array(),
'uri' => null,
......@@ -999,6 +1026,10 @@ class Notice extends Managed_DataObject
throw new ServerException('StartNoticeSave did not give back a Notice');
}
if ($self && common_valid_http_url($self)) {
$stored->setPref('ostatus', 'self', $self);
}
// Only save 'attention' and metadata stuff (URLs, tags...) stuff if
// the activityverb is a POST (since stuff like repeat, favorite etc.
// reasonably handle notifications themselves.
......@@ -2048,9 +2079,12 @@ class Notice extends Managed_DataObject
}
}
try {
$act->selfLink = $this->getSelfLink();
} catch (InvalidUrlException $e) {
$act->selfLink = null;
}
if ($this->isLocal()) {
$act->selfLink = common_local_url('ApiStatusesShow', array('id' => $this->id,
'format' => 'atom'));
$act->editLink = $act->selfLink;
}
......@@ -2148,6 +2182,11 @@ class Notice extends Managed_DataObject
$object->title = sprintf('New %1$s by %2$s', ActivityObject::canonicalType($object->type), $this->getProfile()->getNickname());
$object->content = $this->getRendered();
$object->link = $this->getUrl();
try {
$object->selfLink = $this->getSelfLink();
} catch (InvalidUrlException $e) {
$object->selfLink = null;
}
$object->extra[] = array('status_net', array('notice_id' => $this->id));
......@@ -3082,4 +3121,27 @@ class Notice extends Managed_DataObject
}
print "\n";
}
public function delPref($namespace, $topic) {
return Notice_prefs::setData($this, $namespace, $topic, null);
}
public function getPref($namespace, $topic, $default=null) {
// If you want an exception to be thrown, call Notice_prefs::getData directly
try {
return Notice_prefs::getData($this, $namespace, $topic, $default);
} catch (NoResultException $e) {
return null;
}
}
// The same as getPref but will fall back to common_config value for the same namespace/topic
public function getConfigPref($namespace, $topic)
{
return Notice_prefs::getConfigData($this, $namespace, $topic);
}
public function setPref($namespace, $topic, $data) {
return Notice_prefs::setData($this, $namespace, $topic, $data);
}
}
<?php
/**
* GNU social
*
* Data class for Notice preferences
*
* PHP version 5
*
* LICENCE: This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Data
* @package GNUsocial
* @author Mikael Nordfeldth <mmn@hethane.se>
* @copyright 2013 Free Software Foundation, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
* @link http://www.gnu.org/software/social/
*/
class Notice_prefs extends Managed_DataObject
{
public $__table = 'notice_prefs'; // table name
public $notice_id; // int(4) primary_key not_null
public $namespace; // varchar(191) not_null
public $topic; // varchar(191) not_null
public $data; // text
public $created; // datetime not_null default_0000-00-00%2000%3A00%3A00
public $modified; // timestamp not_null default_CURRENT_TIMESTAMP
public static function schemaDef()
{
return array(
'fields' => array(
'notice_id' => array('type' => 'int', 'not null' => true, 'description' => 'user'),
'namespace' => array('type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'namespace, like pluginname or category'),
'topic' => array('type' => 'varchar', 'length' => 191, 'not null' => true, 'description' => 'preference key, i.e. description, age...'),
'data' => array('type' => 'blob', 'description' => 'topic data, may be anything'),
'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
),
'primary key' => array('notice_id', 'namespace', 'topic'),
'foreign keys' => array(
'notice_prefs_notice_id_fkey' => array('notice', array('notice_id' => 'id')),
),
'indexes' => array(
'notice_prefs_notice_id_idx' => array('notice_id'),
),
);
}
static function getNamespacePrefs(Notice $notice, $namespace, array $topic=array())
{
if (empty($topic)) {
$prefs = new Notice_prefs();
$prefs->notice_id = $notice->getID();
$prefs->namespace = $namespace;
$prefs->find();
} else {
$prefs = self::pivotGet('notice_id', $notice->getID(), array('namespace'=>$namespace, 'topic'=>$topic));
}
if (empty($prefs->N)) {
throw new NoResultException($prefs);
}
return $prefs;
}
static function getNamespace(Notice $notice, $namespace, array $topic=array())
{
$prefs = self::getNamespacePrefs($notice, $namespace, $topic);
return $prefs->fetchAll();
}
static function getAll(Notice $notice)
{
try {
$prefs = self::listFind('notice_id', array($notice->getID()));
} catch (NoResultException $e) {
return array();
}
$list = array();
while ($prefs->fetch()) {
if (!isset($list[$prefs->namespace])) {
$list[$prefs->namespace] = array();
}
$list[$prefs->namespace][$prefs->topic] = $prefs->data;
}
return $list;
}
static function getTopic(Notice $notice, $namespace, $topic) {
return self::getByPK(array('notice_id' => $notice->getID(),
'namespace' => $namespace,
'topic' => $topic));
}
static function getData(Notice $notice, $namespace, $topic, $def=null) {
try {
$pref = self::getTopic($notice, $namespace, $topic);
} catch (NoResultException $e) {
if ($def === null) {
// If no default value was set, continue the exception.
throw $e;
}
// If there was a default value, return that.
return $def;
}
return $pref->data;
}
static function getConfigData(Notice $notice, $namespace, $topic) {
try {
$data = self::getData($notice, $namespace, $topic);
} catch (NoResultException $e) {
$data = common_config($namespace, $topic);
}
return $data;
}
/*
* Sets a notice preference based on Notice, namespace and topic
*
* @param Notice $notice Which notice this is for
* @param string $namespace Under which namespace (pluginname etc.)
* @param string $topic Preference name (think key in key-val store)
* @param string $data Data to be put into preference storage, null means delete
*
* @return true if changes are made, false if no action taken
* @throws ServerException if preference could not be saved
*/
static function setData(Notice $notice, $namespace, $topic, $data=null) {
try {
$pref = self::getTopic($notice, $namespace, $topic);
if (is_null($data)) {
$pref->delete();
} else {
$orig = clone($pref);
$pref->data = $data;
$pref->update($orig);
}
return true;
} catch (NoResultException $e) {
if (is_null($data)) {
return false; // No action taken
}
}
$pref = new Notice_prefs();
$pref->notice_id = $notice->getID();
$pref->namespace = $namespace;
$pref->topic = $topic;
$pref->data = $data;
$pref->created = common_sql_now();
if ($pref->insert() === false) {
throw new ServerException('Could not save notice preference.');
}
return true;
}
}
......@@ -41,6 +41,7 @@ $classes = array('Schema_version',
'Notice',
'Notice_location',
'Notice_source',
'Notice_prefs',
'Reply',
'Consumer',
'Token',
......
......@@ -267,7 +267,7 @@ class Activity
// From APP. Might be useful.
$this->selfLink = ActivityUtils::getLink($entry, 'self', 'application/atom+xml');
$this->selfLink = ActivityUtils::getSelfLink($entry);
$this->editLink = ActivityUtils::getLink($entry, 'edit', 'application/atom+xml');
}
......
......@@ -340,6 +340,7 @@ abstract class ActivityHandlerPlugin extends Plugin
$options = array('uri' => $object->id,
'url' => $object->link,
'self' => $object->selfLink,
'is_local' => Notice::REMOTE,
'source' => 'ostatus');
......@@ -416,6 +417,7 @@ abstract class ActivityHandlerPlugin extends Plugin
$options = array('uri' => $object->id,
'url' => $object->link,
'self' => $object->selfLink,
'is_local' => Notice::REMOTE,
'source' => 'ostatus');
......@@ -467,6 +469,7 @@ abstract class ActivityHandlerPlugin extends Plugin
$options = array('uri' => $object->id,
'url' => $object->link,
'self' => $object->selfLink,
'source' => 'restore');
// $user->getProfile() is a Profile
......
......@@ -102,6 +102,7 @@ class ActivityObject
public $content;
public $owner;
public $link;
public $selfLink; // think APP (Atom Publishing Protocol)
public $source;
public $avatarLinks = array();
public $geopoint;
......@@ -263,6 +264,7 @@ class ActivityObject
$this->source = $this->_getSource($element);
$this->link = ActivityUtils::getPermalink($element);
$this->selfLink = ActivityUtils::getSelfLink($element);
$this->id = $this->_childContent($element, self::ID);
......@@ -651,6 +653,18 @@ class ActivityObject
);
}
if (!empty($this->selfLink)) {
$xo->element(
'link',
array(
'rel' => 'self',
'type' => 'application/atom+xml',
'href' => $this->selfLink
),
null
);
}
if(!empty($this->owner)) {
$owner = $this->owner->asActivityNoun(self::AUTHOR);
$xo->raw($owner);
......
......@@ -65,11 +65,16 @@ class ActivityUtils
*
* @return string related link, if any
*/
static function getPermalink($element)
static function getPermalink(DOMNode $element)
{
return self::getLink($element, 'alternate', 'text/html');
}
static function getSelfLink(DOMNode $element)
{
return self::getLink($element, 'self', 'application/atom+xml');
}
/**
* Get the permalink for an Activity object
*
......@@ -90,8 +95,9 @@ class ActivityUtils
$linkRel = $link->getAttribute(self::REL);
$linkType = $link->getAttribute(self::TYPE);
// XXX: Am I allowed to do this according to specs? (matching using common_bare_mime)
if ($linkRel == $rel &&
(is_null($type) || $linkType == $type)) {
(is_null($type) || common_bare_mime($linkType) == common_bare_mime($type))) {
return $link->getAttribute(self::HREF);
}
}
......@@ -294,7 +300,7 @@ class ActivityUtils
// Possibly an upstream bug; tag: URIs aren't validated properly
// unless you explicitly ask for them. All other schemes are accepted
// for basic URI validation without asking.
if ($validate->uri($uri, array('allowed_scheme' => array('tag')))) {
if ($validate->uri($uri, array('allowed_schemes' => array('tag')))) {
return true;
}
......
......@@ -790,7 +790,8 @@ class ApiAction extends Action
function showSingleAtomStatus($notice)
{
header('Content-Type: application/atom+xml; charset=utf-8');
header('Content-Type: application/atom+xml;type=entry;charset="utf-8"');
print '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
print $notice->asAtomEntry(true, true, true, $this->scoped);
}
......
......@@ -57,6 +57,17 @@ define('NOTICE_INBOX_SOURCE_FORWARD', 4);
define('NOTICE_INBOX_SOURCE_PROFILE_TAG', 5);
define('NOTICE_INBOX_SOURCE_GATEWAY', -1);
/**
* StatusNet had this string as valid path characters: '\pN\pL\,\!\(\)\.\:\-\_\+\/\=\&\;\%\~\*\$\'\@'
* Some of those characters can be troublesome when auto-linking plain text. Such as "http://some.com/)"
* URL encoding should be used whenever a weird character is used, the following strings are not definitive.
*/
define('URL_REGEX_VALID_PATH_CHARS', '\pN\pL\,\!\.\:\-\_\+\/\@\=\;\%\~\*');
define('URL_REGEX_VALID_QSTRING_CHARS', URL_REGEX_VALID_PATH_CHARS . '\&');
define('URL_REGEX_VALID_FRAGMENT_CHARS', URL_REGEX_VALID_QSTRING_CHARS . '\?\#');
define('URL_REGEX_EXCLUDED_END_CHARS', '\?\.\,\!\#\:\''); // don't include these if they are directly after a URL
define('URL_REGEX_DOMAIN_NAME', '(?:(?!-)[A-Za-z0-9\-]{1,63}(?<!-)\.)+[A-Za-z]{2,10}');
// append our extlib dir as the last-resort place to find libs
set_include_path(get_include_path() . PATH_SEPARATOR . INSTALLDIR . '/extlib/');
......
......@@ -1885,6 +1885,10 @@ function common_log_objstring(&$object)
function common_valid_http_url($url, $secure=false)
{
if (empty($url)) {
return false;
}
// If $secure is true, only allow https URLs to pass
// (if false, we use '?' in 'https?' to say the 's' is optional)
$regex = $secure ? '/^https$/' : '/^https?$/';
......
......@@ -249,6 +249,15 @@ class BlacklistPlugin extends Plugin
return true;
}
public function onUrlBlacklistTest($url)
{
common_debug('Checking URL against blacklist: '._ve($url));
if (!$this->_checkUrl($url)) {
throw new ClientException('Forbidden URL', 403);
}
return true;
}
/**
* Helper for checking nicknames
*
......
......@@ -93,6 +93,8 @@ class Discovery
// Normalize the incoming $id to make sure we have a uri
$uri = self::normalize($id);
common_debug(sprintf('Performing discovery for "%s" (normalized "%s")', $id, $uri));
foreach ($this->methods as $class) {
try {
$xrd = new XML_XRD();
......@@ -124,7 +126,14 @@ class Discovery
$xrd->loadString($response->getBody());
return $xrd;
} catch (ClientException $e) {
if ($e->getCode() === 403) {
common_log(LOG_INFO, sprintf('%s: Aborting discovery on URL %s: %s', _ve($class), _ve($uri), _ve($e->getMessage())));
break;
}
} catch (Exception $e) {
common_log(LOG_INFO, sprintf('%s: Failed for %s: %s', _ve($class), _ve($uri), _ve($e->getMessage())));
continue;
}
}
......
......@@ -32,6 +32,9 @@ abstract class LRDDMethod
protected function fetchUrl($url, $method=HTTPClient::METHOD_GET)
{
// If we have a blacklist enabled, let's check against it
Event::handle('UrlBlacklistTest', array($url));
$client = new HTTPClient();
// GAAHHH, this method sucks! How about we make a better HTTPClient interface?
......
......@@ -272,6 +272,48 @@ class OStatusPlugin extends Plugin
return true;
}
/**
* Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz
*
* @return array The matching IDs (without @ or acct:) and each respective position in the given string.
*/
static function extractWebfingerIds($text)
{
$wmatches = array();
$result = preg_match_all('/(?:^|\s+)@((?:\w+[\w\-\_\.]?)*(?:[\w\-\_\.]*\w+)@'.URL_REGEX_DOMAIN_NAME.')/',
$text,
$wmatches,
PREG_OFFSET_CAPTURE);
if ($result === false) {
common_log(LOG_ERR, __METHOD__ . ': Error parsing webfinger IDs from text (preg_last_error=='.preg_last_error().').');
} else {
common_debug(sprintf('Found %i matches for WebFinger IDs: %s', count($wmatches), _ve($wmatches)));
}
return $wmatches[1];
}
/**
* Profile URL matches: @example.com/mublog/user
*
* @return array The matching URLs (without @ or acct:) and each respective position in the given string.
*/
static function extractUrlMentions($text)
{
$wmatches = array();
// In the regexp below we need to match / _before_ URL_REGEX_VALID_PATH_CHARS because it otherwise gets merged
// with the TLD before (but / is in URL_REGEX_VALID_PATH_CHARS anyway, it's just its positioning that is important)
$result = preg_match_all('/(?:^|\s+)@('.URL_REGEX_DOMAIN_NAME.'(?:\/['.URL_REGEX_VALID_PATH_CHARS.']*)*)/',
$text,
$wmatches,
PREG_OFFSET_CAPTURE);
if ($result === false) {
common_log(LOG_ERR, __METHOD__ . ': Error parsing profile URL mentions from text (preg_last_error=='.preg_last_error().').');
} else {
common_debug(sprintf('Found %i matches for profile URL mentions: %s', count($wmatches), _ve($wmatches)));
}
return $wmatches[1];
}
/**
* Find any explicit remote mentions. Accepted forms:
* Webfinger: @user@example.com
......@@ -285,76 +327,63 @@ class OStatusPlugin extends Plugin
{
$matches = array();
$wmatches = array();
// Webfinger matches: @user@example.com or even @user--one.george_orwell@1984.biz
if (preg_match_all('!(?:^|\s+)@((?:\w+[\w\-\_\.]?)*(?:[\w\-\_\.]*\w+)@(?:\w+\-?\w+\.)*\w+(?:\w+\-\w+)*\.\w+)!',
$text,
$wmatches,
PREG_OFFSET_CAPTURE)) {
foreach ($wmatches[1] as $wmatch) {
list($target, $pos) = $wmatch;
$this->log(LOG_INFO, "Checking webfinger '$target'");
$profile = null;
try {
$oprofile = Ostatus_profile::ensureWebfinger($target);
if (!$oprofile instanceof Ostatus_profile || !$oprofile->isPerson()) {
continue;
}
$profile = $oprofile->localProfile();
} catch (OStatusShadowException $e) {
// This means we got a local user in the webfinger lookup
$profile = $e->profile;
} catch (Exception $e) {
$this->log(LOG_ERR, "Webfinger check failed: " . $e->getMessage());
foreach (self::extractWebfingerIds($text) as $wmatch) {
list($target, $pos) = $wmatch;
$this->log(LOG_INFO, "Checking webfinger '$target'");
$profile = null;
try {
$oprofile = Ostatus_profile::ensureWebfinger($target);
if (!$oprofile instanceof Ostatus_profile || !$oprofile->isPerson()) {
continue;
}
$profile = $oprofile->localProfile();
} catch (OStatusShadowException $e) {
// This means we got a local user in the webfinger lookup
$profile = $e->profile;
} catch (Exception $e) {
$this->log(LOG_ERR, "Webfinger check failed: " . $e->getMessage());
continue;
}
assert($profile instanceof Profile);
assert($profile instanceof Profile);
$text = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target)
? $profile->getNickname() // TODO: we could do getFancyName() or getFullname() here
: $target;
$url = $profile->getUri();
if (!common_valid_http_url($url)) {
$url = $profile->getUrl();
}
$matches[$pos] = array('mentioned' => array($profile),
'type' => 'mention',
'text' => $text,
'position' => $pos,
'length' => mb_strlen($target),
'url' => $url);
$text = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target)
? $profile->getNickname() // TODO: we could do getBestName() or getFullname() here
: $target;
$url = $profile->getUri();
if (!common_valid_http_url($url)) {
$url = $profile->getUrl();
}
$matches[$pos] = array('mentioned' => array($profile),
'type' => 'mention',
'text' => $text,
'position' => $pos,
'length' => mb_strlen($target),
'url' => $url);
}
// Profile matches: @example.com/mublog/user
if (preg_match_all('!(?:^|\s+)@((?:\w+\.)*\w+(?:\w+\-\w+)*\.\w+(?:/\w+)*)!',
$text,
$wmatches,
PREG_OFFSET_CAPTURE)) {
foreach ($wmatches[1] as $wmatch) {
list($target, $pos) = $wmatch;
$schemes = array('http', 'https');
foreach ($schemes as $scheme) {
$url = "$scheme://$target";
$this->log(LOG_INFO, "Checking profile address '$url'");
try {
$oprofile = Ostatus_profile::ensureProfileURL($url);
if ($oprofile instanceof Ostatus_profile && !$oprofile->isGroup()) {
$profile = $oprofile->localProfile();
$text = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target) ?
$profile->nickname : $target;
$matches[$pos] = array('mentioned' => array($profile),
'type' => 'mention',
'text' => $text,
'position' => $pos,
'length' => mb_strlen($target),
'url' => $profile->getUrl());
break;
}
} catch (Exception $e) {
$this->log(LOG_ERR, "Profile check failed: " . $e->getMessage());
foreach (self::extractUrlMentions($text) as $wmatch) {
list($target, $pos) = $wmatch;
$schemes = array('http', 'https');
foreach ($schemes as $scheme) {
$url = "$scheme://$target";
$this->log(LOG_INFO, "Checking profile address '$url'");
try {
$oprofile = Ostatus_profile::ensureProfileURL($url);
if ($oprofile instanceof Ostatus_profile && !$oprofile->isGroup()) {
$profile = $oprofile->localProfile();
$text = !empty($profile->nickname) && mb_strlen($profile->nickname) < mb_strlen($target) ?
$profile->nickname : $target;
$matches[$pos] = array('mentioned' => array($profile),
'type' => 'mention',
'text' => $text,
'position' => $pos,
'length' => mb_strlen($target),
'url' => $profile->getUrl());
break;
}
} catch (Exception $e) {
$this->log(LOG_ERR, "Profile check failed: " . $e->getMessage());
}
}
}
......
......@@ -242,9 +242,14 @@ class OStatusSubAction extends Action
function pullRemoteProfile()
{
$validate = new Validate();
$this->profile_uri = $this->trimmed('profile');
try {
if ($validate->email($this->profile_uri)) {
$this->profile_uri = Discovery::normalize($this->trimmed('profile'));
} catch (Exception $e) {
return false;
}
try {
if (Discovery::isAcct($this->profile_uri) && $validate->email(mb_substr($this->profile_uri, 5))) {
$this->oprofile = Ostatus_profile::ensureWebfinger($this->profile_uri);
} else if ($validate->uri($this->profile_uri)) {
$this->oprofile = Ostatus_profile::ensureProfileURL($this->profile_uri);
......@@ -387,7 +392,7 @@ class OStatusSubAction extends Action
function title()
{
// TRANS: Page title for OStatus remote subscription form.
return _m('Confirm');
return !empty($this->profile_uri) ? _m('Confirm') : _m('Remote subscription');
}
/**
......
......@@ -199,7 +199,7 @@ class PushHubAction extends Action
/**
* Grab and validate a URL from POST parameters.
* @throws ClientException for malformed or non-http/https URLs
* @throws ClientException for malformed or non-http/https or blacklisted URLs
*/
protected function argUrl($arg)
{
......@@ -207,13 +207,14 @@ class PushHubAction extends Action