Merge branch 'righttoleave' into 0.9.x

This commit is contained in:
Evan Prodromou 2010-12-22 11:22:51 -08:00
commit 9a6ceb3303
16 changed files with 1578 additions and 350 deletions

6
README
View File

@ -1276,6 +1276,12 @@ Profile management.
biolimit: max character length of bio; 0 means no limit; null means to use biolimit: max character length of bio; 0 means no limit; null means to use
the site text limit default. 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 newuser
------- -------

260
actions/backupaccount.php Normal file
View File

@ -0,0 +1,260 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* Download a backup of your own account to the browser
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Account
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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 <evan@status.net>
* @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 '.
'<a href="http://activitystrea.ms/">Activity Streams</a> '.
'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'));
}
}

319
actions/deleteaccount.php Normal file
View File

@ -0,0 +1,319 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* Delete your own account
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Account
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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 <evan@status.net>
* @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 = _('<p>This will <strong>permanently delete</strong> '.
'your account data from this server. </p>');
if ($cur->hasRight(Right::BACKUPACCOUNT)) {
$msg .= sprintf(_('<p>You are strongly advised to '.
'<a href="%s">back up your data</a>'.
' before deletion.</p>'),
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'));
}
}

View File

@ -452,4 +452,33 @@ class ProfilesettingsAction extends AccountSettingsAction
return $other->id != $user->id; 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');
}
} }

359
actions/restoreaccount.php Normal file
View File

@ -0,0 +1,359 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* Restore a backup of your own account from the browser
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Account
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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 <evan@status.net>
* @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 '.
'<a href="http://activitystrea.ms/">Activity Streams</a> 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'));
}
}

View File

@ -234,6 +234,8 @@ class Notice extends Memcached_DataObject
* in place of extracting # tags from content * in place of extracting # tags from content
* array 'urls' list of attached/referred URLs to save with the * array 'urls' list of attached/referred URLs to save with the
* notice in place of extracting links from content * notice in place of extracting links from content
* boolean 'distribute' whether to distribute the notice, default true
*
* @fixme tag override * @fixme tag override
* *
* @return Notice * @return Notice
@ -243,7 +245,8 @@ class Notice extends Memcached_DataObject
$defaults = array('uri' => null, $defaults = array('uri' => null,
'url' => null, 'url' => null,
'reply_to' => null, 'reply_to' => null,
'repeat_of' => null); 'repeat_of' => null,
'distribute' => true);
if (!empty($options)) { if (!empty($options)) {
$options = $options + $defaults; $options = $options + $defaults;
@ -426,8 +429,10 @@ class Notice extends Memcached_DataObject
$notice->saveUrls(); $notice->saveUrls();
} }
// Prepare inbox delivery, may be queued to background. if ($distribute) {
$notice->distribute(); // Prepare inbox delivery, may be queued to background.
$notice->distribute();
}
return $notice; return $notice;
} }

View File

@ -858,6 +858,18 @@ class Profile extends Memcached_DataObject
case Right::EMAILONFAVE: case Right::EMAILONFAVE:
$result = !$this->isSandboxed(); $result = !$this->isSandboxed();
break; 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: default:
$result = false; $result = false;
break; break;

350
lib/activityimporter.php Normal file
View File

@ -0,0 +1,350 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* class to import activities as part of a user's timeline
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Cache
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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);
}
}

View File

@ -270,4 +270,51 @@ class ActivityUtils
return false; 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;
}
} }

View File

@ -123,7 +123,11 @@ $default =
'featured' => array()), 'featured' => array()),
'profile' => 'profile' =>
array('banned' => array(), array('banned' => array(),
'biolimit' => null), 'biolimit' => null,
'backup' => true,
'restore' => true,
'delete' => true,
'move' => true),
'avatar' => 'avatar' =>
array('server' => null, array('server' => null,
'dir' => INSTALLDIR . '/avatar/', 'dir' => INSTALLDIR . '/avatar/',

160
lib/feedimporter.php Normal file
View File

@ -0,0 +1,160 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* Importer for feeds of activities
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Account
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @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 <evan@status.net>
* @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;
}
}

View File

@ -266,6 +266,8 @@ abstract class QueueManager extends IoManager
// Background user management tasks... // Background user management tasks...
$this->connect('deluser', 'DelUserQueueHandler'); $this->connect('deluser', 'DelUserQueueHandler');
$this->connect('feedimp', 'FeedImporter');
$this->connect('actimp', 'ActivityImporter');
// Broadcasting profile updates to OMB remote subscribers // Broadcasting profile updates to OMB remote subscribers
$this->connect('profile', 'ProfileQueueHandler'); $this->connect('profile', 'ProfileQueueHandler');

View File

@ -61,5 +61,9 @@ class Right
const GRANTROLE = 'grantrole'; const GRANTROLE = 'grantrole';
const REVOKEROLE = 'revokerole'; const REVOKEROLE = 'revokerole';
const DELETEGROUP = 'deletegroup'; const DELETEGROUP = 'deletegroup';
const BACKUPACCOUNT = 'backupaccount';
const RESTOREACCOUNT = 'restoreaccount';
const DELETEACCOUNT = 'deleteaccount';
const MOVEACCOUNT = 'moveaccount';
} }

View File

@ -208,6 +208,9 @@ class Router
'deleteuser', 'deleteuser',
'geocode', 'geocode',
'version', 'version',
'backupaccount',
'deleteaccount',
'restoreaccount',
); );
foreach ($main as $a) { foreach ($main as $a) {

View File

@ -935,54 +935,19 @@ class Ostatus_profile extends Memcached_DataObject
* @return Ostatus_profile * @return Ostatus_profile
* @throws Exception * @throws Exception
*/ */
public static function ensureAtomFeed($feedEl, $hints) 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($author)) {
// XXX: make some educated guesses here
if (!empty($subject)) { // TRANS: Feed sub exception.
$subjObject = new ActivityObject($subject); throw new FeedSubException(_m('Can\'t find enough profile '.
return self::ensureActivityObjectProfile($subjObject, $hints); 'information to make a feed.'));
} }
// Otherwise, try the feed author return self::ensureActivityObjectProfile($author, $hints);
$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.'));
} }
/** /**

View File

@ -36,6 +36,7 @@ END_OF_RESTOREUSER_HELP;
require_once INSTALLDIR.'/scripts/commandline.inc'; require_once INSTALLDIR.'/scripts/commandline.inc';
require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php'; require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
function getActivityStreamDocument() function getActivityStreamDocument()
{ {
$filename = get_option_value('f', 'file'); $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. // TRANS: Commandline script output. %s is the filename that contains a backup for a user.
printfv(_("Getting backup from file '%s'.")."\n",$filename); printfv(_("Getting backup from file '%s'.")."\n",$filename);
$xml = file_get_contents($filename); $xml = file_get_contents($filename);
$dom = DOMDocument::loadXML($xml); return $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 <activity:subject> 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 {
try { try {
@ -372,8 +74,9 @@ try {
} catch (NoUserArgumentException $noae) { } catch (NoUserArgumentException $noae) {
$user = null; $user = null;
} }
$doc = getActivityStreamDocument(); $xml = getActivityStreamDocument();
importActivityStream($user, $doc); $qm = QueueManager::get();
$qm->enqueue(array($user, $xml, true), 'feedimp');
} catch (Exception $e) { } catch (Exception $e) {
print $e->getMessage()."\n"; print $e->getMessage()."\n";
exit(1); exit(1);