Move account restoration code to a shared library

Moved most of the heavy-lifting for account restoration out of
restoreuser.php and into its own class, with the hope that we'll do
the work from the Web eventually.
This commit is contained in:
Evan Prodromou 2010-12-15 17:53:38 -05:00
parent fd22f684bf
commit 2e2519afee
2 changed files with 367 additions and 303 deletions

360
lib/accountrestorer.php Normal file
View File

@ -0,0 +1,360 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* A class for restoring accounts
*
* 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);
}
/**
* A class for restoring accounts
*
* This is a clumsy objectification of the functions in restoreuser.php.
*
* Note that it quite illegally uses the OStatus_profile class which may
* not even exist on this server.
*
* @category Account
* @package StatusNet
* @author Evan Prodromou <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 AccountRestorer
{
function loadXML($xml)
{
$dom = DOMDocument::loadXML($xml);
if ($dom->documentElement->namespaceURI != Activity::ATOM ||
$dom->documentElement->localName != 'feed') {
throw new Exception("'$filename' is not an Atom feed.");
}
return $dom;
}
function importActivityStream($user, $doc)
{
$feed = $doc->documentElement;
$subjectEl = ActivityUtils::child($feed, Activity::SUBJECT, Activity::SPEC);
if (!empty($subjectEl)) {
$subject = new ActivityObject($subjectEl);
// TRANS: Commandline script output. %1$s is the subject ID, %2$s is the subject nickname.
printfv(_("Backup file for user %1$s (%2$s)")."\n", $subject->id, Ostatus_profile::getActivityObjectNickname($subject));
} else {
throw new Exception("Feed doesn't have an <activity:subject> element.");
}
if (is_null($user)) {
// TRANS: Commandline script output.
printfv(_("No user specified; using backup user.")."\n");
$user = $this->userFromSubject($subject);
}
$entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
// TRANS: Commandline script output. %d is the number of entries in the activity stream in backup; used for plural.
printfv(_m("%d entry in backup.","%d entries in backup.",$entries->length)."\n", $entries->length);
for ($i = $entries->length - 1; $i >= 0; $i--) {
try {
$entry = $entries->item($i);
$activity = new Activity($entry, $feed);
switch ($activity->verb) {
case ActivityVerb::FOLLOW:
$this->subscribeProfile($user, $subject, $activity);
break;
case ActivityVerb::JOIN:
$this->joinGroup($user, $activity);
break;
case ActivityVerb::POST:
$this->postNote($user, $activity);
break;
default:
throw new Exception("Unknown verb: {$activity->verb}");
}
} catch (Exception $e) {
print $e->getMessage()."\n";
continue;
}
}
}
function subscribeProfile($user, $subject, $activity)
{
$profile = $user->getProfile();
if ($activity->objects[0]->id == $subject->id) {
$other = $activity->actor;
$otherUser = User::staticGet('uri', $other->id);
if (!empty($otherUser)) {
$otherProfile = $otherUser->getProfile();
} else {
throw new Exception("Can't force remote user to subscribe.");
}
// XXX: don't do this for untrusted input!
Subscription::start($otherProfile, $profile);
} else if (empty($activity->actor) || $activity->actor->id == $subject->id) {
$other = $activity->objects[0];
$otherUser = User::staticGet('uri', $other->id);
if (!empty($otherUser)) {
$otherProfile = $otherUser->getProfile();
} else {
$oprofile = Ostatus_profile::ensureActivityObjectProfile($other);
$otherProfile = $oprofile->localProfile();
}
Subscription::start($profile, $otherProfile);
} else {
throw new Exception("This activity seems unrelated to our user.");
}
}
function joinGroup($user, $activity)
{
// XXX: check that actor == subject
$uri = $activity->objects[0]->id;
$group = User_group::staticGet('uri', $uri);
if (empty($group)) {
$oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]);
if (!$oprofile->isGroup()) {
throw new Exception("Remote profile is not a group!");
}
$group = $oprofile->localGroup();
}
assert(!empty($group));
if (Event::handle('StartJoinGroup', array($group, $user))) {
Group_member::join($group->id, $user->id);
Event::handle('EndJoinGroup', array($group, $user));
}
}
// XXX: largely cadged from Ostatus_profile::processNote()
function postNote($user, $activity)
{
$note = $activity->objects[0];
$sourceUri = $note->id;
$notice = Notice::staticGet('uri', $sourceUri);
if (!empty($notice)) {
// This is weird.
$orig = clone($notice);
$notice->profile_id = $user->id;
$notice->update($orig);
return;
}
// Use summary as fallback for content
if (!empty($note->content)) {
$sourceContent = $note->content;
} else if (!empty($note->summary)) {
$sourceContent = $note->summary;
} else if (!empty($note->title)) {
$sourceContent = $note->title;
} else {
// @fixme fetch from $sourceUrl?
// @todo i18n FIXME: use sprintf and add i18n.
throw new ClientException("No content for notice {$sourceUri}.");
}
// Get (safe!) HTML and text versions of the content
$rendered = $this->purify($sourceContent);
$content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
$shortened = $user->shortenLinks($content);
$options = array('is_local' => Notice::LOCAL_PUBLIC,
'uri' => $sourceUri,
'rendered' => $rendered,
'replies' => array(),
'groups' => array(),
'tags' => array(),
'urls' => array());
// Check for optional attributes...
if (!empty($activity->time)) {
$options['created'] = common_sql_date($activity->time);
}
if ($activity->context) {
// Any individual or group attn: targets?
list($options['groups'], $options['replies']) = $this->filterAttention($activity->context->attention);
// Maintain direct reply associations
// @fixme what about conversation ID?
if (!empty($activity->context->replyToID)) {
$orig = Notice::staticGet('uri',
$activity->context->replyToID);
if (!empty($orig)) {
$options['reply_to'] = $orig->id;
}
}
$location = $activity->context->location;
if ($location) {
$options['lat'] = $location->lat;
$options['lon'] = $location->lon;
if ($location->location_id) {
$options['location_ns'] = $location->location_ns;
$options['location_id'] = $location->location_id;
}
}
}
// Atom categories <-> hashtags
foreach ($activity->categories as $cat) {
if ($cat->term) {
$term = common_canonical_tag($cat->term);
if ($term) {
$options['tags'][] = $term;
}
}
}
// Atom enclosures -> attachment URLs
foreach ($activity->enclosures as $href) {
// @fixme save these locally or....?
$options['urls'][] = $href;
}
$saved = Notice::saveNew($user->id,
$content,
'restore', // TODO: restore the actual source
$options);
return $saved;
}
function filterAttention($attn)
{
$groups = array();
$replies = array();
foreach (array_unique($attn) as $recipient) {
// Is the recipient a local user?
$user = User::staticGet('uri', $recipient);
if ($user) {
// @fixme sender verification, spam etc?
$replies[] = $recipient;
continue;
}
// Is the recipient a remote group?
$oprofile = Ostatus_profile::ensureProfileURI($recipient);
if ($oprofile) {
if (!$oprofile->isGroup()) {
// may be canonicalized or something
$replies[] = $oprofile->uri;
}
continue;
}
// Is the recipient a local group?
// @fixme uri on user_group isn't reliable yet
// $group = User_group::staticGet('uri', $recipient);
$id = OStatusPlugin::localGroupFromUrl($recipient);
if ($id) {
$group = User_group::staticGet('id', $id);
if ($group) {
// Deliver to all members of this local group if allowed.
$profile = $sender->localProfile();
if ($profile->isMember($group)) {
$groups[] = $group->id;
} else {
common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member");
}
continue;
} else {
common_log(LOG_INFO, "Skipping reply to bogus group $recipient");
}
}
}
return array($groups, $replies);
}
function userFromSubject($subject)
{
$user = User::staticGet('uri', $subject->id);
if (empty($user)) {
$attrs =
array('nickname' => Ostatus_profile::getActivityObjectNickname($subject),
'uri' => $subject->id);
$user = User::register($attrs);
}
$profile = $user->getProfile();
Ostatus_profile::updateProfile($profile, $subject);
// FIXME: Update avatar
return $user;
}
function purify($content)
{
$config = array('safe' => 1,
'deny_attribute' => 'id,style,on*');
return htmLawed($content, $config);
}
}

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,10 @@ try {
} catch (NoUserArgumentException $noae) { } catch (NoUserArgumentException $noae) {
$user = null; $user = null;
} }
$doc = getActivityStreamDocument(); $xml = getActivityStreamDocument();
importActivityStream($user, $doc); $restorer = new AccountRestorer();
$doc = $restorer->loadXML($xml);
$restorer->importActivityStream($user, $doc);
} catch (Exception $e) { } catch (Exception $e) {
print $e->getMessage()."\n"; print $e->getMessage()."\n";
exit(1); exit(1);