From 7c7b91e61ad273023d774617f23fa1429f535b22 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 16:28:02 -0500 Subject: [PATCH 01/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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/26] 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 5abd2b7d0c5565ee842969cd5205c9b428efbb6c Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 11:06:45 -0800 Subject: [PATCH 17/26] 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 18/26] 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 * From 464e0f8115e3b5b01b6110ddc7a73274164c8584 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 13:56:19 -0800 Subject: [PATCH 23/26] Don't trust text/xml mime types; generic content detection gives useless stuff like that on SVG images! Todo: replace the extension check in this case with better content-based checks. --- lib/mediafile.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/mediafile.php b/lib/mediafile.php index a41d7c76b5..caa902de5d 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -362,7 +362,9 @@ class MediaFile // we'll try detecting a type from its extension... $unclearTypes = array('application/octet-stream', 'application/vnd.ms-office', - 'application/zip'); + 'application/zip', + // TODO: for XML we could do better content-based sniffing too + 'text/xml'); if ($originalFilename && (!$filetype || in_array($filetype, $unclearTypes))) { $type = $mte->getMIMEType($originalFilename); From d5c2b0d21695f2ae661fa7ef4bbfbe9db0f66925 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 14:55:13 -0800 Subject: [PATCH 24/26] When queueing is off, restore runs immediately. Indicate that we've already finished processing on the success page in this case; otherwise continue to show the 'will take a few minutes' message. --- actions/restoreaccount.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/actions/restoreaccount.php b/actions/restoreaccount.php index 5c8e4a12c1..8cf220a424 100644 --- a/actions/restoreaccount.php +++ b/actions/restoreaccount.php @@ -48,6 +48,7 @@ if (!defined('STATUSNET')) { class RestoreaccountAction extends Action { private $success = false; + private $inprogress = false; /** * Returns the title of the page @@ -208,8 +209,13 @@ class RestoreaccountAction extends Action $qm = QueueManager::get(); $qm->enqueue(array(common_current_user(), $xml, false), 'feedimp'); - $this->success = true; - + if ($qm instanceof UnQueueManager) { + // No active queuing means we've actually just completed the job! + $this->success = true; + } else { + // We've fed data into background queues, and it's probably still running. + $this->inprogress = true; + } $this->showPage(); } catch (Exception $e) { @@ -228,6 +234,9 @@ class RestoreaccountAction extends Action function showContent() { if ($this->success) { + $this->element('p', null, + _('Feed has been restored. Your old posts should now appear in search and your profile page.')); + } else if ($this->inprogress) { $this->element('p', null, _('Feed will be restored. Please wait a few minutes for results.')); } else { From 8babcc2ad21f70745ce261476ffe74b42603419a Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 15:04:50 -0800 Subject: [PATCH 25/26] Makefile to compress LinkPreview's js --- plugins/LinkPreview/Makefile | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 plugins/LinkPreview/Makefile diff --git a/plugins/LinkPreview/Makefile b/plugins/LinkPreview/Makefile new file mode 100644 index 0000000000..6c8a03e94b --- /dev/null +++ b/plugins/LinkPreview/Makefile @@ -0,0 +1,11 @@ +.fake: all clean + +TARGETS=linkpreview.min.js + +all: $(TARGETS) + +clean: + rm -f $(TARGETS) + +linkpreview.min.js: linkpreview.js + yui-compressor $< -o $@ From e0606d3eca761bda5fb804f6522028280d3396e8 Mon Sep 17 00:00:00 2001 From: Brion Vibber Date: Wed, 22 Dec 2010 15:20:07 -0800 Subject: [PATCH 26/26] Break xbImportNode.js and geometa.js back out of util.js; the Makefile in js has been updated to combine them with util.js source when building util.min.js Revert "combine our standard scripts into one big script" This reverts parts of commit 0c5ca46ba3a070803d993b0244fcc69d33875ebd. --- js/Makefile | 5 +- js/geometa.js | 217 ++++++++++++++++++++++++++++++++++++ js/util.js | 269 --------------------------------------------- js/xbImportNode.js | 47 ++++++++ 4 files changed, 267 insertions(+), 271 deletions(-) create mode 100644 js/geometa.js create mode 100644 js/xbImportNode.js diff --git a/js/Makefile b/js/Makefile index 00e4347141..e7ae44e421 100644 --- a/js/Makefile +++ b/js/Makefile @@ -1,11 +1,12 @@ .fake: all clean TARGETS=util.min.js +SOURCES=util.js xbImportNode.js geometa.js all: $(TARGETS) clean: rm -f $(TARGETS) -util.min.js: util.js - yui-compressor $< -o $@ +util.min.js: $(SOURCES) + cat $+ | yui-compressor --type js > $@ diff --git a/js/geometa.js b/js/geometa.js new file mode 100644 index 0000000000..bba59b4486 --- /dev/null +++ b/js/geometa.js @@ -0,0 +1,217 @@ +// A shim to implement the W3C Geolocation API Specification using Gears or the Ajax API +if (typeof navigator.geolocation == "undefined" || navigator.geolocation.shim ) { (function(){ + +// -- BEGIN GEARS_INIT +(function() { + // We are already defined. Hooray! + if (window.google && google.gears) { + return; + } + + var factory = null; + + // Firefox + if (typeof GearsFactory != 'undefined') { + factory = new GearsFactory(); + } else { + // IE + try { + factory = new ActiveXObject('Gears.Factory'); + // privateSetGlobalObject is only required and supported on WinCE. + if (factory.getBuildInfo().indexOf('ie_mobile') != -1) { + factory.privateSetGlobalObject(this); + } + } catch (e) { + // Safari + if ((typeof navigator.mimeTypes != 'undefined') && navigator.mimeTypes["application/x-googlegears"]) { + factory = document.createElement("object"); + factory.style.display = "none"; + factory.width = 0; + factory.height = 0; + factory.type = "application/x-googlegears"; + document.documentElement.appendChild(factory); + } + } + } + + // *Do not* define any objects if Gears is not installed. This mimics the + // behavior of Gears defining the objects in the future. + if (!factory) { + return; + } + + // Now set up the objects, being careful not to overwrite anything. + // + // Note: In Internet Explorer for Windows Mobile, you can't add properties to + // the window object. However, global objects are automatically added as + // properties of the window object in all browsers. + if (!window.google) { + google = {}; + } + + if (!google.gears) { + google.gears = {factory: factory}; + } +})(); +// -- END GEARS_INIT + +var GearsGeoLocation = (function() { + // -- PRIVATE + var geo = google.gears.factory.create('beta.geolocation'); + + var wrapSuccess = function(callback, self) { // wrap it for lastPosition love + return function(position) { + callback(position); + self.lastPosition = position; + }; + }; + + // -- PUBLIC + return { + shim: true, + + type: "Gears", + + lastPosition: null, + + getCurrentPosition: function(successCallback, errorCallback, options) { + var self = this; + var sc = wrapSuccess(successCallback, self); + geo.getCurrentPosition(sc, errorCallback, options); + }, + + watchPosition: function(successCallback, errorCallback, options) { + geo.watchPosition(successCallback, errorCallback, options); + }, + + clearWatch: function(watchId) { + geo.clearWatch(watchId); + }, + + getPermission: function(siteName, imageUrl, extraMessage) { + geo.getPermission(siteName, imageUrl, extraMessage); + } + + }; +}); + +var AjaxGeoLocation = (function() { + // -- PRIVATE + var loading = false; + var loadGoogleLoader = function() { + if (!hasGoogleLoader() && !loading) { + loading = true; + var s = document.createElement('script'); + s.src = (document.location.protocol == "https:"?"https://":"http://") + 'www.google.com/jsapi?callback=_google_loader_apiLoaded'; + s.type = "text/javascript"; + document.getElementsByTagName('body')[0].appendChild(s); + } + }; + + var queue = []; + var addLocationQueue = function(callback) { + queue.push(callback); + }; + + var runLocationQueue = function() { + if (hasGoogleLoader()) { + while (queue.length > 0) { + var call = queue.pop(); + call(); + } + } + }; + + window['_google_loader_apiLoaded'] = function() { + runLocationQueue(); + }; + + var hasGoogleLoader = function() { + return (window['google'] && google['loader']); + }; + + var checkGoogleLoader = function(callback) { + if (hasGoogleLoader()) { return true; } + + addLocationQueue(callback); + + loadGoogleLoader(); + + return false; + }; + + loadGoogleLoader(); // start to load as soon as possible just in case + + // -- PUBLIC + return { + shim: true, + + type: "ClientLocation", + + lastPosition: null, + + getCurrentPosition: function(successCallback, errorCallback, options) { + var self = this; + if (!checkGoogleLoader(function() { + self.getCurrentPosition(successCallback, errorCallback, options); + })) { return; } + + if (google.loader.ClientLocation) { + var cl = google.loader.ClientLocation; + + var position = { + coords: { + latitude: cl.latitude, + longitude: cl.longitude, + altitude: null, + accuracy: 43000, // same as Gears accuracy over wifi? + altitudeAccuracy: null, + heading: null, + speed: null + }, + // extra info that is outside of the bounds of the core API + address: { + city: cl.address.city, + country: cl.address.country, + country_code: cl.address.country_code, + region: cl.address.region + }, + timestamp: new Date() + }; + + successCallback(position); + + this.lastPosition = position; + } else if (errorCallback === "function") { + errorCallback({ code: 3, message: "Using the Google ClientLocation API and it is not able to calculate a location."}); + } + }, + + watchPosition: function(successCallback, errorCallback, options) { + this.getCurrentPosition(successCallback, errorCallback, options); + + var self = this; + var watchId = setInterval(function() { + self.getCurrentPosition(successCallback, errorCallback, options); + }, 10000); + + return watchId; + }, + + clearWatch: function(watchId) { + clearInterval(watchId); + }, + + getPermission: function(siteName, imageUrl, extraMessage) { + // for now just say yes :) + return true; + } + + }; +}); + +// If you have Gears installed use that, else use Ajax ClientLocation +navigator.geolocation = (window.google && google.gears) ? GearsGeoLocation() : AjaxGeoLocation(); + +})(); +} diff --git a/js/util.js b/js/util.js index d929e91e2e..eace1778e2 100644 --- a/js/util.js +++ b/js/util.js @@ -1242,272 +1242,3 @@ $(document).ready(function(){ SN.Init.Login(); } }); - -// Formerly in xbImportNode.js -// @fixme put it back there -- since we're minifying we can concat in the makefile now - -/* is this stuff defined? */ -if (!document.ELEMENT_NODE) { - document.ELEMENT_NODE = 1; - document.ATTRIBUTE_NODE = 2; - document.TEXT_NODE = 3; - document.CDATA_SECTION_NODE = 4; - document.ENTITY_REFERENCE_NODE = 5; - document.ENTITY_NODE = 6; - document.PROCESSING_INSTRUCTION_NODE = 7; - document.COMMENT_NODE = 8; - document.DOCUMENT_NODE = 9; - document.DOCUMENT_TYPE_NODE = 10; - document.DOCUMENT_FRAGMENT_NODE = 11; - document.NOTATION_NODE = 12; -} - -document._importNode = function(node, allChildren) { - /* find the node type to import */ - switch (node.nodeType) { - case document.ELEMENT_NODE: - /* create a new element */ - var newNode = document.createElement(node.nodeName); - /* does the node have any attributes to add? */ - if (node.attributes && node.attributes.length > 0) - /* add all of the attributes */ - for (var i = 0, il = node.attributes.length; i < il;) { - if (node.attributes[i].nodeName == 'class') { - newNode.className = node.getAttribute(node.attributes[i++].nodeName); - } else { - newNode.setAttribute(node.attributes[i].nodeName, node.getAttribute(node.attributes[i++].nodeName)); - } - } - /* are we going after children too, and does the node have any? */ - if (allChildren && node.childNodes && node.childNodes.length > 0) - /* recursively get all of the child nodes */ - for (var i = 0, il = node.childNodes.length; i < il;) - newNode.appendChild(document._importNode(node.childNodes[i++], allChildren)); - return newNode; - break; - case document.TEXT_NODE: - case document.CDATA_SECTION_NODE: - case document.COMMENT_NODE: - return document.createTextNode(node.nodeValue); - break; - } -}; - -// @fixme put this next bit back too -- since we're minifying we can concat in the makefile now -// A shim to implement the W3C Geolocation API Specification using Gears or the Ajax API -if (typeof navigator.geolocation == "undefined" || navigator.geolocation.shim ) { (function(){ - -// -- BEGIN GEARS_INIT -(function() { - // We are already defined. Hooray! - if (window.google && google.gears) { - return; - } - - var factory = null; - - // Firefox - if (typeof GearsFactory != 'undefined') { - factory = new GearsFactory(); - } else { - // IE - try { - factory = new ActiveXObject('Gears.Factory'); - // privateSetGlobalObject is only required and supported on WinCE. - if (factory.getBuildInfo().indexOf('ie_mobile') != -1) { - factory.privateSetGlobalObject(this); - } - } catch (e) { - // Safari - if ((typeof navigator.mimeTypes != 'undefined') && navigator.mimeTypes["application/x-googlegears"]) { - factory = document.createElement("object"); - factory.style.display = "none"; - factory.width = 0; - factory.height = 0; - factory.type = "application/x-googlegears"; - document.documentElement.appendChild(factory); - } - } - } - - // *Do not* define any objects if Gears is not installed. This mimics the - // behavior of Gears defining the objects in the future. - if (!factory) { - return; - } - - // Now set up the objects, being careful not to overwrite anything. - // - // Note: In Internet Explorer for Windows Mobile, you can't add properties to - // the window object. However, global objects are automatically added as - // properties of the window object in all browsers. - if (!window.google) { - google = {}; - } - - if (!google.gears) { - google.gears = {factory: factory}; - } -})(); -// -- END GEARS_INIT - -var GearsGeoLocation = (function() { - // -- PRIVATE - var geo = google.gears.factory.create('beta.geolocation'); - - var wrapSuccess = function(callback, self) { // wrap it for lastPosition love - return function(position) { - callback(position); - self.lastPosition = position; - }; - }; - - // -- PUBLIC - return { - shim: true, - - type: "Gears", - - lastPosition: null, - - getCurrentPosition: function(successCallback, errorCallback, options) { - var self = this; - var sc = wrapSuccess(successCallback, self); - geo.getCurrentPosition(sc, errorCallback, options); - }, - - watchPosition: function(successCallback, errorCallback, options) { - geo.watchPosition(successCallback, errorCallback, options); - }, - - clearWatch: function(watchId) { - geo.clearWatch(watchId); - }, - - getPermission: function(siteName, imageUrl, extraMessage) { - geo.getPermission(siteName, imageUrl, extraMessage); - } - - }; -}); - -var AjaxGeoLocation = (function() { - // -- PRIVATE - var loading = false; - var loadGoogleLoader = function() { - if (!hasGoogleLoader() && !loading) { - loading = true; - var s = document.createElement('script'); - s.src = (document.location.protocol == "https:"?"https://":"http://") + 'www.google.com/jsapi?callback=_google_loader_apiLoaded'; - s.type = "text/javascript"; - document.getElementsByTagName('body')[0].appendChild(s); - } - }; - - var queue = []; - var addLocationQueue = function(callback) { - queue.push(callback); - }; - - var runLocationQueue = function() { - if (hasGoogleLoader()) { - while (queue.length > 0) { - var call = queue.pop(); - call(); - } - } - }; - - window['_google_loader_apiLoaded'] = function() { - runLocationQueue(); - }; - - var hasGoogleLoader = function() { - return (window['google'] && google['loader']); - }; - - var checkGoogleLoader = function(callback) { - if (hasGoogleLoader()) { return true; } - - addLocationQueue(callback); - - loadGoogleLoader(); - - return false; - }; - - loadGoogleLoader(); // start to load as soon as possible just in case - - // -- PUBLIC - return { - shim: true, - - type: "ClientLocation", - - lastPosition: null, - - getCurrentPosition: function(successCallback, errorCallback, options) { - var self = this; - if (!checkGoogleLoader(function() { - self.getCurrentPosition(successCallback, errorCallback, options); - })) { return; } - - if (google.loader.ClientLocation) { - var cl = google.loader.ClientLocation; - - var position = { - coords: { - latitude: cl.latitude, - longitude: cl.longitude, - altitude: null, - accuracy: 43000, // same as Gears accuracy over wifi? - altitudeAccuracy: null, - heading: null, - speed: null - }, - // extra info that is outside of the bounds of the core API - address: { - city: cl.address.city, - country: cl.address.country, - country_code: cl.address.country_code, - region: cl.address.region - }, - timestamp: new Date() - }; - - successCallback(position); - - this.lastPosition = position; - } else if (errorCallback === "function") { - errorCallback({ code: 3, message: "Using the Google ClientLocation API and it is not able to calculate a location."}); - } - }, - - watchPosition: function(successCallback, errorCallback, options) { - this.getCurrentPosition(successCallback, errorCallback, options); - - var self = this; - var watchId = setInterval(function() { - self.getCurrentPosition(successCallback, errorCallback, options); - }, 10000); - - return watchId; - }, - - clearWatch: function(watchId) { - clearInterval(watchId); - }, - - getPermission: function(siteName, imageUrl, extraMessage) { - // for now just say yes :) - return true; - } - - }; -}); - -// If you have Gears installed use that, else use Ajax ClientLocation -navigator.geolocation = (window.google && google.gears) ? GearsGeoLocation() : AjaxGeoLocation(); - -})(); -} diff --git a/js/xbImportNode.js b/js/xbImportNode.js new file mode 100644 index 0000000000..f600a4789e --- /dev/null +++ b/js/xbImportNode.js @@ -0,0 +1,47 @@ +/* is this stuff defined? */ +if (!document.ELEMENT_NODE) { + document.ELEMENT_NODE = 1; + document.ATTRIBUTE_NODE = 2; + document.TEXT_NODE = 3; + document.CDATA_SECTION_NODE = 4; + document.ENTITY_REFERENCE_NODE = 5; + document.ENTITY_NODE = 6; + document.PROCESSING_INSTRUCTION_NODE = 7; + document.COMMENT_NODE = 8; + document.DOCUMENT_NODE = 9; + document.DOCUMENT_TYPE_NODE = 10; + document.DOCUMENT_FRAGMENT_NODE = 11; + document.NOTATION_NODE = 12; +} + +document._importNode = function(node, allChildren) { + /* find the node type to import */ + switch (node.nodeType) { + case document.ELEMENT_NODE: + /* create a new element */ + var newNode = document.createElement(node.nodeName); + /* does the node have any attributes to add? */ + if (node.attributes && node.attributes.length > 0) + /* add all of the attributes */ + for (var i = 0, il = node.attributes.length; i < il;) { + if (node.attributes[i].nodeName == 'class') { + newNode.className = node.getAttribute(node.attributes[i++].nodeName); + } else { + newNode.setAttribute(node.attributes[i].nodeName, node.getAttribute(node.attributes[i++].nodeName)); + } + } + /* are we going after children too, and does the node have any? */ + if (allChildren && node.childNodes && node.childNodes.length > 0) + /* recursively get all of the child nodes */ + for (var i = 0, il = node.childNodes.length; i < il;) + newNode.appendChild(document._importNode(node.childNodes[i++], allChildren)); + return newNode; + break; + case document.TEXT_NODE: + case document.CDATA_SECTION_NODE: + case document.COMMENT_NODE: + return document.createTextNode(node.nodeValue); + break; + } +}; +