From: Evan Prodromou Date: Wed, 22 Dec 2010 19:22:51 +0000 (-0800) Subject: Merge branch 'righttoleave' into 0.9.x X-Git-Url: https://git.mxchange.org/?a=commitdiff_plain;h=9a6ceb3303a98d1c5fba3587f32d0377e55062cc;hp=a16131526b5ae77c558fe6b11d1726af39d7c82d;p=quix0rs-gnu-social.git Merge branch 'righttoleave' into 0.9.x --- 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/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/deleteaccount.php b/actions/deleteaccount.php new file mode 100644 index 0000000000..9abe2fcdb6 --- /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 28b1d20f34..8f55a47189 100644 --- a/actions/profilesettings.php +++ b/actions/profilesettings.php @@ -452,4 +452,33 @@ class ProfilesettingsAction extends AccountSettingsAction return $other->id != $user->id; } } + + function showAside() { + $user = common_current_user(); + + $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'); + } + 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/classes/Notice.php b/classes/Notice.php index 629b7089de..50909f9707 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/classes/Profile.php b/classes/Profile.php index fe1a070bdf..972351a75b 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -858,6 +858,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/activityimporter.php b/lib/activityimporter.php new file mode 100644 index 0000000000..4a76781328 --- /dev/null +++ b/lib/activityimporter.php @@ -0,0 +1,350 @@ +. + * + * @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, $author, $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, $se->getMessage()); + return false; + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + return false; + } + return true; + } + + function subscribeProfile($user, $author, $activity) + { + $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); + + 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 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)); + } + } + + // XXX: largely cadged from Ostatus_profile::processNote() + + function postNote($user, $author, $activity) + { + $note = $activity->objects[0]; + + $sourceUri = $note->id; + + $notice = Notice::staticGet('uri', $sourceUri); + + if (!empty($notice)) { + + 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)); + } + } else { + throw new ClientException("Not overwriting author info for non-trusted user."); + } + } + + // 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(), + 'distribute' => false); + + // 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; + } + + common_log(LOG_INFO, "Saving notice {$options['uri']}"); + + $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) + { + require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php'; + + $config = array('safe' => 1, + 'deny_attribute' => 'id,style,on*'); + + return htmLawed($content, $config); + } +} 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/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/', diff --git a/lib/feedimporter.php b/lib/feedimporter.php new file mode 100644 index 0000000000..e46858cc51 --- /dev/null +++ b/lib/feedimporter.php @@ -0,0 +1,160 @@ +. + * + * @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); +} + +/** + * Importer for feeds of activities + * + * Takes an XML file representing a feed of activities and imports each + * activity to the user in question. + * + * @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 FeedImporter extends QueueHandler +{ + /** + * Transport identifier + * + * @return string identifier for this queue handler + */ + + public function transport() + { + return 'feedimp'; + } + + function handle($data) + { + list($user, $xml, $trusted) = $data; + + try { + $doc = DOMDocument::loadXML($xml); + + $feed = $doc->documentElement; + + if ($feed->namespaceURI != Activity::ATOM || + $feed->localName != 'feed') { + throw new ClientException(_("Not an atom feed.")); + } + + + $author = ActivityUtils::getFeedAuthor($feed); + + if (empty($author)) { + throw new ClientException(_("No author in the feed.")); + } + + if (empty($user)) { + if ($trusted) { + $user = $this->userFromAuthor($author); + } else { + throw new ClientException(_("Can't import without a user.")); + } + } + + $activities = $this->getActivities($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 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); + + if (empty($user)) { + $attrs = + array('nickname' => Ostatus_profile::getActivityObjectNickname($author), + 'uri' => $author->id); + + $user = User::register($attrs); + } + + $profile = $user->getProfile(); + Ostatus_profile::updateProfile($profile, $author); + + // FIXME: Update avatar + return $user; + } +} 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'); 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'; } diff --git a/lib/router.php b/lib/router.php index 9cd28a394d..c8e1c365a5 100644 --- a/lib/router.php +++ b/lib/router.php @@ -208,6 +208,9 @@ class Router 'deleteuser', 'geocode', 'version', + 'backupaccount', + 'deleteaccount', + 'restoreaccount', ); foreach ($main as $a) { 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 - - $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC); - - if (!empty($subject)) { - $subjObject = new ActivityObject($subject); - return self::ensureActivityObjectProfile($subjObject, $hints); - } - - // 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. + $author = ActivityUtils::getFeedAuthor($feedEl); - $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); - } + 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.')); } - // 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); } /** diff --git a/scripts/restoreuser.php b/scripts/restoreuser.php index b37e9db741..17f007b412 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; -} - -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); + $xml = file_get_contents($filename); - // FIXME: Update avatar - return $user; + return $xml; } -function purify($content) -{ - $config = array('safe' => 1, - 'deny_attribute' => 'id,style,on*'); - return htmLawed($content, $config); -} try { try { @@ -372,8 +74,9 @@ try { } catch (NoUserArgumentException $noae) { $user = null; } - $doc = getActivityStreamDocument(); - importActivityStream($user, $doc); + $xml = getActivityStreamDocument(); + $qm = QueueManager::get(); + $qm->enqueue(array($user, $xml, true), 'feedimp'); } catch (Exception $e) { print $e->getMessage()."\n"; exit(1);