From 300ed65d301d21c33a5f0a196d6acfe762a34f29 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Thu, 5 Aug 2010 13:37:47 -0700 Subject: SubMirror plugin initial checkin: allows setting up automatic mirroring of posts from any of your subscriptions into your own stream, either via repeat or by copying the text. The UI for setup and editing is a bit nasty for now. Can be reached via 'Mirroring' tab in account settings, or from a link at top of subscriptions list. Currently relies on the OStatus plugin to handle actual setup, parsing, and importing of feeds; to support more general feed formatting we may need some further work there to accept weird feeds. Also requires an actual live subscription, but this could be changed in future. (Ensuring that PSHB feed subscriptions remain live even if nobody's directly subscribed might be tricky.) The repeat style is our preferred method since it retains full attribution, but right now we don't handle repeats very well across site boundaries; when pushed out to Twitter or to other StatusNet instances via OStatus, currently we end up losing some of the data and can end up with the 'RT @blah' version. WARNING: There's no loop detection yet; it's most likely possible to set up a fun loop of profiles repeating each others' stuff forever and ever and ever and ever... --- plugins/SubMirror/SubMirrorPlugin.php | 149 +++++++++++++++++++ plugins/SubMirror/actions/addmirror.php | 155 +++++++++++++++++++ plugins/SubMirror/actions/editmirror.php | 105 +++++++++++++ plugins/SubMirror/actions/mirrorsettings.php | 105 +++++++++++++ plugins/SubMirror/classes/SubMirror.php | 213 +++++++++++++++++++++++++++ plugins/SubMirror/lib/addmirrorform.php | 171 +++++++++++++++++++++ plugins/SubMirror/lib/editmirrorform.php | 189 ++++++++++++++++++++++++ plugins/SubMirror/lib/mirrorqueuehandler.php | 44 ++++++ 8 files changed, 1131 insertions(+) create mode 100644 plugins/SubMirror/SubMirrorPlugin.php create mode 100644 plugins/SubMirror/actions/addmirror.php create mode 100644 plugins/SubMirror/actions/editmirror.php create mode 100644 plugins/SubMirror/actions/mirrorsettings.php create mode 100644 plugins/SubMirror/classes/SubMirror.php create mode 100644 plugins/SubMirror/lib/addmirrorform.php create mode 100644 plugins/SubMirror/lib/editmirrorform.php create mode 100644 plugins/SubMirror/lib/mirrorqueuehandler.php (limited to 'plugins/SubMirror') diff --git a/plugins/SubMirror/SubMirrorPlugin.php b/plugins/SubMirror/SubMirrorPlugin.php new file mode 100644 index 000000000..8678cc3dd --- /dev/null +++ b/plugins/SubMirror/SubMirrorPlugin.php @@ -0,0 +1,149 @@ +. + */ + +/** + * @package SubMirrorPlugin + * @maintainer Brion Vibber + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } + + +class SubMirrorPlugin extends Plugin +{ + /** + * Hook for RouterInitialized event. + * + * @param Net_URL_Mapper $m path-to-action mapper + * @return boolean hook return + */ + function onRouterInitialized($m) + { + $m->connect('settings/mirror', + array('action' => 'mirrorsettings')); + $m->connect('settings/mirror/add', + array('action' => 'addmirror')); + $m->connect('settings/mirror/edit', + array('action' => 'editmirror')); + return true; + } + + /** + * Automatically load the actions and libraries used by the plugin + * + * @param Class $cls the class + * + * @return boolean hook return + * + */ + function onAutoload($cls) + { + $base = dirname(__FILE__); + $lower = strtolower($cls); + $files = array("$base/lib/$lower.php", + "$base/classes/$cls.php"); + if (substr($lower, -6) == 'action') { + $files[] = "$base/actions/" . substr($lower, 0, -6) . ".php"; + } + foreach ($files as $file) { + if (file_exists($file)) { + include_once $file; + return false; + } + } + return true; + } + + function handle($notice) + { + // Is anybody mirroring? + $mirror = new SubMirror(); + $mirror->subscribed = $notice->profile_id; + if ($mirror->find()) { + while ($mirror->fetch()) { + $mirror->repeat($notice); + } + } + } + + function onPluginVersion(&$versions) + { + $versions[] = array('name' => 'SubMirror', + 'version' => STATUSNET_VERSION, + 'author' => 'Brion Vibber', + 'homepage' => 'http://status.net/wiki/Plugin:SubMirror', + 'rawdescription' => + _m('Pull feeds into your timeline!')); + + return true; + } + + /** + * Menu item for settings + * + * @param Action &$action Action being executed + * + * @return boolean hook return + */ + + function onEndAccountSettingsNav(&$action) + { + $action_name = $action->trimmed('action'); + + $action->menuItem(common_local_url('mirrorsettings'), + // TRANS: SubMirror plugin menu item on user settings page. + _m('MENU', 'Mirroring'), + // TRANS: SubMirror plugin tooltip for user settings menu item. + _m('Configure mirroring of posts from other feeds'), + $action_name === 'mirrorsettings'); + + return true; + } + + function onCheckSchema() + { + $schema = Schema::get(); + $schema->ensureTable('submirror', SubMirror::schemaDef()); + return true; + } + + /** + * Set up queue handlers for outgoing hub pushes + * @param QueueManager $qm + * @return boolean hook return + */ + function onEndInitializeQueueManager(QueueManager $qm) + { + // After each notice save, check if there's any repeat mirrors. + $qm->connect('mirror', 'MirrorQueueHandler'); + return true; + } + + function onStartEnqueueNotice($notice, &$transports) + { + $transports[] = 'mirror'; + } + + function onStartShowSubscriptionsContent($action) + { + $action->element('a', + array('href' => common_local_url('mirrorsettings')), + _m('Set up mirroring options...')); + } +} diff --git a/plugins/SubMirror/actions/addmirror.php b/plugins/SubMirror/actions/addmirror.php new file mode 100644 index 000000000..df6939491 --- /dev/null +++ b/plugins/SubMirror/actions/addmirror.php @@ -0,0 +1,155 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Takes parameters: + * + * - feed: a profile ID + * - token: session token to prevent CSRF attacks + * - ajax: boolean; whether to return Ajax or full-browser results + * + * Only works if the current user is logged in. + * + * @category Action + * @package StatusNet + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class AddMirrorAction extends Action +{ + var $user; + var $feedurl; + + /** + * Check pre-requisites and instantiate attributes + * + * @param Array $args array of arguments (URL, GET, POST) + * + * @return boolean success flag + */ + + function prepare($args) + { + parent::prepare($args); + $ok = $this->sharedBoilerplate(); + if ($ok) { + // and now do something useful! + $this->profile = $this->validateProfile($this->trimmed('profile')); + return true; + } else { + return $ok; + } + } + + function validateProfile($id) + { + $id = intval($id); + $profile = Profile::staticGet('id', $id); + if ($profile && $profile->id != $this->user->id) { + return $profile; + } + // TRANS: Error message returned to user when setting up feed mirroring, but we were unable to resolve the given URL to a working feed. + $this->clientError(_m("Invalid profile for mirroring.")); + } + + /** + * @fixme none of this belongs in end classes + * this stuff belongs in shared code! + */ + function sharedBoilerplate() + { + // Only allow POST requests + + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + $this->clientError(_('This action only accepts POST requests.')); + return false; + } + + // CSRF protection + + $token = $this->trimmed('token'); + + if (!$token || $token != common_session_token()) { + $this->clientError(_('There was a problem with your session token.'. + ' Try again, please.')); + return false; + } + + // Only for logged-in users + + $this->user = common_current_user(); + + if (empty($this->user)) { + $this->clientError(_('Not logged in.')); + return false; + } + return true; + } + + /** + * Handle request + * + * Does the subscription and returns results. + * + * @param Array $args unused. + * + * @return void + */ + + function handle($args) + { + // Throws exception on error + $this->saveMirror(); + + if ($this->boolean('ajax')) { + $this->startHTML('text/xml;charset=utf-8'); + $this->elementStart('head'); + $this->element('title', null, _('Subscribed')); + $this->elementEnd('head'); + $this->elementStart('body'); + $unsubscribe = new EditMirrorForm($this, $this->profile); + $unsubscribe->show(); + $this->elementEnd('body'); + $this->elementEnd('html'); + } else { + $url = common_local_url('mirrorsettings'); + common_redirect($url, 303); + } + } + + function saveMirror() + { + SubMirror::saveMirror($this->user, $this->profile); + } +} diff --git a/plugins/SubMirror/actions/editmirror.php b/plugins/SubMirror/actions/editmirror.php new file mode 100644 index 000000000..7ddd32ef3 --- /dev/null +++ b/plugins/SubMirror/actions/editmirror.php @@ -0,0 +1,105 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Takes parameters: + * + * - feed: a profile ID + * - token: session token to prevent CSRF attacks + * - ajax: boolean; whether to return Ajax or full-browser results + * + * Only works if the current user is logged in. + * + * @category Action + * @package StatusNet + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class EditMirrorAction extends AddMirrorAction +{ + + /** + * Check pre-requisites and instantiate attributes + * + * @param Array $args array of arguments (URL, GET, POST) + * + * @return boolean success flag + */ + + function prepare($args) + { + parent::prepare($args); + $this->mirror = SubMirror::pkeyGet(array('subscriber' => $this->user->id, + 'subscribed' => $this->profile->id)); + + if (!$this->mirror) { + $this->clientError(_m("Requested invalid profile to edit.")); + } + + $this->style = $this->validateStyle($this->trimmed('style')); + + // DO NOT change to $this->boolean(), it will be wrong. + // We're checking for the presence of the setting, not its value. + $this->delete = (bool)$this->arg('delete'); + + return true; + } + + protected function validateStyle($style) + { + $allowed = array('repeat', 'copy'); + if (in_array($style, $allowed)) { + return $style; + } else { + $this->clientError(_m("Bad form data.")); + } + } + + function saveMirror() + { + $mirror = SubMirror::getMirror($this->user, $this->profile); + if (!$mirror) { + $this->clientError(_m('Requested edit of missing mirror')); + } + + if ($this->delete) { + $mirror->delete(); + } else if ($this->style != $mirror->style) { + $orig = clone($mirror); + $mirror->style = $this->style; + $mirror->modified = common_sql_now(); + $mirror->update($orig); + } + } +} diff --git a/plugins/SubMirror/actions/mirrorsettings.php b/plugins/SubMirror/actions/mirrorsettings.php new file mode 100644 index 000000000..edb024183 --- /dev/null +++ b/plugins/SubMirror/actions/mirrorsettings.php @@ -0,0 +1,105 @@ +. + * + * @category Plugins + * @package StatusNet + * @author Brion Vibber + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +class MirrorSettingsAction extends AccountSettingsAction +{ + /** + * Title of the page + * + * @return string Page title + */ + + function title() + { + return _m('Feed mirror settings'); + } + + /** + * Instructions for use + * + * @return string Instructions for use + */ + + function getInstructions() + { + return _m('You can mirror updates from your RSS and Atom feeds ' . + 'into your StatusNet timeline!'); + } + + /** + * Show the form for OpenID management + * + * We have one form with a few different submit buttons to do different things. + * + * @return void + */ + + function showContent() + { + $user = common_current_user(); + + $mirror = new SubMirror(); + $mirror->subscriber = $user->id; + if ($mirror->find()) { + while ($mirror->fetch()) { + $this->showFeedForm($mirror); + } + } + $this->showAddFeedForm(); + } + + function showFeedForm($mirror) + { + $profile = Profile::staticGet('id', $mirror->subscribed); + if ($profile) { + $form = new EditMirrorForm($this, $profile); + $form->show(); + } + } + + function showAddFeedForm() + { + $form = new AddMirrorForm($this); + $form->show(); + } + + /** + * Handle a POST request + * + * Muxes to different sub-functions based on which button was pushed + * + * @return void + */ + + function handlePost() + { + } +} diff --git a/plugins/SubMirror/classes/SubMirror.php b/plugins/SubMirror/classes/SubMirror.php new file mode 100644 index 000000000..4e7e005db --- /dev/null +++ b/plugins/SubMirror/classes/SubMirror.php @@ -0,0 +1,213 @@ +. + */ + +/** + * @package SubMirrorPlugin + * @maintainer Brion Vibber + */ + +class SubMirror extends Memcached_DataObject +{ + public $__table = 'submirror'; + + public $subscriber; + public $subscribed; + + public $style; + + public $created; + public $modified; + + public /*static*/ function staticGet($k, $v=null) + { + return parent::staticGet(__CLASS__, $k, $v); + } + + /** + * return table definition for DB_DataObject + * + * DB_DataObject needs to know something about the table to manipulate + * instances. This method provides all the DB_DataObject needs to know. + * + * @return array array of column definitions + */ + + function table() + { + return array('subscriber' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, + 'subscribed' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, + + 'style' => DB_DATAOBJECT_STR, + + 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL, + 'modified' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); + } + + static function schemaDef() + { + // @fixme need a reverse key on (subscribed, subscriber) as well + return array(new ColumnDef('subscriber', 'integer', + null, false, 'PRI'), + new ColumnDef('subscribed', 'integer', + null, false, 'PRI'), + + new ColumnDef('style', 'varchar', + 16, true), + + new ColumnDef('created', 'datetime', + null, false), + new ColumnDef('modified', 'datetime', + null, false)); + } + + /** + * return key definitions for DB_DataObject + * + * DB_DataObject needs to know about keys that the table has; this function + * defines them. + * + * @return array key definitions + */ + + function keys() + { + return array_keys($this->keyTypes()); + } + + /** + * return key definitions for Memcached_DataObject + * + * Our caching system uses the same key definitions, but uses a different + * method to get them. + * + * @return array key definitions + */ + + function keyTypes() + { + // @fixme keys + // need a sane key for reverse lookup too + return array('subscriber' => 'K', 'subscribed' => 'K'); + } + + function sequenceKey() + { + return array(false, false, false); + } + + /** + * @param Profile $subscribed + * @param Profile $subscribed + * @return SubMirror + * @throws ServerException + */ + public static function saveMirror($subscriber, $subscribed, $style='repeat') + { + // @fixme make sure they're subscribed! + $mirror = new SubMirror(); + + $mirror->subscriber = $subscriber->id; + $mirror->subscribed = $subscribed->id; + $mirror->style = $style; + + $mirror->created = common_sql_now(); + $mirror->modified = common_sql_now(); + $mirror->insert(); + + return $mirror; + } + + /** + * @param Notice $notice + * @return mixed Notice on successful mirroring, boolean if not + */ + public function mirrorNotice($notice) + { + $profile = Profile::staticGet('id', $this->subscriber); + if (!$profile) { + common_log(LOG_ERROR, "SubMirror plugin skipping auto-repeat of notice $notice->id for missing user $profile->id"); + return false; + } + + if ($this->style == 'copy') { + return $this->copyNotice($profile, $notice); + } else { // default to repeat mode + return $this->repeatNotice($profile, $notice); + } + } + + /** + * Mirror a notice using StatusNet's repeat functionality. + * This retains attribution within the site, and other nice things, + * but currently ends up looking like 'RT @foobar bla bla' when + * bridged out over OStatus or TwitterBridge. + * + * @param Notice $notice + * @return mixed Notice on successful repeat, true if already repeated, false on failure + */ + protected function repeatNotice($profile, $notice) + { + if($profile->hasRepeated($notice->id)) { + common_log(LOG_INFO, "SubMirror plugin skipping auto-repeat of notice $notice->id for user $profile->id; already repeated."); + return true; + } else { + common_log(LOG_INFO, "SubMirror plugin auto-repeating notice $notice->id for $profile->id"); + return $notice->repeat($profile->id, 'mirror'); + } + } + + /** + * Mirror a notice by emitting a new notice with the same contents. + * Kind of dirty, but if pulling an external data feed into an account + * that may be what you want. + * + * @param Notice $notice + * @return mixed Notice on successful repeat, true if already repeated, false on failure + */ + protected function copyNotice($profile, $notice) + { + $options = array('is_local' => Notice::LOCAL_PUBLIC, + 'url' => $notice->bestUrl(), // pass through the foreign link... + 'rendered' => $notice->rendered); + + $saved = Notice::saveNew($profile->id, + $notice->content, + 'feed', + $options); + return $saved; + } + + public /*static*/ function pkeyGet($v) + { + return parent::pkeyGet(__CLASS__, $v); + } + + /** + * Get the mirroring setting for a pair of profiles, if existing. + * + * @param Profile $subscriber + * @param Profile $subscribed + * @return mixed Profile or empty + */ + public static function getMirror($subscriber, $subscribed) + { + return self::pkeyGet(array('subscriber' => $subscriber->id, + 'subscribed' => $subscribed->id)); + } +} diff --git a/plugins/SubMirror/lib/addmirrorform.php b/plugins/SubMirror/lib/addmirrorform.php new file mode 100644 index 000000000..9ee59661a --- /dev/null +++ b/plugins/SubMirror/lib/addmirrorform.php @@ -0,0 +1,171 @@ +. + * + * @package StatusNet + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +class AddMirrorForm extends Form +{ + + /** + * Name of the form + * + * Sub-classes should overload this with the name of their form. + * + * @return void + */ + + function formLegend() + { + } + + /** + * Visible or invisible data elements + * + * Display the form fields that make up the data of the form. + * Sub-classes should overload this to show their data. + * + * @return void + */ + + function formData() + { + $this->out->elementStart('fieldset'); + + $this->out->elementStart('ul'); + + $this->li(); + $this->out->element('label', array('for' => $this->id() . '-profile'), + _m("Mirror one of your existing subscriptions:")); + $this->out->elementStart('select', array('name' => 'profile')); + + $user = common_current_user(); + $profile = $user->getSubscriptions(); + while ($profile->fetch()) { + $mirror = SubMirror::pkeyGet(array('subscriber' => $user->id, + 'subscribed' => $profile->id)); + if (!$mirror) { + $this->out->element('option', + array('value' => $profile->id), + $profile->getBestName()); + } + } + $this->out->elementEnd('select'); + $this->out->submit($this->id() . '-save', _m('Save')); + $this->unli(); + + + $this->li(); + + $this->out->elementStart('fieldset', array('style' => 'width: 360px; margin-left: auto; margin-right: auto')); + $this->out->element('p', false, + _m("Not already subscribed to the feed you want? " . + "Add a new remote subscription and paste in the URL!")); + + $this->out->elementStart('div', 'entity_actions'); + $this->out->elementStart('p', array('id' => 'entity_remote_subscribe', + 'class' => 'entity_subscribe')); + $this->out->element('a', array('href' => common_local_url('ostatussub'), + 'class' => 'entity_remote_subscribe') + , _m('Remote')); + $this->out->elementEnd('p'); + $this->out->elementEnd('div'); + + $this->out->element('div', array('style' => 'clear: both')); + $this->out->elementEnd('fieldset'); + $this->unli(); + + $this->out->elementEnd('ul'); + $this->out->elementEnd('fieldset'); + } + + private function doInput($id, $name, $label, $value=null, $instructions=null) + { + $this->out->element('label', array('for' => $id), $label); + $attrs = array('name' => $name, + 'type' => 'text', + 'id' => $id); + if ($value) { + $attrs['value'] = $value; + } + $this->out->element('input', $attrs); + if ($instructions) { + $this->out->element('p', 'form_guide', $instructions); + } + } + + /** + * Buttons for form actions + * + * Submit and cancel buttons (or whatever) + * Sub-classes should overload this to show their own buttons. + * + * @return void + */ + + function formActions() + { + } + + /** + * ID of the form + * + * Should be unique on the page. Sub-classes should overload this + * to show their own IDs. + * + * @return string ID of the form + */ + + function id() + { + return 'add-mirror-form'; + } + + /** + * Action of the form. + * + * URL to post to. Should be overloaded by subclasses to give + * somewhere to post to. + * + * @return string URL to post to + */ + + function action() + { + return common_local_url('addmirror'); + } + + /** + * Class of the form. + * + * @return string the form's class + */ + + function formClass() + { + return 'form_settings'; + } + +} diff --git a/plugins/SubMirror/lib/editmirrorform.php b/plugins/SubMirror/lib/editmirrorform.php new file mode 100644 index 000000000..8236da389 --- /dev/null +++ b/plugins/SubMirror/lib/editmirrorform.php @@ -0,0 +1,189 @@ +. + * + * @package StatusNet + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +class EditMirrorForm extends Form +{ + function __construct($action, $profile) + { + parent::__construct($action); + + $this->profile = clone($profile); + $this->user = common_current_user(); + $this->mirror = SubMirror::pkeyGet(array('subscriber' => $this->user->id, + 'subscribed' => $this->profile->id)); + } + + /** + * Name of the form + * + * Sub-classes should overload this with the name of their form. + * + * @return void + */ + + function formLegend() + { + } + + /** + * Visible or invisible data elements + * + * Display the form fields that make up the data of the form. + * Sub-classes should overload this to show their data. + * + * @return void + */ + + function formData() + { + $this->out->elementStart('fieldset'); + + $this->out->hidden('profile', $this->profile->id); + + $this->out->elementStart('div', array('style' => 'float: left; width: 80px;')); + $img = $this->getAvatar($this->profile); + $feed = $this->getFeed($this->profile); + $this->out->elementStart('a', array('href' => $this->profile->profileurl)); + $this->out->element('img', array('src' => $img, 'style' => 'float: left')); + $this->out->elementEnd('a'); + $this->out->elementEnd('div'); + + + $this->out->elementStart('div', array('style' => 'margin-left: 80px; margin-right: 20px')); + $this->out->elementStart('p'); + $this->out->elementStart('div'); + $this->out->element('a', array('href' => $this->profile->profileurl), $this->profile->getBestName()); + $this->out->elementEnd('div'); + $this->out->elementStart('div'); + if ($feed) { + $this->out->text(_m('LABEL', 'Remote feed:') . ' '); + //$this->out->element('a', array('href' => $feed), $feed); + $this->out->element('input', array('value' => $feed, 'readonly' => 'readonly', 'style' => 'width: 100%')); + } else { + $this->out->text(_m('LABEL', 'Local user')); + } + $this->out->elementEnd('div'); + $this->out->elementEnd('p'); + + $this->out->elementStart('fieldset', array('style' => 'margin-top: 20px')); + $this->out->element('legend', false, _m("Mirroring style")); + + $styles = array('repeat' => _m("Repeat: reference the original user's post (sometimes shows as 'RT @blah')"), + 'copy' => _m("Repost the content under my account")); + foreach ($styles as $key => $label) { + $this->out->elementStart('div'); + $attribs = array('type' => 'radio', + 'value' => $key, + 'name' => 'style', + 'id' => $this->id() . '-style'); + if ($key == $this->mirror->style || ($key == 'repeat' && empty($this->mirror->style))) { + $attribs['checked'] = 'checked'; + } + $this->out->element('input', $attribs); + $this->out->element('span', false, $label); // @fixme should be label, but the styles muck it up for now + $this->out->elementEnd('div'); + + } + $this->out->elementEnd('fieldset'); + + + $this->out->elementStart('div'); + $this->out->submit($this->id() . '-save', _m('Save')); + $this->out->element('input', array('type' => 'submit', + 'value' => _m('Stop mirroring'), + 'name' => 'delete', + 'class' => 'submit')); + $this->out->elementEnd('div'); + + $this->out->elementEnd('div'); + $this->out->elementEnd('fieldset'); + } + + private function getAvatar($profile) + { + $avatar = $this->profile->getAvatar(48); + if ($avatar) { + return $avatar->displayUrl(); + } else { + return Avatar::defaultImage(48); + } + } + + private function getFeed($profile) + { + // Ok this is a bit of a hack. ;) + if (class_exists('Ostatus_profile')) { + $oprofile = Ostatus_profile::staticGet('profile_id', $profile->id); + if ($oprofile) { + return $oprofile->feeduri; + } + } + var_dump('wtf'); + return false; + } + + /** + * ID of the form + * + * Should be unique on the page. Sub-classes should overload this + * to show their own IDs. + * + * @return string ID of the form + */ + + function id() + { + return 'edit-mirror-form-' . $this->profile->id; + } + + /** + * Action of the form. + * + * URL to post to. Should be overloaded by subclasses to give + * somewhere to post to. + * + * @return string URL to post to + */ + + function action() + { + return common_local_url('editmirror'); + } + + /** + * Class of the form. + * + * @return string the form's class + */ + + function formClass() + { + return 'form_settings'; + } + +} diff --git a/plugins/SubMirror/lib/mirrorqueuehandler.php b/plugins/SubMirror/lib/mirrorqueuehandler.php new file mode 100644 index 000000000..0306180ee --- /dev/null +++ b/plugins/SubMirror/lib/mirrorqueuehandler.php @@ -0,0 +1,44 @@ +. + */ + +/** + * Check for subscription mirroring options on each newly seen post! + * + * @package SubMirror + * @author Brion Vibber + */ + +class MirrorQueueHandler extends QueueHandler +{ + function transport() + { + return 'mirror'; + } + + function handle($notice) + { + $mirror = new SubMirror(); + $mirror->subscribed = $notice->profile_id; + if ($mirror->find()) { + while ($mirror->fetch()) { + $mirror->mirrorNotice($notice); + } + } + } +} -- cgit v1.2.3-54-g00ecf From 7e55fc00447923b40b2ffc87329fd95347d776f5 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Fri, 6 Aug 2010 10:56:18 -0700 Subject: OStatus/FeedSub: tweaked PuSH feed garbage collection so other plugins can declare usage of a low-level feed or an OStatus profile besides profile subscriptions & group memberships. SubMirror: redid add-mirror frontend to accept a feed URL, then pass that on to OStatus, instead of pulling from your subscriptions. Profile: tweaked subscriberCount() so it doesn't subtract 1 for foreign profiles who aren't subscribed to themselves; instead excludes the self-subscription in the count query. Memcached_DataObject: tweak to avoid extra error spew in the DB error raising Work in progress: tweaking feedsub garbage collection so we can count other uses --- classes/Memcached_DataObject.php | 2 +- classes/Profile.php | 4 +- plugins/OStatus/OStatusPlugin.php | 18 ++++++ plugins/OStatus/classes/FeedSub.php | 30 +++++++++ plugins/OStatus/classes/Ostatus_profile.php | 45 ++++++++------ plugins/SubMirror/SubMirrorPlugin.php | 23 +++++++ plugins/SubMirror/actions/addmirror.php | 92 +++------------------------- plugins/SubMirror/actions/editmirror.php | 9 ++- plugins/SubMirror/actions/mirrorsettings.php | 5 +- plugins/SubMirror/lib/addmirrorform.php | 44 +++---------- 10 files changed, 124 insertions(+), 148 deletions(-) (limited to 'plugins/SubMirror') diff --git a/classes/Memcached_DataObject.php b/classes/Memcached_DataObject.php index 7768fe757..0f1ed0489 100644 --- a/classes/Memcached_DataObject.php +++ b/classes/Memcached_DataObject.php @@ -574,7 +574,7 @@ class Memcached_DataObject extends Safe_DataObject function raiseError($message, $type = null, $behaviour = null) { $id = get_class($this); - if ($this->id) { + if (!empty($this->id)) { $id .= ':' . $this->id; } if ($message instanceof PEAR_Error) { diff --git a/classes/Profile.php b/classes/Profile.php index 0d0463b73..d7617f0b7 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -464,11 +464,9 @@ class Profile extends Memcached_DataObject $sub = new Subscription(); $sub->subscribed = $this->id; - + $sub->whereAdd('subscriber != subscribed'); $cnt = (int) $sub->count('distinct subscriber'); - $cnt = ($cnt > 0) ? $cnt - 1 : $cnt; - if (!empty($c)) { $c->set(common_cache_key('profile:subscriber_count:'.$this->id), $cnt); } diff --git a/plugins/OStatus/OStatusPlugin.php b/plugins/OStatus/OStatusPlugin.php index 70971c5b3..3b073a5d1 100644 --- a/plugins/OStatus/OStatusPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -479,6 +479,24 @@ class OStatusPlugin extends Plugin } } + /** + * Tell the FeedSub infrastructure whether we have any active OStatus + * usage for the feed; if not it'll be able to garbage-collect the + * feed subscription. + * + * @param FeedSub $feedsub + * @param integer $count in/out + * @return mixed hook return code + */ + function onFeedSubSubscriberCount($feedsub, &$count) + { + $oprofile = Ostatus_profile::staticGet('feeduri', $feedsub->uri); + if ($oprofile) { + $count += $oprofile->subscriberCount(); + } + return true; + } + /** * When about to subscribe to a remote user, start a server-to-server * PuSH subscription if needed. If we can't establish that, abort. diff --git a/plugins/OStatus/classes/FeedSub.php b/plugins/OStatus/classes/FeedSub.php index b10509dae..9cd35e29c 100644 --- a/plugins/OStatus/classes/FeedSub.php +++ b/plugins/OStatus/classes/FeedSub.php @@ -255,6 +255,9 @@ class FeedSub extends Memcached_DataObject /** * Send a PuSH unsubscription request to the hub for this feed. * The hub will later send us a confirmation POST to /main/push/callback. + * Warning: this will cancel the subscription even if someone else in + * the system is using it. Most callers will want garbageCollect() instead, + * which confirms there's no uses left. * * @return bool true on success, false on failure * @throws ServerException if feed state is not valid @@ -275,6 +278,33 @@ class FeedSub extends Memcached_DataObject return $this->doSubscribe('unsubscribe'); } + /** + * Check if there are any active local uses of this feed, and if not then + * make sure it's inactive, unsubscribing if necessary. + * + * @return boolean true if the subscription is now inactive, false if still active. + */ + public function garbageCollect() + { + if ($this->sub_state == '' || $this->sub_state == 'inactive') { + // No active PuSH subscription, we can just leave it be. + return true; + } else { + // PuSH subscription is either active or in an indeterminate state. + // Check if we're out of subscribers, and if so send an unsubscribe. + $count = 0; + Event::handle('FeedSubSubscriberCount', array($this, &$count)); + + if ($count) { + common_log(LOG_INFO, __METHOD__ . ': ok, ' . $count . ' user(s) left for ' . $this->uri); + return false; + } else { + common_log(LOG_INFO, __METHOD__ . ': unsubscribing, no users left for ' . $this->uri); + return $this->unsubscribe(); + } + } + } + protected function doSubscribe($mode) { $orig = clone($this); diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index 5d3f37cd0..2d7c632e6 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -215,22 +215,13 @@ class Ostatus_profile extends Memcached_DataObject } /** - * 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 || $feedsub->sub_state == '' || $feedsub->sub_state == 'inactive') { - // No active PuSH subscription, we can just leave it be. - return true; - } else { - // PuSH subscription is either active or in an indeterminate state. - // Send an unsubscribe. - return $feedsub->unsubscribe(); - } + $this->garbageCollect(); } /** @@ -240,6 +231,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); @@ -247,13 +253,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; } /** diff --git a/plugins/SubMirror/SubMirrorPlugin.php b/plugins/SubMirror/SubMirrorPlugin.php index 8678cc3dd..00c04ad0b 100644 --- a/plugins/SubMirror/SubMirrorPlugin.php +++ b/plugins/SubMirror/SubMirrorPlugin.php @@ -146,4 +146,27 @@ class SubMirrorPlugin extends Plugin array('href' => common_local_url('mirrorsettings')), _m('Set up mirroring options...')); } + + /** + * Let the OStatus subscription garbage collection know if we're + * making use of a remote feed, so it doesn't get dropped out + * from under us. + * + * @param Ostatus_profile $oprofile + * @param int $count in/out + * @return mixed hook return value + */ + function onOstatus_profileSubscriberCount($oprofile, &$count) + { + if ($oprofile->profile_id) { + $mirror = new SubMirror(); + $mirror->subscribed = $oprofile->profile_id; + if ($mirror->find()) { + while ($mirror->fetch()) { + $count++; + } + } + } + return true; + } } diff --git a/plugins/SubMirror/actions/addmirror.php b/plugins/SubMirror/actions/addmirror.php index df6939491..5acdf1dfe 100644 --- a/plugins/SubMirror/actions/addmirror.php +++ b/plugins/SubMirror/actions/addmirror.php @@ -46,9 +46,8 @@ if (!defined('STATUSNET')) { * @link http://status.net/ */ -class AddMirrorAction extends Action +class AddMirrorAction extends BaseMirrorAction { - var $user; var $feedurl; /** @@ -62,94 +61,17 @@ class AddMirrorAction extends Action function prepare($args) { parent::prepare($args); - $ok = $this->sharedBoilerplate(); - if ($ok) { - // and now do something useful! - $this->profile = $this->validateProfile($this->trimmed('profile')); - return true; - } else { - return $ok; - } - } - - function validateProfile($id) - { - $id = intval($id); - $profile = Profile::staticGet('id', $id); - if ($profile && $profile->id != $this->user->id) { - return $profile; - } - // TRANS: Error message returned to user when setting up feed mirroring, but we were unable to resolve the given URL to a working feed. - $this->clientError(_m("Invalid profile for mirroring.")); - } - - /** - * @fixme none of this belongs in end classes - * this stuff belongs in shared code! - */ - function sharedBoilerplate() - { - // Only allow POST requests - - if ($_SERVER['REQUEST_METHOD'] != 'POST') { - $this->clientError(_('This action only accepts POST requests.')); - return false; - } - - // CSRF protection - - $token = $this->trimmed('token'); - - if (!$token || $token != common_session_token()) { - $this->clientError(_('There was a problem with your session token.'. - ' Try again, please.')); - return false; - } - - // Only for logged-in users - - $this->user = common_current_user(); - - if (empty($this->user)) { - $this->clientError(_('Not logged in.')); - return false; - } + $this->feedurl = $this->validateFeedUrl($this->trimmed('feedurl')); + $this->profile = $this->profileForFeed($this->feedurl); return true; } - /** - * Handle request - * - * Does the subscription and returns results. - * - * @param Array $args unused. - * - * @return void - */ - - function handle($args) + function saveMirror() { - // Throws exception on error - $this->saveMirror(); - - if ($this->boolean('ajax')) { - $this->startHTML('text/xml;charset=utf-8'); - $this->elementStart('head'); - $this->element('title', null, _('Subscribed')); - $this->elementEnd('head'); - $this->elementStart('body'); - $unsubscribe = new EditMirrorForm($this, $this->profile); - $unsubscribe->show(); - $this->elementEnd('body'); - $this->elementEnd('html'); + if ($this->oprofile->subscribe()) { + SubMirror::saveMirror($this->user, $this->profile); } else { - $url = common_local_url('mirrorsettings'); - common_redirect($url, 303); + $this->serverError(_m("Could not subscribe to feed.")); } } - - function saveMirror() - { - SubMirror::saveMirror($this->user, $this->profile); - } } diff --git a/plugins/SubMirror/actions/editmirror.php b/plugins/SubMirror/actions/editmirror.php index 7ddd32ef3..c7fdab0d6 100644 --- a/plugins/SubMirror/actions/editmirror.php +++ b/plugins/SubMirror/actions/editmirror.php @@ -46,7 +46,7 @@ if (!defined('STATUSNET')) { * @link http://status.net/ */ -class EditMirrorAction extends AddMirrorAction +class EditMirrorAction extends BaseMirrorAction { /** @@ -60,6 +60,9 @@ class EditMirrorAction extends AddMirrorAction function prepare($args) { parent::prepare($args); + + $this->profile = $this->validateProfile($this->trimmed('profile')); + $this->mirror = SubMirror::pkeyGet(array('subscriber' => $this->user->id, 'subscribed' => $this->profile->id)); @@ -95,6 +98,10 @@ class EditMirrorAction extends AddMirrorAction if ($this->delete) { $mirror->delete(); + $oprofile = Ostatus_profile::staticGet('profile_id', $this->profile->id); + if ($oprofile) { + $oprofile->garbageCollect(); + } } else if ($this->style != $mirror->style) { $orig = clone($mirror); $mirror->style = $this->style; diff --git a/plugins/SubMirror/actions/mirrorsettings.php b/plugins/SubMirror/actions/mirrorsettings.php index edb024183..5463a8dc0 100644 --- a/plugins/SubMirror/actions/mirrorsettings.php +++ b/plugins/SubMirror/actions/mirrorsettings.php @@ -50,7 +50,7 @@ class MirrorSettingsAction extends AccountSettingsAction function getInstructions() { - return _m('You can mirror updates from your RSS and Atom feeds ' . + return _m('You can mirror updates from many RSS and Atom feeds ' . 'into your StatusNet timeline!'); } @@ -65,6 +65,8 @@ class MirrorSettingsAction extends AccountSettingsAction function showContent() { $user = common_current_user(); + + $this->showAddFeedForm(); $mirror = new SubMirror(); $mirror->subscriber = $user->id; @@ -73,7 +75,6 @@ class MirrorSettingsAction extends AccountSettingsAction $this->showFeedForm($mirror); } } - $this->showAddFeedForm(); } function showFeedForm($mirror) diff --git a/plugins/SubMirror/lib/addmirrorform.php b/plugins/SubMirror/lib/addmirrorform.php index 9ee59661a..0a798c9ea 100644 --- a/plugins/SubMirror/lib/addmirrorform.php +++ b/plugins/SubMirror/lib/addmirrorform.php @@ -57,46 +57,15 @@ class AddMirrorForm extends Form $this->out->elementStart('ul'); $this->li(); - $this->out->element('label', array('for' => $this->id() . '-profile'), - _m("Mirror one of your existing subscriptions:")); - $this->out->elementStart('select', array('name' => 'profile')); - - $user = common_current_user(); - $profile = $user->getSubscriptions(); - while ($profile->fetch()) { - $mirror = SubMirror::pkeyGet(array('subscriber' => $user->id, - 'subscribed' => $profile->id)); - if (!$mirror) { - $this->out->element('option', - array('value' => $profile->id), - $profile->getBestName()); - } - } - $this->out->elementEnd('select'); - $this->out->submit($this->id() . '-save', _m('Save')); + $this->doInput('addmirror-feedurl', + 'feedurl', + _m('Web page or feed URL:'), + $this->out->trimmed('feedurl')); $this->unli(); - $this->li(); - - $this->out->elementStart('fieldset', array('style' => 'width: 360px; margin-left: auto; margin-right: auto')); - $this->out->element('p', false, - _m("Not already subscribed to the feed you want? " . - "Add a new remote subscription and paste in the URL!")); - - $this->out->elementStart('div', 'entity_actions'); - $this->out->elementStart('p', array('id' => 'entity_remote_subscribe', - 'class' => 'entity_subscribe')); - $this->out->element('a', array('href' => common_local_url('ostatussub'), - 'class' => 'entity_remote_subscribe') - , _m('Remote')); - $this->out->elementEnd('p'); - $this->out->elementEnd('div'); - - $this->out->element('div', array('style' => 'clear: both')); - $this->out->elementEnd('fieldset'); + $this->out->submit('addmirror-save', _m('Add feed')); $this->unli(); - $this->out->elementEnd('ul'); $this->out->elementEnd('fieldset'); } @@ -106,7 +75,8 @@ class AddMirrorForm extends Form $this->out->element('label', array('for' => $id), $label); $attrs = array('name' => $name, 'type' => 'text', - 'id' => $id); + 'id' => $id, + 'style' => 'width: 80%'); if ($value) { $attrs['value'] = $value; } -- cgit v1.2.3-54-g00ecf From 79485340ab31ef9444431a098a5909b0be874264 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Fri, 6 Aug 2010 11:55:56 -0700 Subject: SubMirror: Drop mirror link from subscriptions list; has decoupled from subscriptions. --- plugins/SubMirror/SubMirrorPlugin.php | 7 ------- 1 file changed, 7 deletions(-) (limited to 'plugins/SubMirror') diff --git a/plugins/SubMirror/SubMirrorPlugin.php b/plugins/SubMirror/SubMirrorPlugin.php index 00c04ad0b..7289e7793 100644 --- a/plugins/SubMirror/SubMirrorPlugin.php +++ b/plugins/SubMirror/SubMirrorPlugin.php @@ -140,13 +140,6 @@ class SubMirrorPlugin extends Plugin $transports[] = 'mirror'; } - function onStartShowSubscriptionsContent($action) - { - $action->element('a', - array('href' => common_local_url('mirrorsettings')), - _m('Set up mirroring options...')); - } - /** * Let the OStatus subscription garbage collection know if we're * making use of a remote feed, so it doesn't get dropped out -- cgit v1.2.3-54-g00ecf From 729912e36a826f63ae109ae82125a97d1b100ce5 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Fri, 6 Aug 2010 12:00:31 -0700 Subject: Missing file from SubMirror. :P --- plugins/SubMirror/actions/basemirror.php | 169 +++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 plugins/SubMirror/actions/basemirror.php (limited to 'plugins/SubMirror') diff --git a/plugins/SubMirror/actions/basemirror.php b/plugins/SubMirror/actions/basemirror.php new file mode 100644 index 000000000..5be0699f0 --- /dev/null +++ b/plugins/SubMirror/actions/basemirror.php @@ -0,0 +1,169 @@ +. + * + * PHP version 5 + * + * @category Action + * @package StatusNet + * @author Brion Vibber + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Takes parameters: + * + * - feed: a profile ID + * - token: session token to prevent CSRF attacks + * - ajax: boolean; whether to return Ajax or full-browser results + * + * Only works if the current user is logged in. + * + * @category Action + * @package StatusNet + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +abstract class BaseMirrorAction extends Action +{ + var $user; + var $profile; + + /** + * Check pre-requisites and instantiate attributes + * + * @param Array $args array of arguments (URL, GET, POST) + * + * @return boolean success flag + */ + + function prepare($args) + { + parent::prepare($args); + return $this->sharedBoilerplate(); + } + + protected function validateFeedUrl($url) + { + if (common_valid_http_url($url)) { + return $url; + } else { + $this->clientError(_m("Invalid feed URL.")); + } + } + + protected function validateProfile($id) + { + $id = intval($id); + $profile = Profile::staticGet('id', $id); + if ($profile && $profile->id != $this->user->id) { + return $profile; + } + // TRANS: Error message returned to user when setting up feed mirroring, but we were unable to resolve the given URL to a working feed. + $this->clientError(_m("Invalid profile for mirroring.")); + } + + /** + * + * @param string $url + * @return Profile + */ + protected function profileForFeed($url) + { + $oprofile = Ostatus_profile::ensureProfileURL($url); + if ($oprofile->isGroup()) { + $this->clientError(_m("Can't mirror a StatusNet group at this time.")); + } + $this->oprofile = $oprofile; // @fixme ugly side effect :D + return $oprofile->localProfile(); + } + + /** + * @fixme none of this belongs in end classes + * this stuff belongs in shared code! + */ + function sharedBoilerplate() + { + // Only allow POST requests + + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + $this->clientError(_('This action only accepts POST requests.')); + return false; + } + + // CSRF protection + + $token = $this->trimmed('token'); + + if (!$token || $token != common_session_token()) { + $this->clientError(_('There was a problem with your session token.'. + ' Try again, please.')); + return false; + } + + // Only for logged-in users + + $this->user = common_current_user(); + + if (empty($this->user)) { + $this->clientError(_('Not logged in.')); + return false; + } + return true; + } + + /** + * Handle request + * + * Does the subscription and returns results. + * + * @param Array $args unused. + * + * @return void + */ + + function handle($args) + { + // Throws exception on error + $this->saveMirror(); + + if ($this->boolean('ajax')) { + $this->startHTML('text/xml;charset=utf-8'); + $this->elementStart('head'); + $this->element('title', null, _('Subscribed')); + $this->elementEnd('head'); + $this->elementStart('body'); + $unsubscribe = new EditMirrorForm($this, $this->profile); + $unsubscribe->show(); + $this->elementEnd('body'); + $this->elementEnd('html'); + } else { + $url = common_local_url('mirrorsettings'); + common_redirect($url, 303); + } + } + + abstract function saveMirror(); +} -- cgit v1.2.3-54-g00ecf From 39277ebf78dbe21e27bb93550689a983bd0237ae Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Fri, 6 Aug 2010 12:04:34 -0700 Subject: And.... one more fix for queueing in SubMirror. --- plugins/SubMirror/lib/mirrorqueuehandler.php | 1 + 1 file changed, 1 insertion(+) (limited to 'plugins/SubMirror') diff --git a/plugins/SubMirror/lib/mirrorqueuehandler.php b/plugins/SubMirror/lib/mirrorqueuehandler.php index 0306180ee..92b36b5eb 100644 --- a/plugins/SubMirror/lib/mirrorqueuehandler.php +++ b/plugins/SubMirror/lib/mirrorqueuehandler.php @@ -40,5 +40,6 @@ class MirrorQueueHandler extends QueueHandler $mirror->mirrorNotice($notice); } } + return true; } } -- cgit v1.2.3-54-g00ecf