diff --git a/README b/README index 3d23e2393b..35a055c77b 100644 --- a/README +++ b/README @@ -1274,6 +1274,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..4f6fb936bd --- /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($argarray); + + 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..8cf220a424 --- /dev/null +++ b/actions/restoreaccount.php @@ -0,0 +1,376 @@ +. + * + * @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; + private $inprogress = 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($argarray); + + 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. + throw new ClientException(_('No uploaded file.')); + return; + case UPLOAD_ERR_NO_TMP_DIR: + // TRANS: Client exception thrown when a temporary folder is not present to store a file upload. + 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 = new DOMDocument(); + + // Disable PHP warnings so we don't spew low-level XML errors to output... + // would be nice if we can just get exceptions instead. + $old_err = error_reporting(); + error_reporting($old_err & ~E_WARNING); + $doc->loadXML($xml); + error_reporting($old_err); + + $feed = $doc->documentElement; + + if (!$feed || + $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'); + + if ($qm instanceof UnQueueManager) { + // No active queuing means we've actually just completed the job! + $this->success = true; + } else { + // We've fed data into background queues, and it's probably still running. + $this->inprogress = true; + } + $this->showPage(); + + } catch (Exception $e) { + // 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 has been restored. Your old posts should now appear in search and your profile page.')); + } else if ($this->inprogress) { + $this->element('p', null, + _('Feed will be restored. Please wait a few minutes for results.')); + } else { + $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 6acf0c7c1a..8d686ab651 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 977947b6f7..b7c1b06a85 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/js/Makefile b/js/Makefile index 00e4347141..e7ae44e421 100644 --- a/js/Makefile +++ b/js/Makefile @@ -1,11 +1,12 @@ .fake: all clean TARGETS=util.min.js +SOURCES=util.js xbImportNode.js geometa.js all: $(TARGETS) clean: rm -f $(TARGETS) -util.min.js: util.js - yui-compressor $< -o $@ +util.min.js: $(SOURCES) + cat $+ | yui-compressor --type js > $@ diff --git a/js/geometa.js b/js/geometa.js new file mode 100644 index 0000000000..bba59b4486 --- /dev/null +++ b/js/geometa.js @@ -0,0 +1,217 @@ +// A shim to implement the W3C Geolocation API Specification using Gears or the Ajax API +if (typeof navigator.geolocation == "undefined" || navigator.geolocation.shim ) { (function(){ + +// -- BEGIN GEARS_INIT +(function() { + // We are already defined. Hooray! + if (window.google && google.gears) { + return; + } + + var factory = null; + + // Firefox + if (typeof GearsFactory != 'undefined') { + factory = new GearsFactory(); + } else { + // IE + try { + factory = new ActiveXObject('Gears.Factory'); + // privateSetGlobalObject is only required and supported on WinCE. + if (factory.getBuildInfo().indexOf('ie_mobile') != -1) { + factory.privateSetGlobalObject(this); + } + } catch (e) { + // Safari + if ((typeof navigator.mimeTypes != 'undefined') && navigator.mimeTypes["application/x-googlegears"]) { + factory = document.createElement("object"); + factory.style.display = "none"; + factory.width = 0; + factory.height = 0; + factory.type = "application/x-googlegears"; + document.documentElement.appendChild(factory); + } + } + } + + // *Do not* define any objects if Gears is not installed. This mimics the + // behavior of Gears defining the objects in the future. + if (!factory) { + return; + } + + // Now set up the objects, being careful not to overwrite anything. + // + // Note: In Internet Explorer for Windows Mobile, you can't add properties to + // the window object. However, global objects are automatically added as + // properties of the window object in all browsers. + if (!window.google) { + google = {}; + } + + if (!google.gears) { + google.gears = {factory: factory}; + } +})(); +// -- END GEARS_INIT + +var GearsGeoLocation = (function() { + // -- PRIVATE + var geo = google.gears.factory.create('beta.geolocation'); + + var wrapSuccess = function(callback, self) { // wrap it for lastPosition love + return function(position) { + callback(position); + self.lastPosition = position; + }; + }; + + // -- PUBLIC + return { + shim: true, + + type: "Gears", + + lastPosition: null, + + getCurrentPosition: function(successCallback, errorCallback, options) { + var self = this; + var sc = wrapSuccess(successCallback, self); + geo.getCurrentPosition(sc, errorCallback, options); + }, + + watchPosition: function(successCallback, errorCallback, options) { + geo.watchPosition(successCallback, errorCallback, options); + }, + + clearWatch: function(watchId) { + geo.clearWatch(watchId); + }, + + getPermission: function(siteName, imageUrl, extraMessage) { + geo.getPermission(siteName, imageUrl, extraMessage); + } + + }; +}); + +var AjaxGeoLocation = (function() { + // -- PRIVATE + var loading = false; + var loadGoogleLoader = function() { + if (!hasGoogleLoader() && !loading) { + loading = true; + var s = document.createElement('script'); + s.src = (document.location.protocol == "https:"?"https://":"http://") + 'www.google.com/jsapi?callback=_google_loader_apiLoaded'; + s.type = "text/javascript"; + document.getElementsByTagName('body')[0].appendChild(s); + } + }; + + var queue = []; + var addLocationQueue = function(callback) { + queue.push(callback); + }; + + var runLocationQueue = function() { + if (hasGoogleLoader()) { + while (queue.length > 0) { + var call = queue.pop(); + call(); + } + } + }; + + window['_google_loader_apiLoaded'] = function() { + runLocationQueue(); + }; + + var hasGoogleLoader = function() { + return (window['google'] && google['loader']); + }; + + var checkGoogleLoader = function(callback) { + if (hasGoogleLoader()) { return true; } + + addLocationQueue(callback); + + loadGoogleLoader(); + + return false; + }; + + loadGoogleLoader(); // start to load as soon as possible just in case + + // -- PUBLIC + return { + shim: true, + + type: "ClientLocation", + + lastPosition: null, + + getCurrentPosition: function(successCallback, errorCallback, options) { + var self = this; + if (!checkGoogleLoader(function() { + self.getCurrentPosition(successCallback, errorCallback, options); + })) { return; } + + if (google.loader.ClientLocation) { + var cl = google.loader.ClientLocation; + + var position = { + coords: { + latitude: cl.latitude, + longitude: cl.longitude, + altitude: null, + accuracy: 43000, // same as Gears accuracy over wifi? + altitudeAccuracy: null, + heading: null, + speed: null + }, + // extra info that is outside of the bounds of the core API + address: { + city: cl.address.city, + country: cl.address.country, + country_code: cl.address.country_code, + region: cl.address.region + }, + timestamp: new Date() + }; + + successCallback(position); + + this.lastPosition = position; + } else if (errorCallback === "function") { + errorCallback({ code: 3, message: "Using the Google ClientLocation API and it is not able to calculate a location."}); + } + }, + + watchPosition: function(successCallback, errorCallback, options) { + this.getCurrentPosition(successCallback, errorCallback, options); + + var self = this; + var watchId = setInterval(function() { + self.getCurrentPosition(successCallback, errorCallback, options); + }, 10000); + + return watchId; + }, + + clearWatch: function(watchId) { + clearInterval(watchId); + }, + + getPermission: function(siteName, imageUrl, extraMessage) { + // for now just say yes :) + return true; + } + + }; +}); + +// If you have Gears installed use that, else use Ajax ClientLocation +navigator.geolocation = (window.google && google.gears) ? GearsGeoLocation() : AjaxGeoLocation(); + +})(); +} diff --git a/js/util.js b/js/util.js index d929e91e2e..eace1778e2 100644 --- a/js/util.js +++ b/js/util.js @@ -1242,272 +1242,3 @@ $(document).ready(function(){ SN.Init.Login(); } }); - -// Formerly in xbImportNode.js -// @fixme put it back there -- since we're minifying we can concat in the makefile now - -/* is this stuff defined? */ -if (!document.ELEMENT_NODE) { - document.ELEMENT_NODE = 1; - document.ATTRIBUTE_NODE = 2; - document.TEXT_NODE = 3; - document.CDATA_SECTION_NODE = 4; - document.ENTITY_REFERENCE_NODE = 5; - document.ENTITY_NODE = 6; - document.PROCESSING_INSTRUCTION_NODE = 7; - document.COMMENT_NODE = 8; - document.DOCUMENT_NODE = 9; - document.DOCUMENT_TYPE_NODE = 10; - document.DOCUMENT_FRAGMENT_NODE = 11; - document.NOTATION_NODE = 12; -} - -document._importNode = function(node, allChildren) { - /* find the node type to import */ - switch (node.nodeType) { - case document.ELEMENT_NODE: - /* create a new element */ - var newNode = document.createElement(node.nodeName); - /* does the node have any attributes to add? */ - if (node.attributes && node.attributes.length > 0) - /* add all of the attributes */ - for (var i = 0, il = node.attributes.length; i < il;) { - if (node.attributes[i].nodeName == 'class') { - newNode.className = node.getAttribute(node.attributes[i++].nodeName); - } else { - newNode.setAttribute(node.attributes[i].nodeName, node.getAttribute(node.attributes[i++].nodeName)); - } - } - /* are we going after children too, and does the node have any? */ - if (allChildren && node.childNodes && node.childNodes.length > 0) - /* recursively get all of the child nodes */ - for (var i = 0, il = node.childNodes.length; i < il;) - newNode.appendChild(document._importNode(node.childNodes[i++], allChildren)); - return newNode; - break; - case document.TEXT_NODE: - case document.CDATA_SECTION_NODE: - case document.COMMENT_NODE: - return document.createTextNode(node.nodeValue); - break; - } -}; - -// @fixme put this next bit back too -- since we're minifying we can concat in the makefile now -// A shim to implement the W3C Geolocation API Specification using Gears or the Ajax API -if (typeof navigator.geolocation == "undefined" || navigator.geolocation.shim ) { (function(){ - -// -- BEGIN GEARS_INIT -(function() { - // We are already defined. Hooray! - if (window.google && google.gears) { - return; - } - - var factory = null; - - // Firefox - if (typeof GearsFactory != 'undefined') { - factory = new GearsFactory(); - } else { - // IE - try { - factory = new ActiveXObject('Gears.Factory'); - // privateSetGlobalObject is only required and supported on WinCE. - if (factory.getBuildInfo().indexOf('ie_mobile') != -1) { - factory.privateSetGlobalObject(this); - } - } catch (e) { - // Safari - if ((typeof navigator.mimeTypes != 'undefined') && navigator.mimeTypes["application/x-googlegears"]) { - factory = document.createElement("object"); - factory.style.display = "none"; - factory.width = 0; - factory.height = 0; - factory.type = "application/x-googlegears"; - document.documentElement.appendChild(factory); - } - } - } - - // *Do not* define any objects if Gears is not installed. This mimics the - // behavior of Gears defining the objects in the future. - if (!factory) { - return; - } - - // Now set up the objects, being careful not to overwrite anything. - // - // Note: In Internet Explorer for Windows Mobile, you can't add properties to - // the window object. However, global objects are automatically added as - // properties of the window object in all browsers. - if (!window.google) { - google = {}; - } - - if (!google.gears) { - google.gears = {factory: factory}; - } -})(); -// -- END GEARS_INIT - -var GearsGeoLocation = (function() { - // -- PRIVATE - var geo = google.gears.factory.create('beta.geolocation'); - - var wrapSuccess = function(callback, self) { // wrap it for lastPosition love - return function(position) { - callback(position); - self.lastPosition = position; - }; - }; - - // -- PUBLIC - return { - shim: true, - - type: "Gears", - - lastPosition: null, - - getCurrentPosition: function(successCallback, errorCallback, options) { - var self = this; - var sc = wrapSuccess(successCallback, self); - geo.getCurrentPosition(sc, errorCallback, options); - }, - - watchPosition: function(successCallback, errorCallback, options) { - geo.watchPosition(successCallback, errorCallback, options); - }, - - clearWatch: function(watchId) { - geo.clearWatch(watchId); - }, - - getPermission: function(siteName, imageUrl, extraMessage) { - geo.getPermission(siteName, imageUrl, extraMessage); - } - - }; -}); - -var AjaxGeoLocation = (function() { - // -- PRIVATE - var loading = false; - var loadGoogleLoader = function() { - if (!hasGoogleLoader() && !loading) { - loading = true; - var s = document.createElement('script'); - s.src = (document.location.protocol == "https:"?"https://":"http://") + 'www.google.com/jsapi?callback=_google_loader_apiLoaded'; - s.type = "text/javascript"; - document.getElementsByTagName('body')[0].appendChild(s); - } - }; - - var queue = []; - var addLocationQueue = function(callback) { - queue.push(callback); - }; - - var runLocationQueue = function() { - if (hasGoogleLoader()) { - while (queue.length > 0) { - var call = queue.pop(); - call(); - } - } - }; - - window['_google_loader_apiLoaded'] = function() { - runLocationQueue(); - }; - - var hasGoogleLoader = function() { - return (window['google'] && google['loader']); - }; - - var checkGoogleLoader = function(callback) { - if (hasGoogleLoader()) { return true; } - - addLocationQueue(callback); - - loadGoogleLoader(); - - return false; - }; - - loadGoogleLoader(); // start to load as soon as possible just in case - - // -- PUBLIC - return { - shim: true, - - type: "ClientLocation", - - lastPosition: null, - - getCurrentPosition: function(successCallback, errorCallback, options) { - var self = this; - if (!checkGoogleLoader(function() { - self.getCurrentPosition(successCallback, errorCallback, options); - })) { return; } - - if (google.loader.ClientLocation) { - var cl = google.loader.ClientLocation; - - var position = { - coords: { - latitude: cl.latitude, - longitude: cl.longitude, - altitude: null, - accuracy: 43000, // same as Gears accuracy over wifi? - altitudeAccuracy: null, - heading: null, - speed: null - }, - // extra info that is outside of the bounds of the core API - address: { - city: cl.address.city, - country: cl.address.country, - country_code: cl.address.country_code, - region: cl.address.region - }, - timestamp: new Date() - }; - - successCallback(position); - - this.lastPosition = position; - } else if (errorCallback === "function") { - errorCallback({ code: 3, message: "Using the Google ClientLocation API and it is not able to calculate a location."}); - } - }, - - watchPosition: function(successCallback, errorCallback, options) { - this.getCurrentPosition(successCallback, errorCallback, options); - - var self = this; - var watchId = setInterval(function() { - self.getCurrentPosition(successCallback, errorCallback, options); - }, 10000); - - return watchId; - }, - - clearWatch: function(watchId) { - clearInterval(watchId); - }, - - getPermission: function(siteName, imageUrl, extraMessage) { - // for now just say yes :) - return true; - } - - }; -}); - -// If you have Gears installed use that, else use Ajax ClientLocation -navigator.geolocation = (window.google && google.gears) ? GearsGeoLocation() : AjaxGeoLocation(); - -})(); -} diff --git a/js/xbImportNode.js b/js/xbImportNode.js new file mode 100644 index 0000000000..f600a4789e --- /dev/null +++ b/js/xbImportNode.js @@ -0,0 +1,47 @@ +/* is this stuff defined? */ +if (!document.ELEMENT_NODE) { + document.ELEMENT_NODE = 1; + document.ATTRIBUTE_NODE = 2; + document.TEXT_NODE = 3; + document.CDATA_SECTION_NODE = 4; + document.ENTITY_REFERENCE_NODE = 5; + document.ENTITY_NODE = 6; + document.PROCESSING_INSTRUCTION_NODE = 7; + document.COMMENT_NODE = 8; + document.DOCUMENT_NODE = 9; + document.DOCUMENT_TYPE_NODE = 10; + document.DOCUMENT_FRAGMENT_NODE = 11; + document.NOTATION_NODE = 12; +} + +document._importNode = function(node, allChildren) { + /* find the node type to import */ + switch (node.nodeType) { + case document.ELEMENT_NODE: + /* create a new element */ + var newNode = document.createElement(node.nodeName); + /* does the node have any attributes to add? */ + if (node.attributes && node.attributes.length > 0) + /* add all of the attributes */ + for (var i = 0, il = node.attributes.length; i < il;) { + if (node.attributes[i].nodeName == 'class') { + newNode.className = node.getAttribute(node.attributes[i++].nodeName); + } else { + newNode.setAttribute(node.attributes[i].nodeName, node.getAttribute(node.attributes[i++].nodeName)); + } + } + /* are we going after children too, and does the node have any? */ + if (allChildren && node.childNodes && node.childNodes.length > 0) + /* recursively get all of the child nodes */ + for (var i = 0, il = node.childNodes.length; i < il;) + newNode.appendChild(document._importNode(node.childNodes[i++], allChildren)); + return newNode; + break; + case document.TEXT_NODE: + case document.CDATA_SECTION_NODE: + case document.COMMENT_NODE: + return document.createTextNode(node.nodeValue); + break; + } +}; + 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/activityobject.php b/lib/activityobject.php index 61614935f1..5185d77610 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -105,6 +105,7 @@ class ActivityObject public $thumbnail; public $largerImage; public $description; + public $extra = array(); /** * Constructor 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 5c4484121d..641528691b 100644 --- a/lib/default.php +++ b/lib/default.php @@ -124,7 +124,11 @@ $default = 'featured' => array()), 'profile' => array('banned' => array(), - 'biolimit' => null), + 'biolimit' => null, + 'backup' => true, + 'restore' => true, + 'delete' => false, + '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/mediafile.php b/lib/mediafile.php index a41d7c76b5..caa902de5d 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -362,7 +362,9 @@ class MediaFile // we'll try detecting a type from its extension... $unclearTypes = array('application/octet-stream', 'application/vnd.ms-office', - 'application/zip'); + 'application/zip', + // TODO: for XML we could do better content-based sniffing too + 'text/xml'); if ($originalFilename && (!$filetype || in_array($filetype, $unclearTypes))) { $type = $mte->getMIMEType($originalFilename); diff --git a/lib/queuemanager.php b/lib/queuemanager.php index 6666a6cb5a..60ac4855a7 100644 --- a/lib/queuemanager.php +++ b/lib/queuemanager.php @@ -241,6 +241,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 b7159e6130..b969829492 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/LinkPreview/Makefile b/plugins/LinkPreview/Makefile new file mode 100644 index 0000000000..6c8a03e94b --- /dev/null +++ b/plugins/LinkPreview/Makefile @@ -0,0 +1,11 @@ +.fake: all clean + +TARGETS=linkpreview.min.js + +all: $(TARGETS) + +clean: + rm -f $(TARGETS) + +linkpreview.min.js: linkpreview.js + yui-compressor $< -o $@ diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php index 1089f6a21f..c975cdf89c 100644 --- a/plugins/OStatus/classes/Ostatus_profile.php +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -897,54 +897,19 @@ class Ostatus_profile extends Managed_DataObject * @return Ostatus_profile * @throws Exception */ + public static function ensureAtomFeed($feedEl, $hints) { - // Try to get a profile from the feed activity:subject + $author = ActivityUtils::getFeedAuthor($feedEl); - $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC); - - if (!empty($subject)) { - $subjObject = new ActivityObject($subject); - return self::ensureActivityObjectProfile($subjObject, $hints); + if (empty($author)) { + // XXX: make some educated guesses here + // TRANS: Feed sub exception. + throw new FeedSubException(_m('Can\'t find enough profile '. + 'information to make a feed.')); } - // Otherwise, try the feed author - - $author = ActivityUtils::child($feedEl, Activity::AUTHOR, Activity::ATOM); - - if (!empty($author)) { - $authorObject = new ActivityObject($author); - return self::ensureActivityObjectProfile($authorObject, $hints); - } - - // Sheesh. Not a very nice feed! Let's try fingerpoken in the - // entries. - - $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - if (!empty($entries) && $entries->length > 0) { - - $entry = $entries->item(0); - - $actor = ActivityUtils::child($entry, Activity::ACTOR, Activity::SPEC); - - if (!empty($actor)) { - $actorObject = new ActivityObject($actor); - return self::ensureActivityObjectProfile($actorObject, $hints); - - } - - $author = ActivityUtils::child($entry, Activity::AUTHOR, Activity::ATOM); - - if (!empty($author)) { - $authorObject = new ActivityObject($author); - return self::ensureActivityObjectProfile($authorObject, $hints); - } - } - - // XXX: make some educated guesses here - // TRANS: Feed sub exception. - throw new FeedSubException(_m('Can\'t find enough profile information to make a feed.')); + return self::ensureActivityObjectProfile($author, $hints); } /** 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; + return $xml; } -function importActivityStream($user, $doc) -{ - $feed = $doc->documentElement; - - $subjectEl = ActivityUtils::child($feed, Activity::SUBJECT, Activity::SPEC); - - if (!empty($subjectEl)) { - $subject = new ActivityObject($subjectEl); - // TRANS: Commandline script output. %1$s is the subject ID, %2$s is the subject nickname. - printfv(_("Backup file for user %1$s (%2$s)")."\n", $subject->id, Ostatus_profile::getActivityObjectNickname($subject)); - } else { - throw new Exception("Feed doesn't have an element."); - } - - if (is_null($user)) { - // TRANS: Commandline script output. - printfv(_("No user specified; using backup user.")."\n"); - $user = userFromSubject($subject); - } - - $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); - - // TRANS: Commandline script output. %d is the number of entries in the activity stream in backup; used for plural. - printfv(_m("%d entry in backup.","%d entries in backup.",$entries->length)."\n", $entries->length); - - for ($i = $entries->length - 1; $i >= 0; $i--) { - try { - $entry = $entries->item($i); - - $activity = new Activity($entry, $feed); - - switch ($activity->verb) { - case ActivityVerb::FOLLOW: - subscribeProfile($user, $subject, $activity); - break; - case ActivityVerb::JOIN: - joinGroup($user, $activity); - break; - case ActivityVerb::POST: - postNote($user, $activity); - break; - default: - throw new Exception("Unknown verb: {$activity->verb}"); - } - } catch (Exception $e) { - print $e->getMessage()."\n"; - continue; - } - } -} - -function subscribeProfile($user, $subject, $activity) -{ - $profile = $user->getProfile(); - - if ($activity->objects[0]->id == $subject->id) { - - $other = $activity->actor; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - throw new Exception("Can't force remote user to subscribe."); - } - // XXX: don't do this for untrusted input! - Subscription::start($otherProfile, $profile); - - } else if (empty($activity->actor) || $activity->actor->id == $subject->id) { - - $other = $activity->objects[0]; - $otherUser = User::staticGet('uri', $other->id); - - if (!empty($otherUser)) { - $otherProfile = $otherUser->getProfile(); - } else { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($other); - $otherProfile = $oprofile->localProfile(); - } - - Subscription::start($profile, $otherProfile); - } else { - throw new Exception("This activity seems unrelated to our user."); - } -} - -function joinGroup($user, $activity) -{ - // XXX: check that actor == subject - - $uri = $activity->objects[0]->id; - - $group = User_group::staticGet('uri', $uri); - - if (empty($group)) { - $oprofile = Ostatus_profile::ensureActivityObjectProfile($activity->objects[0]); - if (!$oprofile->isGroup()) { - throw new Exception("Remote profile is not a group!"); - } - $group = $oprofile->localGroup(); - } - - assert(!empty($group)); - - if (Event::handle('StartJoinGroup', array($group, $user))) { - Group_member::join($group->id, $user->id); - Event::handle('EndJoinGroup', array($group, $user)); - } -} - -// XXX: largely cadged from Ostatus_profile::processNote() - -function postNote($user, $activity) -{ - $note = $activity->objects[0]; - - $sourceUri = $note->id; - - $notice = Notice::staticGet('uri', $sourceUri); - - if (!empty($notice)) { - // This is weird. - $orig = clone($notice); - $notice->profile_id = $user->id; - $notice->update($orig); - return; - } - - // Use summary as fallback for content - - if (!empty($note->content)) { - $sourceContent = $note->content; - } else if (!empty($note->summary)) { - $sourceContent = $note->summary; - } else if (!empty($note->title)) { - $sourceContent = $note->title; - } else { - // @fixme fetch from $sourceUrl? - // @todo i18n FIXME: use sprintf and add i18n. - throw new ClientException("No content for notice {$sourceUri}."); - } - - // Get (safe!) HTML and text versions of the content - - $rendered = purify($sourceContent); - $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8'); - - $shortened = $user->shortenLinks($content); - - $options = array('is_local' => Notice::LOCAL_PUBLIC, - 'uri' => $sourceUri, - 'rendered' => $rendered, - 'replies' => array(), - 'groups' => array(), - 'tags' => array(), - 'urls' => array()); - - // Check for optional attributes... - - if (!empty($activity->time)) { - $options['created'] = common_sql_date($activity->time); - } - - if ($activity->context) { - // Any individual or group attn: targets? - - list($options['groups'], $options['replies']) = filterAttention($activity->context->attention); - - // Maintain direct reply associations - // @fixme what about conversation ID? - if (!empty($activity->context->replyToID)) { - $orig = Notice::staticGet('uri', - $activity->context->replyToID); - if (!empty($orig)) { - $options['reply_to'] = $orig->id; - } - } - - $location = $activity->context->location; - - if ($location) { - $options['lat'] = $location->lat; - $options['lon'] = $location->lon; - if ($location->location_id) { - $options['location_ns'] = $location->location_ns; - $options['location_id'] = $location->location_id; - } - } - } - - // Atom categories <-> hashtags - - foreach ($activity->categories as $cat) { - if ($cat->term) { - $term = common_canonical_tag($cat->term); - if ($term) { - $options['tags'][] = $term; - } - } - } - - // Atom enclosures -> attachment URLs - foreach ($activity->enclosures as $href) { - // @fixme save these locally or....? - $options['urls'][] = $href; - } - - $saved = Notice::saveNew($user->id, - $content, - 'restore', // TODO: restore the actual source - $options); - - return $saved; -} - -function filterAttention($attn) -{ - $groups = array(); - $replies = array(); - - foreach (array_unique($attn) as $recipient) { - - // Is the recipient a local user? - - $user = User::staticGet('uri', $recipient); - - if ($user) { - // @fixme sender verification, spam etc? - $replies[] = $recipient; - continue; - } - - // Is the recipient a remote group? - $oprofile = Ostatus_profile::ensureProfileURI($recipient); - - if ($oprofile) { - if (!$oprofile->isGroup()) { - // may be canonicalized or something - $replies[] = $oprofile->uri; - } - continue; - } - - // Is the recipient a local group? - // @fixme uri on user_group isn't reliable yet - // $group = User_group::staticGet('uri', $recipient); - $id = OStatusPlugin::localGroupFromUrl($recipient); - - if ($id) { - $group = User_group::staticGet('id', $id); - if ($group) { - // Deliver to all members of this local group if allowed. - $profile = $sender->localProfile(); - if ($profile->isMember($group)) { - $groups[] = $group->id; - } else { - common_log(LOG_INFO, "Skipping reply to local group {$group->nickname} as sender {$profile->id} is not a member"); - } - continue; - } else { - common_log(LOG_INFO, "Skipping reply to bogus group $recipient"); - } - } - } - - return array($groups, $replies); -} - -function userFromSubject($subject) -{ - $user = User::staticGet('uri', $subject->id); - - if (empty($user)) { - $attrs = - array('nickname' => Ostatus_profile::getActivityObjectNickname($subject), - 'uri' => $subject->id); - - $user = User::register($attrs); - } - - $profile = $user->getProfile(); - Ostatus_profile::updateProfile($profile, $subject); - - // FIXME: Update avatar - return $user; -} - -function purify($content) -{ - $config = array('safe' => 1, - 'deny_attribute' => 'id,style,on*'); - return htmLawed($content, $config); -} try { try { @@ -372,8 +74,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);