From 7c7b91e61ad273023d774617f23fa1429f535b22 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 16:28:02 -0500 Subject: [PATCH 01/29] define configuration settings for account maintenance security --- README | 6 ++++++ lib/default.php | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README b/README index 3bebb11ee0..e2e4c580ef 100644 --- a/README +++ b/README @@ -1276,6 +1276,12 @@ Profile management. biolimit: max character length of bio; 0 means no limit; null means to use the site text limit default. +backup: whether users can backup their own profiles. Defaults to true. +restore: whether users can restore their profiles from backup files. Defaults + to true. +delete: whether users can delete their own accounts. Defaults to true. +move: whether users can move their accounts to another server. Defaults + to true. newuser ------- diff --git a/lib/default.php b/lib/default.php index 029dbb3906..7a44ed875d 100644 --- a/lib/default.php +++ b/lib/default.php @@ -123,7 +123,11 @@ $default = 'featured' => array()), 'profile' => array('banned' => array(), - 'biolimit' => null), + 'biolimit' => null, + 'backup' => true, + 'restore' => true, + 'delete' => true, + 'move' => true), 'avatar' => array('server' => null, 'dir' => INSTALLDIR . '/avatar/', From 75aaa9846263cb25d0047200e9eec678ca725ffe Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 16:28:32 -0500 Subject: [PATCH 02/29] define rights for account maintenance and default rules --- classes/Profile.php | 12 ++++++++++++ lib/right.php | 4 ++++ 2 files changed, 16 insertions(+) diff --git a/classes/Profile.php b/classes/Profile.php index 332d51e203..b83337cd22 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -891,6 +891,18 @@ class Profile extends Memcached_DataObject case Right::EMAILONFAVE: $result = !$this->isSandboxed(); break; + case Right::BACKUPACCOUNT: + $result = common_config('profile', 'backup'); + break; + case Right::RESTOREACCOUNT: + $result = common_config('profile', 'restore'); + break; + case Right::DELETEACCOUNT: + $result = common_config('profile', 'delete'); + break; + case Right::MOVEACCOUNT: + $result = common_config('profile', 'move'); + break; default: $result = false; break; diff --git a/lib/right.php b/lib/right.php index bacbea5f29..5bf9c41161 100644 --- a/lib/right.php +++ b/lib/right.php @@ -61,5 +61,9 @@ class Right const GRANTROLE = 'grantrole'; const REVOKEROLE = 'revokerole'; const DELETEGROUP = 'deletegroup'; + const BACKUPACCOUNT = 'backupaccount'; + const RESTOREACCOUNT = 'restoreaccount'; + const DELETEACCOUNT = 'deleteaccount'; + const MOVEACCOUNT = 'moveaccount'; } From 5089d3065c5b2944e468d821a0afdc0881830445 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 16:32:39 -0500 Subject: [PATCH 03/29] add an action to backup the current account in ActivityStreams format --- actions/backupaccount.php | 260 ++++++++++++++++++++++++++++++++++++ actions/profilesettings.php | 9 ++ lib/router.php | 1 + 3 files changed, 270 insertions(+) create mode 100644 actions/backupaccount.php diff --git a/actions/backupaccount.php b/actions/backupaccount.php new file mode 100644 index 0000000000..9454741f00 --- /dev/null +++ b/actions/backupaccount.php @@ -0,0 +1,260 @@ +. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +/** + * Download a backup of your own account to the browser + * + * We go through some hoops to make this only respond to POST, since + * it's kind of expensive and there's probably some downside to having + * your account in all kinds of search engines. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class BackupaccountAction extends Action +{ + /** + * Returns the title of the page + * + * @return string page title + */ + + function title() + { + return _("Backup account"); + } + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $cur = common_current_user(); + + if (empty($cur)) { + throw new ClientException(_('Only logged-in users can backup their account.'), 403); + } + + if (!$cur->hasRight(Right::BACKUPACCOUNT)) { + throw new ClientException(_('You may not backup your account.'), 403); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($args); + + if ($this->isPost()) { + $this->sendFeed(); + } else { + $this->showPage(); + } + return; + } + + /** + * Send a feed of the user's activities to the browser + * + * Uses the UserActivityStream class; may take a long time! + * + * @return void + */ + + function sendFeed() + { + $cur = common_current_user(); + + $stream = new UserActivityStream($cur); + + header('Content-Disposition: attachment; filename='.$cur->nickname.'.atom'); + header('Content-Type: application/atom+xml; charset=utf-8'); + + $this->raw($stream->getString()); + } + + /** + * Show a little form so that the person can request a backup. + * + * @return void + */ + + function showContent() + { + $form = new BackupAccountForm($this); + $form->show(); + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + return false; + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + + function lastModified() + { + // For comparison with If-Last-Modified + // If not applicable, return null + return null; + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + return null; + } +} + +/** + * A form for backing up the account. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class BackupAccountForm extends Form +{ + /** + * Class of the form. + * + * @return string the form's class + */ + + function formClass() + { + return 'form_profile_backup'; + } + + /** + * URL the form posts to + * + * @return string the form's action URL + */ + + function action() + { + return common_local_url('backupaccount'); + } + + /** + * Output form data + * + * Really, just instructions for doing a backup. + * + * @return void + */ + + function formData() + { + $msg = + _('You can backup your account data in '. + 'Activity Streams '. + 'format. This is an experimental feature and provides an '. + 'incomplete backup; private account '. + 'information like email and IM addresses is not backed up. '. + 'Additionally, uploaded files and direct messages are not '. + 'backed up.'); + $this->out->elementStart('p'); + $this->out->raw($msg); + $this->out->elementEnd('p'); + } + + /** + * Buttons for the form + * + * In this case, a single submit button + * + * @return void + */ + + function formActions() + { + $this->out->submit('submit', + _m('BUTTON', 'Backup'), + 'submit', + null, + _('Backup your account')); + } +} diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 28b1d20f34..17ffdf811b 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -452,4 +452,13 @@ class ProfilesettingsAction extends AccountSettingsAction return $other->id != $user->id; } } + + function showAside() { + $this->elementStart('div', array('id' => 'aside_primary', + 'class' => 'aside')); + $this->element('a', + array('href' => common_local_url('backupaccount')), + _('Backup account')); + $this->elementEnd('div'); + } } diff --git a/lib/router.php b/lib/router.php index c42cca5f60..369eebf8b4 100644 --- a/lib/router.php +++ b/lib/router.php @@ -199,6 +199,7 @@ class Router 'deleteuser', 'geocode', 'version', + 'backupaccount', ); foreach ($main as $a) { From 6a7bf9dbf97a86881181d070894b0586d9d34129 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 16:49:01 -0500 Subject: [PATCH 04/29] don't show the backup link if the user can't backup --- actions/profilesettings.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 17ffdf811b..4890a575b1 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -454,11 +454,15 @@ class ProfilesettingsAction extends AccountSettingsAction } function showAside() { + $user = common_current_user(); + $this->elementStart('div', array('id' => 'aside_primary', 'class' => 'aside')); - $this->element('a', - array('href' => common_local_url('backupaccount')), - _('Backup account')); + if ($user->hasRight(Right::BACKUPACCOUNT)) { + $this->element('a', + array('href' => common_local_url('backupaccount')), + _('Backup account')); + } $this->elementEnd('div'); } } From d840578aa0ad6284f57591aae87f87865905db3c Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Tue, 14 Dec 2010 12:38:43 -0500 Subject: [PATCH 05/29] An action to delete your own account The new DeleteaccountAction lets a user delete their own account (subject to global rights set by the admin). It presents a form to delete the account, with an "I am sure." text entry box. It then schedules the account for deletion and logs the user out. --- actions/deleteaccount.php | 319 ++++++++++++++++++++++++++++++++++++ actions/profilesettings.php | 9 + lib/router.php | 1 + 3 files changed, 329 insertions(+) create mode 100644 actions/deleteaccount.php diff --git a/actions/deleteaccount.php b/actions/deleteaccount.php new file mode 100644 index 0000000000..c7dfa570cd --- /dev/null +++ b/actions/deleteaccount.php @@ -0,0 +1,319 @@ +. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +/** + * Action to delete your own account + * + * Note that this is distinct from DeleteuserAction, which see. I thought + * that making that action do both things (delete another user and delete the + * current user) would open a lot of holes. I'm open to refactoring, however. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class DeleteaccountAction extends Action +{ + private $_complete = false; + private $_error = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $cur = common_current_user(); + + if (empty($cur)) { + throw new ClientException(_("Only logged-in users ". + "can delete their account."), 403); + } + + if (!$cur->hasRight(Right::DELETEACCOUNT)) { + throw new ClientException(_("You cannot delete your account."), 403); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($argarray); + + if ($this->isPost()) { + $this->deleteAccount(); + } else { + $this->showPage(); + } + return; + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + return false; + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + + function lastModified() + { + // For comparison with If-Last-Modified + // If not applicable, return null + return null; + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + return null; + } + + /** + * Delete the current user's account + * + * Checks for the "I am sure." string to make sure the user really + * wants to delete their account. + * + * Then, marks the account as deleted and begins the deletion process + * (actually done by a back-end handler). + * + * If successful it logs the user out, and shows a brief completion message. + * + * @return void + */ + + function deleteAccount() + { + $this->checkSessionToken(); + + if ($this->trimmed('iamsure') != _('I am sure.')) { + $this->_error = _('You must write "I am sure." exactly in the box.'); + $this->showPage(); + return; + } + + $cur = common_current_user(); + + // Mark the account as deleted and shove low-level deletion tasks + // to background queues. Removing a lot of posts can take a while... + + if (!$cur->hasRole(Profile_role::DELETED)) { + $cur->grantRole(Profile_role::DELETED); + } + + $qm = QueueManager::get(); + $qm->enqueue($cur, 'deluser'); + + // The user is really-truly logged out + + common_set_user(null); + common_real_login(false); // not logged in + common_forgetme(); // don't log back in! + + $this->_complete = true; + $this->showPage(); + } + + /** + * Shows the page content. + * + * If the deletion is complete, just shows a completion message. + * + * Otherwise, shows the deletion form. + * + * @return void + * + */ + + function showContent() + { + if ($this->_complete) { + $this->element('p', 'confirmation', + _('Account deleted.')); + return; + } + + if (!empty($this->_error)) { + $this->element('p', 'error', $this->_error); + $this->_error = null; + } + + $form = new DeleteAccountForm($this); + $form->show(); + } + + /** + * Show the title of the page + * + * @return string title + */ + + function title() + { + return _('Delete account'); + } +} + +/** + * Form for deleting your account + * + * Note that this mostly is here to keep you from accidentally deleting your + * account. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class DeleteAccountForm extends Form +{ + /** + * Class of the form. + * + * @return string the form's class + */ + + function formClass() + { + return 'form_profile_delete'; + } + + /** + * URL the form posts to + * + * @return string the form's action URL + */ + + function action() + { + return common_local_url('deleteaccount'); + } + + /** + * Output form data + * + * Instructions plus an 'i am sure' entry box. + * + * @return void + */ + + function formData() + { + $cur = common_current_user(); + + $msg = _('

This will permanently delete '. + 'your account data from this server.

'); + + if ($cur->hasRight(Right::BACKUPACCOUNT)) { + $msg .= sprintf(_('

You are strongly advised to '. + 'back up your data' + ' before deletion.

'), + common_local_url('backupaccount')); + } + + $this->out->elementStart('p'); + $this->out->raw($msg); + $this->out->elementEnd('p'); + + $this->out->input('iamsure', + _('Confirm'), + null, + _('Enter "I am sure." to confirm that '. + 'you want to delete your account.')); + } + + /** + * Buttons for the form + * + * In this case, a single submit button + * + * @return void + */ + + function formActions() + { + $this->out->submit('submit', + _m('BUTTON', 'Delete'), + 'submit', + null, + _('Permanently your account')); + } +} diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 4890a575b1..0226e1dd45 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -459,9 +459,18 @@ class ProfilesettingsAction extends AccountSettingsAction $this->elementStart('div', array('id' => 'aside_primary', 'class' => 'aside')); if ($user->hasRight(Right::BACKUPACCOUNT)) { + $this->elementStart('li'); $this->element('a', array('href' => common_local_url('backupaccount')), _('Backup account')); + $this->elementEnd('li'); + } + if ($user->hasRight(Right::DELETEACCOUNT)) { + $this->elementStart('li'); + $this->element('a', + array('href' => common_local_url('deleteaccount')), + _('Delete account')); + $this->elementEnd('li'); } $this->elementEnd('div'); } diff --git a/lib/router.php b/lib/router.php index 369eebf8b4..fc5f17cdec 100644 --- a/lib/router.php +++ b/lib/router.php @@ -200,6 +200,7 @@ class Router 'geocode', 'version', 'backupaccount', + 'deleteaccount', ); foreach ($main as $a) { From fd22f684bf24e2c3d5ea8af0b11de8127c3b7b99 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 15 Dec 2010 17:39:58 -0500 Subject: [PATCH 06/29] syntax error in deleteaccount --- actions/deleteaccount.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/deleteaccount.php b/actions/deleteaccount.php index c7dfa570cd..9abe2fcdb6 100644 --- a/actions/deleteaccount.php +++ b/actions/deleteaccount.php @@ -284,7 +284,7 @@ class DeleteAccountForm extends Form if ($cur->hasRight(Right::BACKUPACCOUNT)) { $msg .= sprintf(_('

You are strongly advised to '. - 'back up your data' + 'back up your data'. ' before deletion.

'), common_local_url('backupaccount')); } From 2e2519afee87009165c97026737f72634461e82b Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 15 Dec 2010 17:53:38 -0500 Subject: [PATCH 07/29] Move account restoration code to a shared library Moved most of the heavy-lifting for account restoration out of restoreuser.php and into its own class, with the hope that we'll do the work from the Web eventually. --- lib/accountrestorer.php | 360 ++++++++++++++++++++++++++++++++++++++++ scripts/restoreuser.php | 310 +--------------------------------- 2 files changed, 367 insertions(+), 303 deletions(-) create mode 100644 lib/accountrestorer.php diff --git a/lib/accountrestorer.php b/lib/accountrestorer.php new file mode 100644 index 0000000000..98f12ccb6a --- /dev/null +++ b/lib/accountrestorer.php @@ -0,0 +1,360 @@ +. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +/** + * A class for restoring accounts + * + * This is a clumsy objectification of the functions in restoreuser.php. + * + * Note that it quite illegally uses the OStatus_profile class which may + * not even exist on this server. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AccountRestorer +{ + function loadXML($xml) + { + $dom = DOMDocument::loadXML($xml); + + if ($dom->documentElement->namespaceURI != Activity::ATOM || + $dom->documentElement->localName != 'feed') { + throw new Exception("'$filename' is not an Atom feed."); + } + + return $dom; + } + + function importActivityStream($user, $doc) + { + $feed = $doc->documentElement; + + $subjectEl = ActivityUtils::child($feed, Activity::SUBJECT, Activity::SPEC); + + if (!empty($subjectEl)) { + $subject = new ActivityObject($subjectEl); + // TRANS: Commandline script output. %1$s is the subject ID, %2$s is the subject nickname. + printfv(_("Backup file for user %1$s (%2$s)")."\n", $subject->id, Ostatus_profile::getActivityObjectNickname($subject)); + } else { + throw new Exception("Feed doesn't have an element."); + } + + if (is_null($user)) { + // TRANS: Commandline script output. + printfv(_("No user specified; using backup user.")."\n"); + $user = $this->userFromSubject($subject); + } + + $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); + + // TRANS: Commandline script output. %d is the number of entries in the activity stream in backup; used for plural. + printfv(_m("%d entry in backup.","%d entries in backup.",$entries->length)."\n", $entries->length); + + for ($i = $entries->length - 1; $i >= 0; $i--) { + try { + $entry = $entries->item($i); + + $activity = new Activity($entry, $feed); + + switch ($activity->verb) { + case ActivityVerb::FOLLOW: + $this->subscribeProfile($user, $subject, $activity); + break; + case ActivityVerb::JOIN: + $this->joinGroup($user, $activity); + break; + case ActivityVerb::POST: + $this->postNote($user, $activity); + break; + default: + throw new Exception("Unknown verb: {$activity->verb}"); + } + } catch (Exception $e) { + print $e->getMessage()."\n"; + continue; + } + } + } + + function subscribeProfile($user, $subject, $activity) + { + $profile = $user->getProfile(); + + if ($activity->objects[0]->id == $subject->id) { + + $other = $activity->actor; + $otherUser = User::staticGet('uri', $other->id); + + if (!empty($otherUser)) { + $otherProfile = $otherUser->getProfile(); + } else { + throw new Exception("Can't force remote user to subscribe."); + } + // XXX: don't do this for untrusted input! + Subscription::start($otherProfile, $profile); + + } else if (empty($activity->actor) || $activity->actor->id == $subject->id) { + + $other = $activity->objects[0]; + $otherUser = User::staticGet('uri', $other->id); + + if (!empty($otherUser)) { + $otherProfile = $otherUser->getProfile(); + } else { + $oprofile = Ostatus_profile::ensureActivityObjectProfile($other); + $otherProfile = $oprofile->localProfile(); + } + + Subscription::start($profile, $otherProfile); + } else { + throw new Exception("This activity seems unrelated to our user."); + } + } + + function joinGroup($user, $activity) + { + // XXX: check that actor == subject + + $uri = $activity->objects[0]->id; + + $group = User_group::staticGet('uri', $uri); + + if (empty($group)) { + $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); + if (!$oprofile->isGroup()) { + throw new Exception("Remote profile is not a group!"); + } + $group = $oprofile->localGroup(); + } + + assert(!empty($group)); + + if (Event::handle('StartJoinGroup', array($group, $user))) { + Group_member::join($group->id, $user->id); + Event::handle('EndJoinGroup', array($group, $user)); + } + } + + // XXX: largely cadged from Ostatus_profile::processNote() + + function postNote($user, $activity) + { + $note = $activity->objects[0]; + + $sourceUri = $note->id; + + $notice = Notice::staticGet('uri', $sourceUri); + + if (!empty($notice)) { + // This is weird. + $orig = clone($notice); + $notice->profile_id = $user->id; + $notice->update($orig); + return; + } + + // 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? + // @todo i18n FIXME: use sprintf and add i18n. + throw new ClientException("No content for notice {$sourceUri}."); + } + + // Get (safe!) HTML and text versions of the content + + $rendered = $this->purify($sourceContent); + $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8'); + + $shortened = $user->shortenLinks($content); + + $options = array('is_local' => Notice::LOCAL_PUBLIC, + 'uri' => $sourceUri, + 'rendered' => $rendered, + 'replies' => array(), + 'groups' => array(), + 'tags' => array(), + 'urls' => array()); + + // Check for optional attributes... + + if (!empty($activity->time)) { + $options['created'] = common_sql_date($activity->time); + } + + if ($activity->context) { + // Any individual or group attn: targets? + + list($options['groups'], $options['replies']) = $this->filterAttention($activity->context->attention); + + // Maintain direct reply associations + // @fixme what about conversation ID? + if (!empty($activity->context->replyToID)) { + $orig = Notice::staticGet('uri', + $activity->context->replyToID); + if (!empty($orig)) { + $options['reply_to'] = $orig->id; + } + } + + $location = $activity->context->location; + + if ($location) { + $options['lat'] = $location->lat; + $options['lon'] = $location->lon; + if ($location->location_id) { + $options['location_ns'] = $location->location_ns; + $options['location_id'] = $location->location_id; + } + } + } + + // Atom categories <-> hashtags + + foreach ($activity->categories as $cat) { + if ($cat->term) { + $term = common_canonical_tag($cat->term); + if ($term) { + $options['tags'][] = $term; + } + } + } + + // Atom enclosures -> attachment URLs + foreach ($activity->enclosures as $href) { + // @fixme save these locally or....? + $options['urls'][] = $href; + } + + $saved = Notice::saveNew($user->id, + $content, + 'restore', // TODO: restore the actual source + $options); + + return $saved; + } + + function filterAttention($attn) + { + $groups = array(); + $replies = array(); + + foreach (array_unique($attn) as $recipient) { + + // Is the recipient a local user? + + $user = User::staticGet('uri', $recipient); + + if ($user) { + // @fixme sender verification, spam etc? + $replies[] = $recipient; + continue; + } + + // Is the recipient a remote group? + $oprofile = Ostatus_profile::ensureProfileURI($recipient); + + if ($oprofile) { + if (!$oprofile->isGroup()) { + // may be canonicalized or something + $replies[] = $oprofile->uri; + } + continue; + } + + // Is the recipient a local group? + // @fixme uri on user_group isn't reliable yet + // $group = User_group::staticGet('uri', $recipient); + $id = OStatusPlugin::localGroupFromUrl($recipient); + + if ($id) { + $group = User_group::staticGet('id', $id); + if ($group) { + // Deliver to all members of this local group if allowed. + $profile = $sender->localProfile(); + if ($profile->isMember($group)) { + $groups[] = $group->id; + } else { + common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member"); + } + continue; + } else { + common_log(LOG_INFO, "Skipping reply to bogus group $recipient"); + } + } + } + + return array($groups, $replies); + } + + function userFromSubject($subject) + { + $user = User::staticGet('uri', $subject->id); + + if (empty($user)) { + $attrs = + array('nickname' => Ostatus_profile::getActivityObjectNickname($subject), + 'uri' => $subject->id); + + $user = User::register($attrs); + } + + $profile = $user->getProfile(); + Ostatus_profile::updateProfile($profile, $subject); + + // FIXME: Update avatar + return $user; + } + + function purify($content) + { + $config = array('safe' => 1, + 'deny_attribute' => 'id,style,on*'); + return htmLawed($content, $config); + } +} diff --git a/scripts/restoreuser.php b/scripts/restoreuser.php index b37e9db741..eac7e5cf29 100644 --- a/scripts/restoreuser.php +++ b/scripts/restoreuser.php @@ -36,6 +36,7 @@ END_OF_RESTOREUSER_HELP; require_once INSTALLDIR.'/scripts/commandline.inc'; require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php'; + function getActivityStreamDocument() { $filename = get_option_value('f', 'file'); @@ -60,311 +61,12 @@ function getActivityStreamDocument() // TRANS: Commandline script output. %s is the filename that contains a backup for a user. printfv(_("Getting backup from file '%s'.")."\n",$filename); + $xml = file_get_contents($filename); - $dom = DOMDocument::loadXML($xml); - - if ($dom->documentElement->namespaceURI != Activity::ATOM || - $dom->documentElement->localName != 'feed') { - throw new Exception("'$filename' is not an Atom feed."); - } - - return $dom; + return $xml; } -function importActivityStream($user, $doc) -{ - $feed = $doc->documentElement; - - $subjectEl = ActivityUtils::child($feed, Activity::SUBJECT, Activity::SPEC); - - if (!empty($subjectEl)) { - $subject = new ActivityObject($subjectEl); - // TRANS: Commandline script output. %1$s is the subject ID, %2$s is the subject nickname. - printfv(_("Backup file for user %1$s (%2$s)")."\n", $subject->id, Ostatus_profile::getActivityObjectNickname($subject)); - } else { - throw new Exception("Feed doesn't have an element."); - } - - if (is_null($user)) { - // TRANS: Commandline script output. - printfv(_("No user specified; using backup user.")."\n"); - $user = userFromSubject($subject); - } - - $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - // TRANS: Commandline script output. %d is the number of entries in the activity stream in backup; used for plural. - printfv(_m("%d entry in backup.","%d entries in backup.",$entries->length)."\n", $entries->length); - - for ($i = $entries->length - 1; $i >= 0; $i--) { - try { - $entry = $entries->item($i); - - $activity = new Activity($entry, $feed); - - switch ($activity->verb) { - case ActivityVerb::FOLLOW: - subscribeProfile($user, $subject, $activity); - break; - case ActivityVerb::JOIN: - joinGroup($user, $activity); - break; - case ActivityVerb::POST: - postNote($user, $activity); - break; - default: - throw new Exception("Unknown verb: {$activity->verb}"); - } - } catch (Exception $e) { - print $e->getMessage()."\n"; - continue; - } - } -} - -function subscribeProfile($user, $subject, $activity) -{ - $profile = $user->getProfile(); - - if ($activity->objects[0]->id == $subject->id) { - - $other = $activity->actor; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - throw new Exception("Can't force remote user to subscribe."); - } - // XXX: don't do this for untrusted input! - Subscription::start($otherProfile, $profile); - - } else if (empty($activity->actor) || $activity->actor->id == $subject->id) { - - $other = $activity->objects[0]; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($other); - $otherProfile = $oprofile->localProfile(); - } - - Subscription::start($profile, $otherProfile); - } else { - throw new Exception("This activity seems unrelated to our user."); - } -} - -function joinGroup($user, $activity) -{ - // XXX: check that actor == subject - - $uri = $activity->objects[0]->id; - - $group = User_group::staticGet('uri', $uri); - - if (empty($group)) { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); - if (!$oprofile->isGroup()) { - throw new Exception("Remote profile is not a group!"); - } - $group = $oprofile->localGroup(); - } - - assert(!empty($group)); - - if (Event::handle('StartJoinGroup', array($group, $user))) { - Group_member::join($group->id, $user->id); - Event::handle('EndJoinGroup', array($group, $user)); - } -} - -// XXX: largely cadged from Ostatus_profile::processNote() - -function postNote($user, $activity) -{ - $note = $activity->objects[0]; - - $sourceUri = $note->id; - - $notice = Notice::staticGet('uri', $sourceUri); - - if (!empty($notice)) { - // This is weird. - $orig = clone($notice); - $notice->profile_id = $user->id; - $notice->update($orig); - return; - } - - // 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? - // @todo i18n FIXME: use sprintf and add i18n. - throw new ClientException("No content for notice {$sourceUri}."); - } - - // Get (safe!) HTML and text versions of the content - - $rendered = purify($sourceContent); - $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8'); - - $shortened = $user->shortenLinks($content); - - $options = array('is_local' => Notice::LOCAL_PUBLIC, - 'uri' => $sourceUri, - 'rendered' => $rendered, - 'replies' => array(), - 'groups' => array(), - 'tags' => array(), - 'urls' => array()); - - // Check for optional attributes... - - if (!empty($activity->time)) { - $options['created'] = common_sql_date($activity->time); - } - - if ($activity->context) { - // Any individual or group attn: targets? - - list($options['groups'], $options['replies']) = filterAttention($activity->context->attention); - - // Maintain direct reply associations - // @fixme what about conversation ID? - if (!empty($activity->context->replyToID)) { - $orig = Notice::staticGet('uri', - $activity->context->replyToID); - if (!empty($orig)) { - $options['reply_to'] = $orig->id; - } - } - - $location = $activity->context->location; - - if ($location) { - $options['lat'] = $location->lat; - $options['lon'] = $location->lon; - if ($location->location_id) { - $options['location_ns'] = $location->location_ns; - $options['location_id'] = $location->location_id; - } - } - } - - // Atom categories <-> hashtags - - foreach ($activity->categories as $cat) { - if ($cat->term) { - $term = common_canonical_tag($cat->term); - if ($term) { - $options['tags'][] = $term; - } - } - } - - // Atom enclosures -> attachment URLs - foreach ($activity->enclosures as $href) { - // @fixme save these locally or....? - $options['urls'][] = $href; - } - - $saved = Notice::saveNew($user->id, - $content, - 'restore', // TODO: restore the actual source - $options); - - return $saved; -} - -function filterAttention($attn) -{ - $groups = array(); - $replies = array(); - - foreach (array_unique($attn) as $recipient) { - - // Is the recipient a local user? - - $user = User::staticGet('uri', $recipient); - - if ($user) { - // @fixme sender verification, spam etc? - $replies[] = $recipient; - continue; - } - - // Is the recipient a remote group? - $oprofile = Ostatus_profile::ensureProfileURI($recipient); - - if ($oprofile) { - if (!$oprofile->isGroup()) { - // may be canonicalized or something - $replies[] = $oprofile->uri; - } - continue; - } - - // Is the recipient a local group? - // @fixme uri on user_group isn't reliable yet - // $group = User_group::staticGet('uri', $recipient); - $id = OStatusPlugin::localGroupFromUrl($recipient); - - if ($id) { - $group = User_group::staticGet('id', $id); - if ($group) { - // Deliver to all members of this local group if allowed. - $profile = $sender->localProfile(); - if ($profile->isMember($group)) { - $groups[] = $group->id; - } else { - common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member"); - } - continue; - } else { - common_log(LOG_INFO, "Skipping reply to bogus group $recipient"); - } - } - } - - return array($groups, $replies); -} - -function userFromSubject($subject) -{ - $user = User::staticGet('uri', $subject->id); - - if (empty($user)) { - $attrs = - array('nickname' => Ostatus_profile::getActivityObjectNickname($subject), - 'uri' => $subject->id); - - $user = User::register($attrs); - } - - $profile = $user->getProfile(); - Ostatus_profile::updateProfile($profile, $subject); - - // FIXME: Update avatar - return $user; -} - -function purify($content) -{ - $config = array('safe' => 1, - 'deny_attribute' => 'id,style,on*'); - return htmLawed($content, $config); -} try { try { @@ -372,8 +74,10 @@ try { } catch (NoUserArgumentException $noae) { $user = null; } - $doc = getActivityStreamDocument(); - importActivityStream($user, $doc); + $xml = getActivityStreamDocument(); + $restorer = new AccountRestorer(); + $doc = $restorer->loadXML($xml); + $restorer->importActivityStream($user, $doc); } catch (Exception $e) { print $e->getMessage()."\n"; exit(1); From 39804809dd67d72926d985f31164e6df334ee387 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Thu, 16 Dec 2010 16:17:38 -0500 Subject: [PATCH 08/29] distribute flag for Notice::saveNew() --- classes/Notice.php | 11 ++++++++--- lib/accountrestorer.php | 44 ++++++++++++++++++++--------------------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/classes/Notice.php b/classes/Notice.php index a067cd3741..d412c5f3ae 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -234,6 +234,8 @@ class Notice extends Memcached_DataObject * in place of extracting # tags from content * array 'urls' list of attached/referred URLs to save with the * notice in place of extracting links from content + * boolean 'distribute' whether to distribute the notice, default true + * * @fixme tag override * * @return Notice @@ -243,7 +245,8 @@ class Notice extends Memcached_DataObject $defaults = array('uri' => null, 'url' => null, 'reply_to' => null, - 'repeat_of' => null); + 'repeat_of' => null, + 'distribute' => true); if (!empty($options)) { $options = $options + $defaults; @@ -426,8 +429,10 @@ class Notice extends Memcached_DataObject $notice->saveUrls(); } - // Prepare inbox delivery, may be queued to background. - $notice->distribute(); + if ($distribute) { + // Prepare inbox delivery, may be queued to background. + $notice->distribute(); + } return $notice; } diff --git a/lib/accountrestorer.php b/lib/accountrestorer.php index 98f12ccb6a..3f6ac0da4a 100644 --- a/lib/accountrestorer.php +++ b/lib/accountrestorer.php @@ -52,6 +52,8 @@ if (!defined('STATUSNET')) { class AccountRestorer { + private $_trusted = false; + function loadXML($xml) { $dom = DOMDocument::loadXML($xml); @@ -72,29 +74,22 @@ class AccountRestorer if (!empty($subjectEl)) { $subject = new ActivityObject($subjectEl); - // TRANS: Commandline script output. %1$s is the subject ID, %2$s is the subject nickname. - printfv(_("Backup file for user %1$s (%2$s)")."\n", $subject->id, Ostatus_profile::getActivityObjectNickname($subject)); } else { throw new Exception("Feed doesn't have an element."); } if (is_null($user)) { - // TRANS: Commandline script output. - printfv(_("No user specified; using backup user.")."\n"); $user = $this->userFromSubject($subject); } $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); - // TRANS: Commandline script output. %d is the number of entries in the activity stream in backup; used for plural. - printfv(_m("%d entry in backup.","%d entries in backup.",$entries->length)."\n", $entries->length); + $activities = $this->entriesToActivities($entries, $feed); - for ($i = $entries->length - 1; $i >= 0; $i--) { + // XXX: sort entries here + + foreach ($activities as $activity) { try { - $entry = $entries->item($i); - - $activity = new Activity($entry, $feed); - switch ($activity->verb) { case ActivityVerb::FOLLOW: $this->subscribeProfile($user, $subject, $activity); @@ -109,7 +104,7 @@ class AccountRestorer throw new Exception("Unknown verb: {$activity->verb}"); } } catch (Exception $e) { - print $e->getMessage()."\n"; + common_log(LOG_WARNING, $e->getMessage()); continue; } } @@ -120,19 +115,22 @@ class AccountRestorer $profile = $user->getProfile(); if ($activity->objects[0]->id == $subject->id) { - - $other = $activity->actor; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); + if (!$this->_trusted) { + throw new Exception("Skipping a pushed subscription."); } else { - throw new Exception("Can't force remote user to subscribe."); - } - // XXX: don't do this for untrusted input! - Subscription::start($otherProfile, $profile); + $other = $activity->actor; + $otherUser = User::staticGet('uri', $other->id); - } else if (empty($activity->actor) || $activity->actor->id == $subject->id) { + if (!empty($otherUser)) { + $otherProfile = $otherUser->getProfile(); + } else { + throw new Exception("Can't force remote user to subscribe."); + } + // XXX: don't do this for untrusted input! + Subscription::start($otherProfile, $profile); + } + } else if (empty($activity->actor) + || $activity->actor->id == $subject->id) { $other = $activity->objects[0]; $otherUser = User::staticGet('uri', $other->id); From 16fc5314fbc4d7542e45c75af787ef906ae359ae Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 13:09:37 -0500 Subject: [PATCH 09/29] move code to get an author object for a feed to a library from Ostatus_profile --- lib/activityutils.php | 47 +++++++++++++++++++ plugins/OStatus/classes/Ostatus_profile.php | 51 ++++----------------- 2 files changed, 55 insertions(+), 43 deletions(-) diff --git a/lib/activityutils.php b/lib/activityutils.php index c462514c49..11befc0ed4 100644 --- a/lib/activityutils.php +++ b/lib/activityutils.php @@ -270,4 +270,51 @@ class ActivityUtils return false; } + + static function getFeedAuthor($feedEl) + { + // Try the feed author + + $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM); + + if (!empty($author)) { + return new ActivityObject($author); + } + + // Try old and deprecated activity:subject + + $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC); + + if (!empty($subject)) { + return new ActivityObject($subject); + } + + // Sheesh. Not a very nice feed! Let's try fingerpoken in the + // entries. + + $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry'); + + if (!empty($entries) && $entries->length > 0) { + + $entry = $entries->item(0); + + // Try the author + + $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM); + + if (!empty($author)) { + return new ActivityObject($author); + } + + // Try the (deprecated) activity:actor + + $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC); + + if (!empty($actor)) { + return new ActivityObject($actor); + } + } + + return null; + } } diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index e5b8939a9d..77cf57a670 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -935,54 +935,19 @@ class Ostatus_profile extends Memcached_DataObject * @return Ostatus_profile * @throws Exception */ + public static function ensureAtomFeed($feedEl, $hints) { - // Try to get a profile from the feed activity:subject + $author = ActivityUtils::getFeedAuthor($feedEl); - $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC); - - if (!empty($subject)) { - $subjObject = new ActivityObject($subject); - return self::ensureActivityObjectProfile($subjObject, $hints); + if (empty($author)) { + // XXX: make some educated guesses here + // TRANS: Feed sub exception. + throw new FeedSubException(_m('Can\'t find enough profile '. + 'information to make a feed.')); } - // Otherwise, try the feed author - - $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM); - - if (!empty($author)) { - $authorObject = new ActivityObject($author); - return self::ensureActivityObjectProfile($authorObject, $hints); - } - - // Sheesh. Not a very nice feed! Let's try fingerpoken in the - // entries. - - $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - if (!empty($entries) && $entries->length > 0) { - - $entry = $entries->item(0); - - $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC); - - if (!empty($actor)) { - $actorObject = new ActivityObject($actor); - return self::ensureActivityObjectProfile($actorObject, $hints); - - } - - $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM); - - if (!empty($author)) { - $authorObject = new ActivityObject($author); - return self::ensureActivityObjectProfile($authorObject, $hints); - } - } - - // XXX: make some educated guesses here - // TRANS: Feed sub exception. - throw new FeedSubException(_m('Can\'t find enough profile information to make a feed.')); + return self::ensureActivityObjectProfile($author, $hints); } /** From 6469d75fb0c2d148c8947e905545da89293b3236 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 13:10:23 -0500 Subject: [PATCH 10/29] Move accountrestorer class to feed importer --- lib/{accountrestorer.php => feedimporter.php} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename lib/{accountrestorer.php => feedimporter.php} (100%) diff --git a/lib/accountrestorer.php b/lib/feedimporter.php similarity index 100% rename from lib/accountrestorer.php rename to lib/feedimporter.php From 044763cf06b1fb99cd8246dcbb8bb4a3e545d3ed Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 13:12:17 -0500 Subject: [PATCH 11/29] move activity importing code to two different queuehandler classes --- lib/activityimporter.php | 317 +++++++++++++++++++++++++++++++++++ lib/feedimporter.php | 345 +++++++-------------------------------- lib/queuemanager.php | 2 + 3 files changed, 377 insertions(+), 287 deletions(-) create mode 100644 lib/activityimporter.php diff --git a/lib/activityimporter.php b/lib/activityimporter.php new file mode 100644 index 0000000000..07a6b0e77b --- /dev/null +++ b/lib/activityimporter.php @@ -0,0 +1,317 @@ +. + * + * @category Cache + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +/** + * Class comment + * + * @category General + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class ActivityImporter extends QueueHandler +{ + private $trusted = false; + + /** + * Function comment + * + * @param + * + * @return + */ + + function handle($data) + { + list($user, $author, $activity, $trusted) = $data; + + $this->trusted = $trusted; + + try { + switch ($activity->verb) { + case ActivityVerb::FOLLOW: + $this->subscribeProfile($user, $author, $activity); + break; + case ActivityVerb::JOIN: + $this->joinGroup($user, $activity); + break; + case ActivityVerb::POST: + $this->postNote($user, $activity); + break; + default: + throw new Exception("Unknown verb: {$activity->verb}"); + } + } catch (ClientException $ce) { + common_log(LOG_WARNING, $ce->getMessage()); + return true; + } catch (ServerException $se) { + common_log(LOG_ERR, $ce->getMessage()); + return false; + } catch (Exception $e) { + common_log(LOG_ERR, $ce->getMessage()); + return false; + } + return true; + } + + function subscribeProfile($user, $author, $activity) + { + $profile = $user->getProfile(); + + if ($activity->objects[0]->id == $author->id) { + $other = $activity->actor; + $otherUser = User::staticGet('uri', $other->id); + + if (!empty($otherUser)) { + $otherProfile = $otherUser->getProfile(); + } else { + throw new Exception("Can't force remote user to subscribe."); + } + + // XXX: don't do this for untrusted input! + + Subscription::start($otherProfile, $profile); + + } else if (empty($activity->actor) + || $activity->actor->id == $author->id) { + + $other = $activity->objects[0]; + + $otherProfile = Profile::fromUri($other->id); + + if (empty($otherProfile)) { + throw new ClientException(_("Unknown profile.")); + } + + Subscription::start($profile, $otherProfile); + } else { + throw new Exception("This activity seems unrelated to our user."); + } + } + + function joinGroup($user, $activity) + { + // XXX: check that actor == subject + + $uri = $activity->objects[0]->id; + + $group = User_group::staticGet('uri', $uri); + + if (empty($group)) { + $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); + if (!$oprofile->isGroup()) { + throw new Exception("Remote profile is not a group!"); + } + $group = $oprofile->localGroup(); + } + + assert(!empty($group)); + + if (Event::handle('StartJoinGroup', array($group, $user))) { + Group_member::join($group->id, $user->id); + Event::handle('EndJoinGroup', array($group, $user)); + } + } + + // XXX: largely cadged from Ostatus_profile::processNote() + + function postNote($user, $activity) + { + $note = $activity->objects[0]; + + $sourceUri = $note->id; + + $notice = Notice::staticGet('uri', $sourceUri); + + if (!empty($notice)) { + // This is weird. + $orig = clone($notice); + $notice->profile_id = $user->id; + $notice->update($orig); + return; + } + + // 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? + // @todo i18n FIXME: use sprintf and add i18n. + throw new ClientException("No content for notice {$sourceUri}."); + } + + // Get (safe!) HTML and text versions of the content + + $rendered = $this->purify($sourceContent); + $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8'); + + $shortened = $user->shortenLinks($content); + + $options = array('is_local' => Notice::LOCAL_PUBLIC, + 'uri' => $sourceUri, + 'rendered' => $rendered, + 'replies' => array(), + 'groups' => array(), + 'tags' => array(), + 'urls' => array()); + + // Check for optional attributes... + + if (!empty($activity->time)) { + $options['created'] = common_sql_date($activity->time); + } + + if ($activity->context) { + // Any individual or group attn: targets? + + list($options['groups'], $options['replies']) = $this->filterAttention($activity->context->attention); + + // Maintain direct reply associations + // @fixme what about conversation ID? + if (!empty($activity->context->replyToID)) { + $orig = Notice::staticGet('uri', + $activity->context->replyToID); + if (!empty($orig)) { + $options['reply_to'] = $orig->id; + } + } + + $location = $activity->context->location; + + if ($location) { + $options['lat'] = $location->lat; + $options['lon'] = $location->lon; + if ($location->location_id) { + $options['location_ns'] = $location->location_ns; + $options['location_id'] = $location->location_id; + } + } + } + + // Atom categories <-> hashtags + + foreach ($activity->categories as $cat) { + if ($cat->term) { + $term = common_canonical_tag($cat->term); + if ($term) { + $options['tags'][] = $term; + } + } + } + + // Atom enclosures -> attachment URLs + foreach ($activity->enclosures as $href) { + // @fixme save these locally or....? + $options['urls'][] = $href; + } + + $saved = Notice::saveNew($user->id, + $content, + 'restore', // TODO: restore the actual source + $options); + + return $saved; + } + + function filterAttention($attn) + { + $groups = array(); + $replies = array(); + + foreach (array_unique($attn) as $recipient) { + + // Is the recipient a local user? + + $user = User::staticGet('uri', $recipient); + + if ($user) { + // @fixme sender verification, spam etc? + $replies[] = $recipient; + continue; + } + + // Is the recipient a remote group? + $oprofile = Ostatus_profile::ensureProfileURI($recipient); + + if ($oprofile) { + if (!$oprofile->isGroup()) { + // may be canonicalized or something + $replies[] = $oprofile->uri; + } + continue; + } + + // Is the recipient a local group? + // @fixme uri on user_group isn't reliable yet + // $group = User_group::staticGet('uri', $recipient); + $id = OStatusPlugin::localGroupFromUrl($recipient); + + if ($id) { + $group = User_group::staticGet('id', $id); + if ($group) { + // Deliver to all members of this local group if allowed. + $profile = $sender->localProfile(); + if ($profile->isMember($group)) { + $groups[] = $group->id; + } else { + common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member"); + } + continue; + } else { + common_log(LOG_INFO, "Skipping reply to bogus group $recipient"); + } + } + } + + return array($groups, $replies); + } + + + function purify($content) + { + $config = array('safe' => 1, + 'deny_attribute' => 'id,style,on*'); + return htmLawed($content, $config); + } +} diff --git a/lib/feedimporter.php b/lib/feedimporter.php index 3f6ac0da4a..e2c9df72fd 100644 --- a/lib/feedimporter.php +++ b/lib/feedimporter.php @@ -3,7 +3,7 @@ * StatusNet - the distributed open-source microblogging tool * Copyright (C) 2010, StatusNet, Inc. * - * A class for restoring accounts + * Importer for feeds of activities * * PHP version 5 * @@ -35,13 +35,11 @@ if (!defined('STATUSNET')) { } /** - * A class for restoring accounts + * Importer for feeds of activities + * + * Takes an XML file representing a feed of activities and imports each + * activity to the user in question. * - * This is a clumsy objectification of the functions in restoreuser.php. - * - * Note that it quite illegally uses the OStatus_profile class which may - * not even exist on this server. - * * @category Account * @package StatusNet * @author Evan Prodromou @@ -50,309 +48,82 @@ if (!defined('STATUSNET')) { * @link http://status.net/ */ -class AccountRestorer +class FeedImporter extends QueueHandler { - private $_trusted = false; + /** + * Transport identifier + * + * @return string identifier for this queue handler + */ - function loadXML($xml) + public function transport() { - $dom = DOMDocument::loadXML($xml); - - if ($dom->documentElement->namespaceURI != Activity::ATOM || - $dom->documentElement->localName != 'feed') { - throw new Exception("'$filename' is not an Atom feed."); - } - - return $dom; + return 'feedimp'; } - function importActivityStream($user, $doc) + function handle($data) { - $feed = $doc->documentElement; + list($user, $xml, $trusted) = $data; - $subjectEl = ActivityUtils::child($feed, Activity::SUBJECT, Activity::SPEC); + try { + $doc = DOMDocument::loadXML($xml); - if (!empty($subjectEl)) { - $subject = new ActivityObject($subjectEl); - } else { - throw new Exception("Feed doesn't have an element."); - } - - if (is_null($user)) { - $user = $this->userFromSubject($subject); - } - - $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - $activities = $this->entriesToActivities($entries, $feed); - - // XXX: sort entries here - - foreach ($activities as $activity) { - try { - switch ($activity->verb) { - case ActivityVerb::FOLLOW: - $this->subscribeProfile($user, $subject, $activity); - break; - case ActivityVerb::JOIN: - $this->joinGroup($user, $activity); - break; - case ActivityVerb::POST: - $this->postNote($user, $activity); - break; - default: - throw new Exception("Unknown verb: {$activity->verb}"); - } - } catch (Exception $e) { - common_log(LOG_WARNING, $e->getMessage()); - continue; + if ($doc->documentElement->namespaceURI != Activity::ATOM || + $doc->documentElement->localName != 'feed') { + throw new ClientException(_("Not an atom feed.")); } + + $feed = $doc->documentElement; + + $author = ActivityUtils::getFeedAuthor($feed); + + if (empty($author)) { + throw new ClientException(_("No author in the feed.")); + } + + if (empty($user)) { + if ($trusted) { + $user = $this->userFromAuthor($author); + } + } + + $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); + + $activities = $this->entriesToActivities($entries, $feed); + + $qm = QueueManager::get(); + + foreach ($activities as $activity) { + $qm->enqueue(array($user, $author, $activity, $trusted), 'actimp'); + } + } catch (ClientException $ce) { + common_log(LOG_WARNING, $ce->getMessage()); + return true; + } catch (ServerException $se) { + common_log(LOG_ERR, $ce->getMessage()); + return false; + } catch (Exception $e) { + common_log(LOG_ERR, $ce->getMessage()); + return false; } } - function subscribeProfile($user, $subject, $activity) + function userFromAuthor($author) { - $profile = $user->getProfile(); - - if ($activity->objects[0]->id == $subject->id) { - if (!$this->_trusted) { - throw new Exception("Skipping a pushed subscription."); - } else { - $other = $activity->actor; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - throw new Exception("Can't force remote user to subscribe."); - } - // XXX: don't do this for untrusted input! - Subscription::start($otherProfile, $profile); - } - } else if (empty($activity->actor) - || $activity->actor->id == $subject->id) { - - $other = $activity->objects[0]; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($other); - $otherProfile = $oprofile->localProfile(); - } - - Subscription::start($profile, $otherProfile); - } else { - throw new Exception("This activity seems unrelated to our user."); - } - } - - function joinGroup($user, $activity) - { - // XXX: check that actor == subject - - $uri = $activity->objects[0]->id; - - $group = User_group::staticGet('uri', $uri); - - if (empty($group)) { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); - if (!$oprofile->isGroup()) { - throw new Exception("Remote profile is not a group!"); - } - $group = $oprofile->localGroup(); - } - - assert(!empty($group)); - - if (Event::handle('StartJoinGroup', array($group, $user))) { - Group_member::join($group->id, $user->id); - Event::handle('EndJoinGroup', array($group, $user)); - } - } - - // XXX: largely cadged from Ostatus_profile::processNote() - - function postNote($user, $activity) - { - $note = $activity->objects[0]; - - $sourceUri = $note->id; - - $notice = Notice::staticGet('uri', $sourceUri); - - if (!empty($notice)) { - // This is weird. - $orig = clone($notice); - $notice->profile_id = $user->id; - $notice->update($orig); - return; - } - - // 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? - // @todo i18n FIXME: use sprintf and add i18n. - throw new ClientException("No content for notice {$sourceUri}."); - } - - // Get (safe!) HTML and text versions of the content - - $rendered = $this->purify($sourceContent); - $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8'); - - $shortened = $user->shortenLinks($content); - - $options = array('is_local' => Notice::LOCAL_PUBLIC, - 'uri' => $sourceUri, - 'rendered' => $rendered, - 'replies' => array(), - 'groups' => array(), - 'tags' => array(), - 'urls' => array()); - - // Check for optional attributes... - - if (!empty($activity->time)) { - $options['created'] = common_sql_date($activity->time); - } - - if ($activity->context) { - // Any individual or group attn: targets? - - list($options['groups'], $options['replies']) = $this->filterAttention($activity->context->attention); - - // Maintain direct reply associations - // @fixme what about conversation ID? - if (!empty($activity->context->replyToID)) { - $orig = Notice::staticGet('uri', - $activity->context->replyToID); - if (!empty($orig)) { - $options['reply_to'] = $orig->id; - } - } - - $location = $activity->context->location; - - if ($location) { - $options['lat'] = $location->lat; - $options['lon'] = $location->lon; - if ($location->location_id) { - $options['location_ns'] = $location->location_ns; - $options['location_id'] = $location->location_id; - } - } - } - - // Atom categories <-> hashtags - - foreach ($activity->categories as $cat) { - if ($cat->term) { - $term = common_canonical_tag($cat->term); - if ($term) { - $options['tags'][] = $term; - } - } - } - - // Atom enclosures -> attachment URLs - foreach ($activity->enclosures as $href) { - // @fixme save these locally or....? - $options['urls'][] = $href; - } - - $saved = Notice::saveNew($user->id, - $content, - 'restore', // TODO: restore the actual source - $options); - - return $saved; - } - - function filterAttention($attn) - { - $groups = array(); - $replies = array(); - - foreach (array_unique($attn) as $recipient) { - - // Is the recipient a local user? - - $user = User::staticGet('uri', $recipient); - - if ($user) { - // @fixme sender verification, spam etc? - $replies[] = $recipient; - continue; - } - - // Is the recipient a remote group? - $oprofile = Ostatus_profile::ensureProfileURI($recipient); - - if ($oprofile) { - if (!$oprofile->isGroup()) { - // may be canonicalized or something - $replies[] = $oprofile->uri; - } - continue; - } - - // Is the recipient a local group? - // @fixme uri on user_group isn't reliable yet - // $group = User_group::staticGet('uri', $recipient); - $id = OStatusPlugin::localGroupFromUrl($recipient); - - if ($id) { - $group = User_group::staticGet('id', $id); - if ($group) { - // Deliver to all members of this local group if allowed. - $profile = $sender->localProfile(); - if ($profile->isMember($group)) { - $groups[] = $group->id; - } else { - common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member"); - } - continue; - } else { - common_log(LOG_INFO, "Skipping reply to bogus group $recipient"); - } - } - } - - return array($groups, $replies); - } - - function userFromSubject($subject) - { - $user = User::staticGet('uri', $subject->id); + $user = User::staticGet('uri', $author->id); if (empty($user)) { $attrs = - array('nickname' => Ostatus_profile::getActivityObjectNickname($subject), - 'uri' => $subject->id); + array('nickname' => Ostatus_profile::getActivityObjectNickname($author), + 'uri' => $author->id); $user = User::register($attrs); } $profile = $user->getProfile(); - Ostatus_profile::updateProfile($profile, $subject); + Ostatus_profile::updateProfile($profile, $author); // FIXME: Update avatar return $user; } - - function purify($content) - { - $config = array('safe' => 1, - 'deny_attribute' => 'id,style,on*'); - return htmLawed($content, $config); - } } diff --git a/lib/queuemanager.php b/lib/queuemanager.php index 0829c8a8bc..65a972e234 100644 --- a/lib/queuemanager.php +++ b/lib/queuemanager.php @@ -266,6 +266,8 @@ abstract class QueueManager extends IoManager // Background user management tasks... $this->connect('deluser', 'DelUserQueueHandler'); + $this->connect('feedimp', 'FeedImporter'); + $this->connect('actimp', 'ActivityImporter'); // Broadcasting profile updates to OMB remote subscribers $this->connect('profile', 'ProfileQueueHandler'); From 4b41d05a13cc8d49871687b767640fbcd15eb05c Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 16:27:20 -0500 Subject: [PATCH 12/29] Make restoreuser use new FeedImporter queue handler --- lib/activityimporter.php | 9 +++++++-- lib/feedimporter.php | 36 +++++++++++++++++++++++++++++++++--- scripts/restoreuser.php | 5 ++--- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/lib/activityimporter.php b/lib/activityimporter.php index 07a6b0e77b..e936449c86 100644 --- a/lib/activityimporter.php +++ b/lib/activityimporter.php @@ -81,10 +81,10 @@ class ActivityImporter extends QueueHandler common_log(LOG_WARNING, $ce->getMessage()); return true; } catch (ServerException $se) { - common_log(LOG_ERR, $ce->getMessage()); + common_log(LOG_ERR, $se->getMessage()); return false; } catch (Exception $e) { - common_log(LOG_ERR, $ce->getMessage()); + common_log(LOG_ERR, $e->getMessage()); return false; } return true; @@ -95,6 +95,11 @@ class ActivityImporter extends QueueHandler $profile = $user->getProfile(); if ($activity->objects[0]->id == $author->id) { + + if (!$this->trusted) { + throw new ClientException(_("Can't force subscription for untrusted user.")); + } + $other = $activity->actor; $otherUser = User::staticGet('uri', $other->id); diff --git a/lib/feedimporter.php b/lib/feedimporter.php index e2c9df72fd..0b94eeb9b8 100644 --- a/lib/feedimporter.php +++ b/lib/feedimporter.php @@ -84,12 +84,12 @@ class FeedImporter extends QueueHandler if (empty($user)) { if ($trusted) { $user = $this->userFromAuthor($author); + } else { + throw new ClientException(_("Can't import without a user.")); } } - $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - $activities = $this->entriesToActivities($entries, $feed); + $activities = $this->getActivities($feed); $qm = QueueManager::get(); @@ -108,6 +108,36 @@ class FeedImporter extends QueueHandler } } + function getActivities($feed) + { + $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); + + $activities = array(); + + for ($i = 0; $i < $entries->length; $i++) { + $activities[] = new Activity($entries->item($i)); + } + + usort($activities, array("FeedImporter", "activitySort")); + + return $activities; + } + + /** + * Sort activities oldest-first + */ + + static function activitySort($a, $b) + { + if ($a->time == $b->time) { + return 0; + } else if ($a->time < $b->time) { + return -1; + } else { + return 1; + } + } + function userFromAuthor($author) { $user = User::staticGet('uri', $author->id); diff --git a/scripts/restoreuser.php b/scripts/restoreuser.php index eac7e5cf29..17f007b412 100644 --- a/scripts/restoreuser.php +++ b/scripts/restoreuser.php @@ -75,9 +75,8 @@ try { $user = null; } $xml = getActivityStreamDocument(); - $restorer = new AccountRestorer(); - $doc = $restorer->loadXML($xml); - $restorer->importActivityStream($user, $doc); + $qm = QueueManager::get(); + $qm->enqueue(array($user, $xml, true), 'feedimp'); } catch (Exception $e) { print $e->getMessage()."\n"; exit(1); From 1a81356622dba7bcbf75de6eb10f28170e972b96 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 17:37:43 -0500 Subject: [PATCH 13/29] I'm still not sure when it's useful to reset a notice's author --- lib/activityimporter.php | 41 +++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/lib/activityimporter.php b/lib/activityimporter.php index e936449c86..28c371e4dc 100644 --- a/lib/activityimporter.php +++ b/lib/activityimporter.php @@ -72,7 +72,7 @@ class ActivityImporter extends QueueHandler $this->joinGroup($user, $activity); break; case ActivityVerb::POST: - $this->postNote($user, $activity); + $this->postNote($user, $author, $activity); break; default: throw new Exception("Unknown verb: {$activity->verb}"); @@ -141,13 +141,17 @@ class ActivityImporter extends QueueHandler if (empty($group)) { $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); if (!$oprofile->isGroup()) { - throw new Exception("Remote profile is not a group!"); + throw new ClientException("Remote profile is not a group!"); } $group = $oprofile->localGroup(); } assert(!empty($group)); + if ($user->isMember($group)) { + throw new ClientException("User is already a member of this group."); + } + if (Event::handle('StartJoinGroup', array($group, $user))) { Group_member::join($group->id, $user->id); Event::handle('EndJoinGroup', array($group, $user)); @@ -156,7 +160,7 @@ class ActivityImporter extends QueueHandler // XXX: largely cadged from Ostatus_profile::processNote() - function postNote($user, $activity) + function postNote($user, $author, $activity) { $note = $activity->objects[0]; @@ -165,11 +169,27 @@ class ActivityImporter extends QueueHandler $notice = Notice::staticGet('uri', $sourceUri); if (!empty($notice)) { - // This is weird. - $orig = clone($notice); - $notice->profile_id = $user->id; - $notice->update($orig); - return; + + common_log(LOG_INFO, "Notice {$sourceUri} already exists."); + + if ($this->trusted) { + + $profile = $notice->getProfile(); + + $uri = $profile->getUri(); + + if ($uri == $author->id) { + common_log(LOG_INFO, "Updating notice author from $author->id to $user->uri"); + $orig = clone($notice); + $notice->profile_id = $user->id; + $notice->update($orig); + return; + } else { + throw new ClientException(sprintf(_("Already know about notice %s and ". + " it's got a different author %s."), + $sourceUri, $uri)); + } + } } // Use summary as fallback for content @@ -199,7 +219,8 @@ class ActivityImporter extends QueueHandler 'replies' => array(), 'groups' => array(), 'tags' => array(), - 'urls' => array()); + 'urls' => array(), + 'distribute' => false); // Check for optional attributes... @@ -251,6 +272,8 @@ class ActivityImporter extends QueueHandler $options['urls'][] = $href; } + common_log(LOG_INFO, "Saving notice {$options['uri']}"); + $saved = Notice::saveNew($user->id, $content, 'restore', // TODO: restore the actual source From 120802b807145db6419f964f3c5fecf49ea7411d Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 18:55:00 -0500 Subject: [PATCH 14/29] change code order to make shorter lines --- lib/feedimporter.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/feedimporter.php b/lib/feedimporter.php index 0b94eeb9b8..e46858cc51 100644 --- a/lib/feedimporter.php +++ b/lib/feedimporter.php @@ -68,12 +68,13 @@ class FeedImporter extends QueueHandler try { $doc = DOMDocument::loadXML($xml); - if ($doc->documentElement->namespaceURI != Activity::ATOM || - $doc->documentElement->localName != 'feed') { + $feed = $doc->documentElement; + + if ($feed->namespaceURI != Activity::ATOM || + $feed->localName != 'feed') { throw new ClientException(_("Not an atom feed.")); } - $feed = $doc->documentElement; $author = ActivityUtils::getFeedAuthor($feed); From 1d6091cad20c0d5a7a31263032431ac13854a5b8 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 18:56:17 -0500 Subject: [PATCH 15/29] Two bug fixes in activityimporter --- lib/activityimporter.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/activityimporter.php b/lib/activityimporter.php index 28c371e4dc..4a76781328 100644 --- a/lib/activityimporter.php +++ b/lib/activityimporter.php @@ -189,6 +189,8 @@ class ActivityImporter extends QueueHandler " it's got a different author %s."), $sourceUri, $uri)); } + } else { + throw new ClientException("Not overwriting author info for non-trusted user."); } } @@ -338,8 +340,11 @@ class ActivityImporter extends QueueHandler function purify($content) { + require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php'; + $config = array('safe' => 1, 'deny_attribute' => 'id,style,on*'); + return htmLawed($content, $config); } } From 573bbeced10f06951db8875db8b4f9f0d0deca41 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 17 Dec 2010 18:56:48 -0500 Subject: [PATCH 16/29] action to restore a user's backup from the Web interface --- actions/profilesettings.php | 7 + actions/restoreaccount.php | 359 ++++++++++++++++++++++++++++++++++++ lib/router.php | 1 + 3 files changed, 367 insertions(+) create mode 100644 actions/restoreaccount.php diff --git a/actions/profilesettings.php b/actions/profilesettings.php index 0226e1dd45..8f55a47189 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -472,6 +472,13 @@ class ProfilesettingsAction extends AccountSettingsAction _('Delete account')); $this->elementEnd('li'); } + if ($user->hasRight(Right::RESTOREACCOUNT)) { + $this->elementStart('li'); + $this->element('a', + array('href' => common_local_url('restoreaccount')), + _('Restore account')); + $this->elementEnd('li'); + } $this->elementEnd('div'); } } diff --git a/actions/restoreaccount.php b/actions/restoreaccount.php new file mode 100644 index 0000000000..c33756d48f --- /dev/null +++ b/actions/restoreaccount.php @@ -0,0 +1,359 @@ +. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +/** + * Restore a backup of your own account from the browser + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class RestoreaccountAction extends Action +{ + private $success = false; + + /** + * Returns the title of the page + * + * @return string page title + */ + + function title() + { + return _("Restore account"); + } + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $cur = common_current_user(); + + if (empty($cur)) { + throw new ClientException(_('Only logged-in users can restore their account.'), 403); + } + + if (!$cur->hasRight(Right::RESTOREACCOUNT)) { + throw new ClientException(_('You may not restore your account.'), 403); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($args); + + if ($this->isPost()) { + $this->restoreAccount(); + } else { + $this->showPage(); + } + return; + } + + /** + * Queue a file for restoration + * + * Uses the UserActivityStream class; may take a long time! + * + * @return void + */ + + function restoreAccount() + { + $this->checkSessionToken(); + + if (!isset($_FILES['restorefile']['error'])) { + throw new ClientException(_('No uploaded file.')); + } + + switch ($_FILES['restorefile']['error']) { + case UPLOAD_ERR_OK: // success, jump out + break; + case UPLOAD_ERR_INI_SIZE: + // TRANS: Client exception thrown when an uploaded file is larger than set in php.ini. + throw new ClientException(_('The uploaded file exceeds the ' . + 'upload_max_filesize directive in php.ini.')); + return; + case UPLOAD_ERR_FORM_SIZE: + throw new ClientException( + // TRANS: Client exception. + _('The uploaded file exceeds the MAX_FILE_SIZE directive' . + ' that was specified in the HTML form.')); + return; + case UPLOAD_ERR_PARTIAL: + @unlink($_FILES['restorefile']['tmp_name']); + // TRANS: Client exception. + throw new ClientException(_('The uploaded file was only' . + ' partially uploaded.')); + return; + case UPLOAD_ERR_NO_FILE: + // No file; probably just a non-AJAX submission. + return; + case UPLOAD_ERR_NO_TMP_DIR: + // TRANS: Client exception thrown when a temporary folder is not present to store a file upload. + throw new ClientException(_('Missing a temporary folder.')); + return; + case UPLOAD_ERR_CANT_WRITE: + // TRANS: Client exception thrown when writing to disk is not possible during a file upload operation. + throw new ClientException(_('Failed to write file to disk.')); + return; + case UPLOAD_ERR_EXTENSION: + // TRANS: Client exception thrown when a file upload operation has been stopped by an extension. + throw new ClientException(_('File upload stopped by extension.')); + return; + default: + common_log(LOG_ERR, __METHOD__ . ": Unknown upload error " . + $_FILES['restorefile']['error']); + // TRANS: Client exception thrown when a file upload operation has failed with an unknown reason. + throw new ClientException(_('System error uploading file.')); + return; + } + + $filename = $_FILES['restorefile']['tmp_name']; + + try { + if (!file_exists($filename)) { + throw new ServerException("No such file '$filename'."); + } + + if (!is_file($filename)) { + throw new ServerException("Not a regular file: '$filename'."); + } + + if (!is_readable($filename)) { + throw new ServerException("File '$filename' not readable."); + } + + common_debug(sprintf(_("Getting backup from file '%s'."), $filename)); + + $xml = file_get_contents($filename); + + // This check is costly but we should probably give + // the user some info ahead of time. + + $doc = DOMDocument::loadXML($xml); + + $feed = $doc->documentElement; + + if ($feed->namespaceURI != Activity::ATOM || + $feed->localName != 'feed') { + throw new ClientException(_("Not an atom feed.")); + } + + // Enqueue for processing. + + $qm = QueueManager::get(); + $qm->enqueue(array(common_current_user(), $xml, false), 'feedimp'); + + $this->success = true; + + $this->showPage(); + + } catch (Exception $e) { + // Delete the file and re-throw + @unlink($_FILES['restorefile']['tmp_name']); + throw $e; + } + } + + /** + * Show a little form so that the person can upload a file to restore + * + * @return void + */ + + function showContent() + { + if ($this->success) { + $this->element('p', null, + _('Feed will be restored. Please wait a few minutes for results.')); + } else { + $form = new RestoreAccountForm($this); + $form->show(); + } + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + return false; + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + + function lastModified() + { + // For comparison with If-Last-Modified + // If not applicable, return null + return null; + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + return null; + } +} + +/** + * A form for backing up the account. + * + * @category Account + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class RestoreAccountForm extends Form +{ + function __construct($out=null) { + parent::__construct($out); + $this->enctype = 'multipart/form-data'; + } + + /** + * Class of the form. + * + * @return string the form's class + */ + + function formClass() + { + return 'form_profile_restore'; + } + + /** + * URL the form posts to + * + * @return string the form's action URL + */ + + function action() + { + return common_local_url('restoreaccount'); + } + + /** + * Output form data + * + * Really, just instructions for doing a backup. + * + * @return void + */ + + function formData() + { + $this->out->elementStart('p', 'instructions'); + + $this->out->raw(_('You can upload a backed-up stream in '. + 'Activity Streams format.')); + + $this->out->elementEnd('p'); + + $this->out->elementStart('ul', 'form_data'); + + $this->out->elementStart('li', array ('id' => 'settings_attach')); + $this->out->element('input', array('name' => 'restorefile', + 'type' => 'file', + 'id' => 'restorefile')); + $this->out->elementEnd('li'); + + $this->out->elementEnd('ul'); + } + + /** + * Buttons for the form + * + * In this case, a single submit button + * + * @return void + */ + + function formActions() + { + $this->out->submit('submit', + _m('BUTTON', 'Upload'), + 'submit', + null, + _('Upload the file')); + } +} diff --git a/lib/router.php b/lib/router.php index 90bc9fa352..49a16dffe3 100644 --- a/lib/router.php +++ b/lib/router.php @@ -201,6 +201,7 @@ class Router 'version', 'backupaccount', 'deleteaccount', + 'restoreaccount', ); foreach ($main as $a) { From 24f9a991b6ac8edfc7936b7adfcb7dd5955c66a3 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 20 Dec 2010 13:26:57 -0500 Subject: [PATCH 17/29] Let activity objects write directly to activity's own outputter --- lib/activity.php | 33 +++---------------------- lib/activityobject.php | 56 +++++++++++++++++++++++++++--------------- 2 files changed, 40 insertions(+), 49 deletions(-) diff --git a/lib/activity.php b/lib/activity.php index c3a984a7b9..3458c76b9a 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -349,32 +349,7 @@ class Activity if ($this->verb == ActivityVerb::POST && count($this->objects) == 1) { $obj = $this->objects[0]; - - $xs->element('id', null, $obj->id); - $xs->element('activity:object-type', null, $obj->type); - - if (!empty($obj->title)) { - $xs->element('title', null, $obj->title); - } else { - // XXX need a better default title - $xs->element('title', null, _('Post')); - } - - if (!empty($obj->content)) { - $xs->element('content', array('type' => 'html'), $obj->content); - } - - if (!empty($obj->summary)) { - $xs->element('summary', null, $obj->summary); - } - - if (!empty($obj->link)) { - $xs->element('link', array('rel' => 'alternate', - 'type' => 'text/html'), - $obj->link); - } - - // XXX: some object types might have other values here. + $obj->outputTo($xs, null); } else { $xs->element('id', null, $this->id); @@ -408,12 +383,12 @@ class Activity $xs->element('name', array(), $this->actor->title); } $xs->elementEnd('author'); - $xs->raw($this->actor->asString('activity:actor')); + $this->actor->outputTo($xs, 'activity:actor'); } if ($this->verb != ActivityVerb::POST || count($this->objects) != 1) { foreach($this->objects as $object) { - $xs->raw($object->asString()); + $object->outputTo($xs, 'activity:object'); } } @@ -467,7 +442,7 @@ class Activity } if ($this->target) { - $xs->raw($this->target->asString('activity:target')); + $this->target->outputTo($xs, 'activity:target'); } foreach ($this->categories as $cat) { diff --git a/lib/activityobject.php b/lib/activityobject.php index 536e021334..61614935f1 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -415,10 +415,10 @@ class ActivityObject } $sizes = array( - AVATAR_PROFILE_SIZE, - AVATAR_STREAM_SIZE, - AVATAR_MINI_SIZE - ); + AVATAR_PROFILE_SIZE, + AVATAR_STREAM_SIZE, + AVATAR_MINI_SIZE + ); foreach ($sizes as $size) { $alink = null; @@ -488,19 +488,19 @@ class ActivityObject return $object; } + + function outputTo($xo, $tag='activity:object') + { + if (!empty($tag)) { + $xo->elementStart($tag); + } - function asString($tag='activity:object') - { - $xs = new XMLStringer(true); + $xo->element('activity:object-type', null, $this->type); - $xs->elementStart($tag); - - $xs->element('activity:object-type', null, $this->type); - - $xs->element(self::ID, null, $this->id); + $xo->element(self::ID, null, $this->id); if (!empty($this->title)) { - $xs->element( + $xo->element( self::TITLE, null, common_xml_safe_str($this->title) @@ -508,7 +508,7 @@ class ActivityObject } if (!empty($this->summary)) { - $xs->element( + $xo->element( self::SUMMARY, null, common_xml_safe_str($this->summary) @@ -517,7 +517,7 @@ class ActivityObject if (!empty($this->content)) { // XXX: assuming HTML content here - $xs->element( + $xo->element( ActivityUtils::CONTENT, array('type' => 'html'), common_xml_safe_str($this->content) @@ -525,7 +525,7 @@ class ActivityObject } if (!empty($this->link)) { - $xs->element( + $xo->element( 'link', array( 'rel' => 'alternate', @@ -540,7 +540,7 @@ class ActivityObject || $this->type == ActivityObject::GROUP) { foreach ($this->avatarLinks as $avatar) { - $xs->element( + $xo->element( 'link', array( 'rel' => 'avatar', 'type' => $avatar->type, @@ -554,7 +554,7 @@ class ActivityObject } if (!empty($this->geopoint)) { - $xs->element( + $xo->element( 'georss:point', null, $this->geopoint @@ -562,10 +562,26 @@ class ActivityObject } if (!empty($this->poco)) { - $xs->raw($this->poco->asString()); + $xo->raw($this->poco->asString()); } - $xs->elementEnd($tag); + foreach ($this->extra as $el) { + list($extraTag, $attrs, $content) = $el; + $xo->element($extraTag, $attrs, $content); + } + + if (!empty($tag)) { + $xo->elementEnd($tag); + } + + return; + } + + function asString($tag='activity:object') + { + $xs = new XMLStringer(true); + + $this->outputTo($xs, $tag); return $xs->getString(); } From c71d701a3f32472bb71ae38d21a4dd16ea1dcb97 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 20 Dec 2010 10:46:23 -0800 Subject: [PATCH 18/29] Logging helper for bogus hmacs on PuSH in -- record the url & hub with the err msg to help tell what broke --- plugins/OStatus/classes/FeedSub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/OStatus/classes/FeedSub.php b/plugins/OStatus/classes/FeedSub.php index 140f323846..b34d7cd854 100644 --- a/plugins/OStatus/classes/FeedSub.php +++ b/plugins/OStatus/classes/FeedSub.php @@ -487,7 +487,7 @@ class FeedSub extends Memcached_DataObject if ($their_hmac === $our_hmac) { return true; } - common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac"); + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac for feed $this->uri on $this->huburi"); } else { common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'"); } From dd48bdb1c41986b68f8c855728a9285bb22c916e Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 20 Dec 2010 10:46:23 -0800 Subject: [PATCH 19/29] Logging helper for bogus hmacs on PuSH in -- record the url & hub with the err msg to help tell what broke --- plugins/OStatus/classes/FeedSub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/OStatus/classes/FeedSub.php b/plugins/OStatus/classes/FeedSub.php index 140f323846..b34d7cd854 100644 --- a/plugins/OStatus/classes/FeedSub.php +++ b/plugins/OStatus/classes/FeedSub.php @@ -487,7 +487,7 @@ class FeedSub extends Memcached_DataObject if ($their_hmac === $our_hmac) { return true; } - common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac"); + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac for feed $this->uri on $this->huburi"); } else { common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'"); } From a4e2f3835643c1d4b6d228151de2f863c02cb63c Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 20 Dec 2010 13:05:17 -0800 Subject: [PATCH 20/29] Slightly fancier debug code for PuSH hmac mismatches -- save the post to a temp file if feedsub/debug is on in config. --- plugins/OStatus/classes/FeedSub.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugins/OStatus/classes/FeedSub.php b/plugins/OStatus/classes/FeedSub.php index b34d7cd854..97245203d5 100644 --- a/plugins/OStatus/classes/FeedSub.php +++ b/plugins/OStatus/classes/FeedSub.php @@ -483,11 +483,19 @@ class FeedSub extends Memcached_DataObject if ($this->secret) { if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { $their_hmac = strtolower($matches[1]); - $our_hmac = hash_hmac('sha1', $post, $this->secret); + $our_hmac = hash_hmac('sha1', $post, $this->secret) . 'x'; if ($their_hmac === $our_hmac) { return true; } - common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac for feed $this->uri on $this->huburi"); + if (common_config('feedsub', 'debug')) { + $tempfile = tempnam(sys_get_temp_dir(), 'feedsub-receive'); + if ($tempfile) { + file_put_contents($tempfile, $post); + } + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac for feed $this->uri on $this->huburi; saved to $tempfile"); + } else { + common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac for feed $this->uri on $this->huburi"); + } } else { common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'"); } From 46123e37543be4ea784e90528176fd205bfece49 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Mon, 20 Dec 2010 13:06:58 -0800 Subject: [PATCH 21/29] *cough* don't commit the code that breaks your code that you used to test the debug code :D --- plugins/OStatus/classes/FeedSub.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/OStatus/classes/FeedSub.php b/plugins/OStatus/classes/FeedSub.php index 97245203d5..7756f6a234 100644 --- a/plugins/OStatus/classes/FeedSub.php +++ b/plugins/OStatus/classes/FeedSub.php @@ -483,7 +483,7 @@ class FeedSub extends Memcached_DataObject if ($this->secret) { if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { $their_hmac = strtolower($matches[1]); - $our_hmac = hash_hmac('sha1', $post, $this->secret) . 'x'; + $our_hmac = hash_hmac('sha1', $post, $this->secret); if ($their_hmac === $our_hmac) { return true; } From c5ab7026dd8f463e460e20b4b813fd0f7f51dcb2 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 10:17:22 -0800 Subject: [PATCH 22/29] Partial fix for ticket #2964: bad .po files http://translatewiki.net/wiki/StatusNet:08b929b29496be84ff75d266b5e60b426cff449f-Hey,_1$s._Someone_just_entered/ca --- locale/ca/LC_MESSAGES/statusnet.po | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/locale/ca/LC_MESSAGES/statusnet.po b/locale/ca/LC_MESSAGES/statusnet.po index 93e150107f..cb4381819e 100644 --- a/locale/ca/LC_MESSAGES/statusnet.po +++ b/locale/ca/LC_MESSAGES/statusnet.po @@ -7927,7 +7927,7 @@ msgstr "" "Si no, simplement ignoreu el missatge.\n" "\n" "GrĂ cies pel vostre temps, \n" -"%2$s" +"%2$s\n" #. TRANS: Subject of new-subscriber notification e-mail. #. TRANS: %1$s is the subscribing user's nickname, %2$s is the StatusNet sitename. From a16131526b5ae77c558fe6b11d1726af39d7c82d Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 10:20:47 -0800 Subject: [PATCH 23/29] Partial fix for ticket #2964: bad .po file entries http://translatewiki.net/wiki/StatusNet:Cb5746be52330331844dea750bf452c0618aecb3-All_Rights_Reserved/pt-br --- locale/pt_BR/LC_MESSAGES/statusnet.po | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/locale/pt_BR/LC_MESSAGES/statusnet.po b/locale/pt_BR/LC_MESSAGES/statusnet.po index 37412a9355..20a0666b35 100644 --- a/locale/pt_BR/LC_MESSAGES/statusnet.po +++ b/locale/pt_BR/LC_MESSAGES/statusnet.po @@ -3219,9 +3219,7 @@ msgstr "Particular" #: actions/licenseadminpanel.php:246 msgid "All Rights Reserved" -msgstr "" -"\n" -"Todos os Direitos Reservados" +msgstr "Todos os Direitos Reservados" #: actions/licenseadminpanel.php:247 msgid "Creative Commons" From 5abd2b7d0c5565ee842969cd5205c9b428efbb6c Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 11:06:45 -0800 Subject: [PATCH 24/29] fix notice error --- actions/backupaccount.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/backupaccount.php b/actions/backupaccount.php index 9454741f00..4f6fb936bd 100644 --- a/actions/backupaccount.php +++ b/actions/backupaccount.php @@ -97,7 +97,7 @@ class BackupaccountAction extends Action function handle($argarray=null) { - parent::handle($args); + parent::handle($argarray); if ($this->isPost()) { $this->sendFeed(); From 754bc1b6164b82109acd14d70b295291f40d07c7 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 11:13:57 -0800 Subject: [PATCH 25/29] Error handling cleanup on backup/restore: * avoid PHP notice from using wrong variable * show a visible error instead of blank screen if no file submitted with restore form * avoid PHP strict warning from using calling "non-static" DOMDocument::loadXML statically * suppress PHP warning from XML parse errors --- actions/restoreaccount.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/actions/restoreaccount.php b/actions/restoreaccount.php index c33756d48f..5c8e4a12c1 100644 --- a/actions/restoreaccount.php +++ b/actions/restoreaccount.php @@ -95,7 +95,7 @@ class RestoreaccountAction extends Action function handle($argarray=null) { - parent::handle($args); + parent::handle($argarray); if ($this->isPost()) { $this->restoreAccount(); @@ -143,6 +143,7 @@ class RestoreaccountAction extends Action return; case UPLOAD_ERR_NO_FILE: // No file; probably just a non-AJAX submission. + throw new ClientException(_('No uploaded file.')); return; case UPLOAD_ERR_NO_TMP_DIR: // TRANS: Client exception thrown when a temporary folder is not present to store a file upload. @@ -185,12 +186,19 @@ class RestoreaccountAction extends Action // This check is costly but we should probably give // the user some info ahead of time. + $doc = new DOMDocument(); - $doc = DOMDocument::loadXML($xml); + // Disable PHP warnings so we don't spew low-level XML errors to output... + // would be nice if we can just get exceptions instead. + $old_err = error_reporting(); + error_reporting($old_err & ~E_WARNING); + $doc->loadXML($xml); + error_reporting($old_err); $feed = $doc->documentElement; - if ($feed->namespaceURI != Activity::ATOM || + if (!$feed || + $feed->namespaceURI != Activity::ATOM || $feed->localName != 'feed') { throw new ClientException(_("Not an atom feed.")); } From 5fe83011299ac930def8e1db5f9eeae782ddb14c Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 22 Dec 2010 11:25:47 -0800 Subject: [PATCH 26/29] disable account deletion by default --- lib/default.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/default.php b/lib/default.php index 7a44ed875d..6d57c4ef02 100644 --- a/lib/default.php +++ b/lib/default.php @@ -126,7 +126,7 @@ $default = 'biolimit' => null, 'backup' => true, 'restore' => true, - 'delete' => true, + 'delete' => false, 'move' => true), 'avatar' => array('server' => null, From 3e82000d578cf5f5935d972a26c84fe31768460a Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 22 Dec 2010 12:02:50 -0800 Subject: [PATCH 27/29] initialize ActivityObject::$extra --- lib/activityobject.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/activityobject.php b/lib/activityobject.php index 61614935f1..fc7da7ee8b 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -106,6 +106,10 @@ class ActivityObject public $largerImage; public $description; + // Extra stuff, that may need to be serialized + + public $extra = array(); + /** * Constructor * From 448dfb69d433c2e244b32ef1ba0af48b6d91ca23 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 12:03:05 -0800 Subject: [PATCH 28/29] Initialize $extra member to empty array on ActivityObject --- lib/activityobject.php | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/activityobject.php b/lib/activityobject.php index 61614935f1..5185d77610 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -105,6 +105,7 @@ class ActivityObject public $thumbnail; public $largerImage; public $description; + public $extra = array(); /** * Constructor From 35d9a065fb0f1ada1a96034d2e5a4076420d4e8a Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 22 Dec 2010 12:07:13 -0800 Subject: [PATCH 29/29] Revert "initialize ActivityObject::$extra" This reverts commit 3e82000d578cf5f5935d972a26c84fe31768460a. --- lib/activityobject.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/activityobject.php b/lib/activityobject.php index b86e6a9846..5185d77610 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -107,10 +107,6 @@ class ActivityObject public $description; public $extra = array(); - // Extra stuff, that may need to be serialized - - public $extra = array(); - /** * Constructor *