diff options
Diffstat (limited to 'lib')
61 files changed, 2686 insertions, 566 deletions
diff --git a/lib/accountsettingsaction.php b/lib/accountsettingsaction.php index c79a1f5d7..57740f8b8 100644 --- a/lib/accountsettingsaction.php +++ b/lib/accountsettingsaction.php @@ -105,27 +105,45 @@ class AccountSettingsNav extends Widget $user = common_current_user(); if(Event::handle('StartAccountSettingsProfileMenuItem', array($this, &$menu))){ - $this->showMenuItem('profilesettings',_('Profile'),_('Change your profile settings')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Change your profile settings'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('profilesettings',_('Profile'),$title); Event::handle('EndAccountSettingsProfileMenuItem', array($this, &$menu)); } if(Event::handle('StartAccountSettingsAvatarMenuItem', array($this, &$menu))){ - $this->showMenuItem('avatarsettings',_('Avatar'),_('Upload an avatar')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Upload an avatar'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('avatarsettings',_('Avatar'),$title); Event::handle('EndAccountSettingsAvatarMenuItem', array($this, &$menu)); } if(Event::handle('StartAccountSettingsPasswordMenuItem', array($this, &$menu))){ - $this->showMenuItem('passwordsettings',_('Password'),_('Change your password')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Change your password'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('passwordsettings',_('Password'),$title); Event::handle('EndAccountSettingsPasswordMenuItem', array($this, &$menu)); } if(Event::handle('StartAccountSettingsEmailMenuItem', array($this, &$menu))){ - $this->showMenuItem('emailsettings',_('Email'),_('Change email handling')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Change email handling'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('emailsettings',_('Email'),$title); Event::handle('EndAccountSettingsEmailMenuItem', array($this, &$menu)); } if(Event::handle('StartAccountSettingsDesignMenuItem', array($this, &$menu))){ - $this->showMenuItem('userdesignsettings',_('Design'),_('Design your profile')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Design your profile'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('userdesignsettings',_('Design'),$title); Event::handle('EndAccountSettingsDesignMenuItem', array($this, &$menu)); } if(Event::handle('StartAccountSettingsOtherMenuItem', array($this, &$menu))){ - $this->showMenuItem('othersettings',_('Other'),_('Other options')); + // TRANS: Link title attribute in user account settings menu. + $title = _('Other options'); + // TRANS: Link description in user account settings menu. + $this->showMenuItem('othersettings',_('Other'),$title); Event::handle('EndAccountSettingsOtherMenuItem', array($this, &$menu)); } diff --git a/lib/action.php b/lib/action.php index 491d7d481..2b3b707c5 100644 --- a/lib/action.php +++ b/lib/action.php @@ -141,6 +141,7 @@ class Action extends HTMLOutputter // lawsuit function showTitle() { $this->element('title', null, + // TRANS: Page title. %1$s is the title, %2$s is the site name. sprintf(_("%1\$s - %2\$s"), $this->title(), common_config('site', 'name'))); @@ -156,6 +157,7 @@ class Action extends HTMLOutputter // lawsuit function title() { + // TRANS: Page title for a page without a title set. return _("Untitled page"); } @@ -198,8 +200,7 @@ class Action extends HTMLOutputter // lawsuit if (Event::handle('StartShowStatusNetStyles', array($this)) && Event::handle('StartShowLaconicaStyles', array($this))) { - $this->cssLink('css/display.css',null,'screen, projection, tv'); - $this->cssLink('css/print.css','base','print'); + $this->cssLink('css/display.css',null, 'screen, projection, tv, print'); Event::handle('EndShowStatusNetStyles', array($this)); Event::handle('EndShowLaconicaStyles', array($this)); } @@ -234,6 +235,16 @@ class Action extends HTMLOutputter // lawsuit Event::handle('EndShowDesign', array($this)); } Event::handle('EndShowStyles', array($this)); + + if (common_config('custom_css', 'enabled')) { + $css = common_config('custom_css', 'css'); + if (Event::handle('StartShowCustomCss', array($this, &$css))) { + if (trim($css) != '') { + $this->style($css); + } + Event::handle('EndShowCustomCss', array($this)); + } + } } } @@ -421,6 +432,7 @@ class Action extends HTMLOutputter // lawsuit { $user = common_current_user(); $this->elementStart('dl', array('id' => 'site_nav_global_primary')); + // TRANS: DT element for primary navigation menu. String is hidden in default CSS. $this->element('dt', null, _('Primary site navigation')); $this->elementStart('dd'); $this->elementStart('ul', array('class' => 'nav')); @@ -428,31 +440,31 @@ class Action extends HTMLOutputter // lawsuit if ($user) { // TRANS: Tooltip for main menu option "Personal" $tooltip = _m('TOOLTIP', 'Personal profile and friends timeline'); - // TRANS: Main menu option when logged in for access to personal profile and friends timeline $this->menuItem(common_local_url('all', array('nickname' => $user->nickname)), + // TRANS: Main menu option when logged in for access to personal profile and friends timeline _m('MENU', 'Personal'), $tooltip, false, 'nav_home'); // TRANS: Tooltip for main menu option "Account" $tooltip = _m('TOOLTIP', 'Change your email, avatar, password, profile'); - // TRANS: Main menu option when logged in for access to user settings $this->menuItem(common_local_url('profilesettings'), + // TRANS: Main menu option when logged in for access to user settings _('Account'), $tooltip, false, 'nav_account'); // TRANS: Tooltip for main menu option "Services" $tooltip = _m('TOOLTIP', 'Connect to services'); - // TRANS: Main menu option when logged in and connection are possible for access to options to connect to other services $this->menuItem(common_local_url('oauthconnectionssettings'), + // TRANS: Main menu option when logged in and connection are possible for access to options to connect to other services _('Connect'), $tooltip, false, 'nav_connect'); if ($user->hasRight(Right::CONFIGURESITE)) { // TRANS: Tooltip for menu option "Admin" $tooltip = _m('TOOLTIP', 'Change site configuration'); - // TRANS: Main menu option when logged in and site admin for access to site configuration $this->menuItem(common_local_url('siteadminpanel'), + // TRANS: Main menu option when logged in and site admin for access to site configuration _m('MENU', 'Admin'), $tooltip, false, 'nav_admin'); } if (common_config('invite', 'enabled')) { // TRANS: Tooltip for main menu option "Invite" $tooltip = _m('TOOLTIP', 'Invite friends and colleagues to join you on %s'); - // TRANS: Main menu option when logged in and invitations are allowed for inviting new users $this->menuItem(common_local_url('invite'), + // TRANS: Main menu option when logged in and invitations are allowed for inviting new users _m('MENU', 'Invite'), sprintf($tooltip, common_config('site', 'name')), @@ -460,16 +472,16 @@ class Action extends HTMLOutputter // lawsuit } // TRANS: Tooltip for main menu option "Logout" $tooltip = _m('TOOLTIP', 'Logout from the site'); - // TRANS: Main menu option when logged in to log out the current user $this->menuItem(common_local_url('logout'), + // TRANS: Main menu option when logged in to log out the current user _m('MENU', 'Logout'), $tooltip, false, 'nav_logout'); } else { - if (!common_config('site', 'closed')) { + if (!common_config('site', 'closed') && !common_config('site', 'inviteonly')) { // TRANS: Tooltip for main menu option "Register" $tooltip = _m('TOOLTIP', 'Create an account'); - // TRANS: Main menu option when not logged in to register a new account $this->menuItem(common_local_url('register'), + // TRANS: Main menu option when not logged in to register a new account _m('MENU', 'Register'), $tooltip, false, 'nav_register'); } // TRANS: Tooltip for main menu option "Login" @@ -576,6 +588,7 @@ class Action extends HTMLOutputter // lawsuit function showLocalNavBlock() { $this->elementStart('dl', array('id' => 'site_nav_local_views')); + // TRANS: DT element for local views block. String is hidden in default CSS. $this->element('dt', null, _('Local views')); $this->elementStart('dd'); $this->showLocalNav(); @@ -642,6 +655,7 @@ class Action extends HTMLOutputter // lawsuit $this->elementStart('dl', array('id' => 'page_notice', 'class' => 'system_notice')); + // TRANS: DT element for page notice. String is hidden in default CSS. $this->element('dt', null, _('Page notice')); $this->elementStart('dd'); if (Event::handle('StartShowPageNotice', array($this))) { @@ -744,28 +758,37 @@ class Action extends HTMLOutputter // lawsuit function showSecondaryNav() { $this->elementStart('dl', array('id' => 'site_nav_global_secondary')); + // TRANS: DT element for secondary navigation menu. String is hidden in default CSS. $this->element('dt', null, _('Secondary site navigation')); $this->elementStart('dd', null); $this->elementStart('ul', array('class' => 'nav')); if (Event::handle('StartSecondaryNav', array($this))) { $this->menuItem(common_local_url('doc', array('title' => 'help')), + // TRANS: Secondary navigation menu option leading to help on StatusNet. _('Help')); $this->menuItem(common_local_url('doc', array('title' => 'about')), + // TRANS: Secondary navigation menu option leading to text about StatusNet site. _('About')); $this->menuItem(common_local_url('doc', array('title' => 'faq')), + // TRANS: Secondary navigation menu option leading to Frequently Asked Questions. _('FAQ')); $bb = common_config('site', 'broughtby'); if (!empty($bb)) { $this->menuItem(common_local_url('doc', array('title' => 'tos')), + // TRANS: Secondary navigation menu option leading to Terms of Service. _('TOS')); } $this->menuItem(common_local_url('doc', array('title' => 'privacy')), + // TRANS: Secondary navigation menu option leading to privacy policy. _('Privacy')); $this->menuItem(common_local_url('doc', array('title' => 'source')), + // TRANS: Secondary navigation menu option. _('Source')); $this->menuItem(common_local_url('version'), + // TRANS: Secondary navigation menu option leading to version information on the StatusNet site. _('Version')); $this->menuItem(common_local_url('doc', array('title' => 'contact')), + // TRANS: Secondary navigation menu option leading to contact information on the StatusNet site. _('Contact')); $this->menuItem(common_local_url('doc', array('title' => 'badge')), _('Badge')); @@ -796,16 +819,18 @@ class Action extends HTMLOutputter // lawsuit */ function showStatusNetLicense() { + // TRANS: DT element for StatusNet software license. $this->element('dt', array('id' => 'site_statusnet_license'), _('StatusNet software license')); $this->elementStart('dd', null); - // @fixme drop the final spaces in the messages when at good spot - // to let translations get updated. if (common_config('site', 'broughtby')) { - $instr = _('**%%site.name%%** is a microblogging service brought to you by [%%site.broughtby%%](%%site.broughtbyurl%%). '); + // TRANS: First sentence of the StatusNet site license. Used if 'broughtby' is set. + $instr = _('**%%site.name%%** is a microblogging service brought to you by [%%site.broughtby%%](%%site.broughtbyurl%%).'); } else { - $instr = _('**%%site.name%%** is a microblogging service. '); + // TRANS: First sentence of the StatusNet site license. Used if 'broughtby' is not set. + $instr = _('**%%site.name%%** is a microblogging service.'); } $instr .= ' '; + // TRANS: Second sentence of the StatusNet site license. Mentions the StatusNet source code license. $instr .= sprintf(_('It runs the [StatusNet](http://status.net/) microblogging software, version %s, available under the [GNU Affero General Public License](http://www.fsf.org/licensing/licenses/agpl-3.0.html).'), STATUSNET_VERSION); $output = common_markup_to_html($instr); $this->raw($output); @@ -821,19 +846,25 @@ class Action extends HTMLOutputter // lawsuit function showContentLicense() { if (Event::handle('StartShowContentLicense', array($this))) { + // TRANS: DT element for StatusNet site content license. $this->element('dt', array('id' => 'site_content_license'), _('Site content license')); $this->elementStart('dd', array('id' => 'site_content_license_cc')); switch (common_config('license', 'type')) { case 'private': + // TRANS: Content license displayed when license is set to 'private'. + // TRANS: %1$s is the site name. $this->element('p', null, sprintf(_('Content and data of %1$s are private and confidential.'), common_config('site', 'name'))); // fall through case 'allrightsreserved': if (common_config('license', 'owner')) { + // TRANS: Content license displayed when license is set to 'allrightsreserved'. + // TRANS: %1$s is the copyright owner. $this->element('p', null, sprintf(_('Content and data copyright by %1$s. All rights reserved.'), common_config('license', 'owner'))); } else { + // TRANS: Content license displayed when license is set to 'allrightsreserved' and no owner is set. $this->element('p', null, _('Content and data copyright by contributors. All rights reserved.')); } break; @@ -846,14 +877,16 @@ class Action extends HTMLOutputter // lawsuit 'width' => '80', 'height' => '15')); $this->text(' '); - //TODO: This is dirty: i18n - $this->text(_('All '.common_config('site', 'name').' content and data are available under the ')); - $this->element('a', array('class' => 'license', - 'rel' => 'external license', - 'href' => common_config('license', 'url')), - common_config('license', 'title')); - $this->text(' '); - $this->text(_('license.')); + // TRANS: license message in footer. %1$s is the site name, %2$s is a link to the license URL, with a licence name set in configuration. + $notice = _('All %1$s content and data are available under the %2$s license.'); + $link = "<a class=\"license\" rel=\"external license\" href=\"" . + htmlspecialchars(common_config('license', 'url')) . + "\">" . + htmlspecialchars(common_config('license', 'title')) . + "</a>"; + $this->raw(sprintf(htmlspecialchars($notice), + htmlspecialchars(common_config('site', 'name')), + $link)); $this->elementEnd('p'); break; } @@ -1147,11 +1180,15 @@ class Action extends HTMLOutputter // lawsuit * * @return nothing */ + // XXX: The messages in this pagination method only tailor to navigating + // notices. In other lists, "Previous"/"Next" type navigation is + // desirable, but not available. function pagination($have_before, $have_after, $page, $action, $args=null) { // Does a little before-after block for next/prev page if ($have_before || $have_after) { $this->elementStart('dl', 'pagination'); + // TRANS: DT element for pagination (previous/next, etc.). $this->element('dt', null, _('Pagination')); $this->elementStart('dd', null); $this->elementStart('ul', array('class' => 'nav')); @@ -1161,6 +1198,8 @@ class Action extends HTMLOutputter // lawsuit $this->elementStart('li', array('class' => 'nav_prev')); $this->element('a', array('href' => common_local_url($action, $args, $pargs), 'rel' => 'prev'), + // TRANS: Pagination message to go to a page displaying information more in the + // TRANS: present than the currently displayed information. _('After')); $this->elementEnd('li'); } @@ -1169,6 +1208,8 @@ class Action extends HTMLOutputter // lawsuit $this->elementStart('li', array('class' => 'nav_next')); $this->element('a', array('href' => common_local_url($action, $args, $pargs), 'rel' => 'next'), + // TRANS: Pagination message to go to a page displaying information more in the + // TRANS: past than the currently displayed information. _('Before')); $this->elementEnd('li'); } @@ -1212,6 +1253,8 @@ class Action extends HTMLOutputter // lawsuit * @return void */ + // XXX: Finding this type of check with the same message about 50 times. + // Possible to refactor? function checkSessionToken() { // CSRF protection diff --git a/lib/activity.php b/lib/activity.php index bd1d5d56c..8e2da99bb 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -53,6 +53,7 @@ class Activity { const SPEC = 'http://activitystrea.ms/spec/1.0/'; const SCHEMA = 'http://activitystrea.ms/schema/1.0/'; + const MEDIA = 'http://purl.org/syndication/atommedia'; const VERB = 'verb'; const OBJECT = 'object'; @@ -82,10 +83,11 @@ class Activity const CREATOR = 'creator'; const CONTENTNS = 'http://purl.org/rss/1.0/modules/content/'; + const ENCODED = 'encoded'; public $actor; // an ActivityObject public $verb; // a string (the URL) - public $object; // an ActivityObject + public $objects = array(); // an array of ActivityObjects public $target; // an ActivityObject public $context; // an ActivityObject public $time; // Time of the activity @@ -116,7 +118,8 @@ class Activity // Insist on a feed's root DOMElement; don't allow a DOMDocument if ($feed instanceof DOMDocument) { throw new ClientException( - _("Expecting a root feed element but got a whole XML document.") + // TRANS: Client exception thrown when a feed instance is a DOMDocument. + _('Expecting a root feed element but got a whole XML document.') ); } @@ -161,12 +164,15 @@ class Activity // XXX: do other implied stuff here } - $objectEl = $this->_child($entry, self::OBJECT); + $objectEls = $entry->getElementsByTagNameNS(self::SPEC, self::OBJECT); - if (!empty($objectEl)) { - $this->object = new ActivityObject($objectEl); + if ($objectEls->length > 0) { + for ($i = 0; $i < $objectEls->length; $i++) { + $objectEl = $objectEls->item($i); + $this->objects[] = new ActivityObject($objectEl); + } } else { - $this->object = new ActivityObject($entry); + $this->objects[] = new ActivityObject($entry); } $actorEl = $this->_child($entry, self::ACTOR); @@ -175,6 +181,17 @@ class Activity $this->actor = new ActivityObject($actorEl); + // Cliqset has bad actor IDs (just nickname of user). We + // work around it by getting the author data and using its + // id instead + + if (!preg_match('/^\w+:/', $this->actor->id)) { + $authorEl = ActivityUtils::child($entry, 'author'); + if (!empty($authorEl)) { + $authorObj = new ActivityObject($authorEl); + $this->actor->id = $authorObj->id; + } + } } else if (!empty($feed) && $subjectEl = $this->_child($feed, self::SUBJECT)) { @@ -253,14 +270,21 @@ class Activity $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, self::RSS); - $contentEl = ActivityUtils::child($item, ActivityUtils::CONTENT, self::CONTENTNS); + $contentEl = ActivityUtils::child($item, self::ENCODED, self::CONTENTNS); if (!empty($contentEl)) { - $this->content = htmlspecialchars_decode($contentEl->textContent, ENT_QUOTES); + // <content:encoded> XML node's text content is HTML; no further processing needed. + $this->content = $contentEl->textContent; } else { $descriptionEl = ActivityUtils::child($item, self::DESCRIPTION, self::RSS); if (!empty($descriptionEl)) { - $this->content = htmlspecialchars_decode($descriptionEl->textContent, ENT_QUOTES); + // Per spec, <description> must be plaintext. + // In practice, often there's HTML... but these days good + // feeds are using <content:encoded> which is explicitly + // real HTML. + // We'll treat this following spec, and do HTML escaping + // to convert from plaintext to HTML. + $this->content = htmlspecialchars($descriptionEl->textContent); } } @@ -280,8 +304,8 @@ class Activity } } - $this->object = new ActivityObject($item); - $this->context = new ActivityContext($item); + $this->objects[] = new ActivityObject($item); + $this->context = new ActivityContext($item); } /** @@ -339,8 +363,10 @@ class Activity $xs->element('activity:verb', null, $this->verb); - if ($this->object) { - $xs->raw($this->object->asString()); + if (!empty($this->objects)) { + foreach($this->objects as $object) { + $xs->raw($object->asString()); + } } if ($this->target) { diff --git a/lib/activitycontext.php b/lib/activitycontext.php index 2df7613f7..09a457924 100644 --- a/lib/activitycontext.php +++ b/lib/activitycontext.php @@ -51,6 +51,7 @@ class ActivityContext const POINT = 'point'; const ATTENTION = 'ostatus:attention'; + const MENTIONED = 'mentioned'; const CONVERSATION = 'ostatus:conversation'; function __construct($element) @@ -70,16 +71,22 @@ class ActivityContext $links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK); + $attention = array(); for ($i = 0; $i < $links->length; $i++) { $link = $links->item($i); $linkRel = $link->getAttribute(ActivityUtils::REL); + // XXX: Deprecate this in favour of "mentioned" from Salmon spec + // http://salmon-protocol.googlecode.com/svn/trunk/draft-panzer-salmon-00.html#SALR if ($linkRel == self::ATTENTION) { - $this->attention[] = $link->getAttribute(self::HREF); + $attention[] = $link->getAttribute(self::HREF); + } elseif ($linkRel == self::MENTIONED) { + $attention[] = $link->getAttribute(self::HREF); } } + $this->attention = array_unique($attention); } /** diff --git a/lib/activityobject.php b/lib/activityobject.php index e5cea727b..677a48197 100644 --- a/lib/activityobject.php +++ b/lib/activityobject.php @@ -100,6 +100,13 @@ class ActivityObject public $poco; public $displayName; + // @todo move this stuff to it's own PHOTO activity object + const MEDIA_DESCRIPTION = 'description'; + + public $thumbnail; + public $largerImage; + public $description; + /** * Constructor * @@ -150,13 +157,27 @@ class ActivityObject $this->poco = new PoCo($element); } + + if ($this->type == self::PHOTO) { + + $this->thumbnail = ActivityUtils::getLink($element, 'preview'); + $this->largerImage = ActivityUtils::getLink($element, 'enclosure'); + + $this->description = ActivityUtils::childContent( + $element, + ActivityObject::MEDIA_DESCRIPTION, + Activity::MEDIA + ); + + } } private function _fromAuthor($element) { $this->type = self::PERSON; // XXX: is this fair? $this->title = $this->_childContent($element, self::NAME); - $this->id = $this->_childContent($element, self::URI); + + $this->id = $this->_childContent($element, self::URI); if (empty($this->id)) { $email = $this->_childContent($element, self::EMAIL); @@ -176,7 +197,6 @@ class ActivityObject $this->type = ActivityObject::NOTE; } - $this->id = $this->_childContent($element, self::ID); $this->summary = ActivityUtils::childHtmlContent($element, self::SUMMARY); $this->content = ActivityUtils::getContent($element); @@ -189,6 +209,12 @@ class ActivityObject $this->source = $this->_getSource($element); $this->link = ActivityUtils::getPermalink($element); + + $this->id = $this->_childContent($element, self::ID); + + if (empty($this->id) && !empty($this->link)) { // fallback if there's no ID + $this->id = $this->link; + } } // @fixme rationalize with Activity::_fromRssItem() diff --git a/lib/activityutils.php b/lib/activityutils.php index c85a3db55..dd38d4e14 100644 --- a/lib/activityutils.php +++ b/lib/activityutils.php @@ -213,11 +213,19 @@ class ActivityUtils // slavishly following http://atompub.org/rfc4287.html#rfc.section.4.1.3.3 if (empty($type) || $type == 'text') { - return $el->textContent; + // We have plaintext saved as the XML text content. + // Since we want HTML, we need to escape any special chars. + return htmlspecialchars($el->textContent); } else if ($type == 'html') { + // We have HTML saved as the XML text content. + // No additional processing required once we've got it. $text = $el->textContent; - return htmlspecialchars_decode($text, ENT_QUOTES); + return $text; } else if ($type == 'xhtml') { + // Per spec, the <content type="xhtml"> contains a single + // HTML <div> with XHTML namespace on it as a child node. + // We need to pull all of that <div>'s child nodes and + // serialize them back to an (X)HTML source fragment. $divEl = ActivityUtils::child($el, 'div', 'http://www.w3.org/1999/xhtml'); if (empty($divEl)) { return null; @@ -240,4 +248,32 @@ class ActivityUtils throw new ClientException(_("Can't handle embedded Base64 content yet.")); } } + + /** + * Is this a valid URI for remote profile/notice identification? + * Does not have to be a resolvable URL. + * @param string $uri + * @return boolean + */ + static function validateUri($uri) + { + // Check mailto: URIs first + + if (preg_match('/^mailto:(.*)$/', $uri, $match)) { + return Validate::email($match[1], common_config('email', 'check_domain')); + } + + if (Validate::uri($uri)) { + return true; + } + + // Possibly an upstream bug; tag: URIs aren't validated properly + // unless you explicitly ask for them. All other schemes are accepted + // for basic URI validation without asking. + if (Validate::uri($uri, array('allowed_scheme' => array('tag')))) { + return true; + } + + return false; + } } diff --git a/lib/adminpanelaction.php b/lib/adminpanelaction.php index a927e2333..41cfe5851 100644 --- a/lib/adminpanelaction.php +++ b/lib/adminpanelaction.php @@ -69,7 +69,7 @@ class AdminPanelAction extends Action // User must be logged in. if (!common_logged_in()) { - // TRANS: Client error message + // TRANS: Client error message thrown when trying to access the admin panel while not logged in. $this->clientError(_('Not logged in.')); return false; } @@ -94,7 +94,7 @@ class AdminPanelAction extends Action // User must have the right to change admin settings if (!$user->hasRight(Right::CONFIGURESITE)) { - // TRANS: Client error message + // TRANS: Client error message thrown when a user tries to change admin settings but has no access rights. $this->clientError(_('You cannot make changes to this site.')); return false; } @@ -106,7 +106,7 @@ class AdminPanelAction extends Action $name = mb_substr($name, 0, -10); if (!self::canAdmin($name)) { - // TRANS: Client error message + // TRANS: Client error message throw when a certain panel's settings cannot be changed. $this->clientError(_('Changes to that panel are not allowed.'), 403); return false; } @@ -225,7 +225,7 @@ class AdminPanelAction extends Action function showForm() { - // TRANS: Client error message + // TRANS: Client error message. $this->clientError(_('showForm() not implemented.')); return; } @@ -279,13 +279,15 @@ class AdminPanelAction extends Action $result = $config->delete(); if (!$result) { common_log_db_error($config, 'DELETE', __FILE__); - // TRANS: Client error message + // TRANS: Client error message thrown if design settings could not be deleted in + // TRANS: the admin panel Design. $this->clientError(_("Unable to delete design setting.")); return null; } + return $result; } - return $result; + return null; } function canAdmin($name) diff --git a/lib/apiaction.php b/lib/apiaction.php index e6aaf9316..479a86ad8 100644 --- a/lib/apiaction.php +++ b/lib/apiaction.php @@ -27,11 +27,73 @@ * @author Jeffery To <jeffery.to@gmail.com> * @author Toby Inkster <mail@tobyinkster.co.uk> * @author Zach Copley <zach@status.net> - * @copyright 2009 StatusNet, Inc. + * @copyright 2009-2010 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ +/* External API usage documentation. Please update when you change how the API works. */ + +/*! @mainpage StatusNet REST API + + @section Introduction + + Some explanatory text about the API would be nice. + + @section API Methods + + @subsection timelinesmethods_sec Timeline Methods + + @li @ref publictimeline + @li @ref friendstimeline + + @subsection statusmethods_sec Status Methods + + @li @ref statusesupdate + + @subsection usermethods_sec User Methods + + @subsection directmessagemethods_sec Direct Message Methods + + @subsection friendshipmethods_sec Friendship Methods + + @subsection socialgraphmethods_sec Social Graph Methods + + @subsection accountmethods_sec Account Methods + + @subsection favoritesmethods_sec Favorites Methods + + @subsection blockmethods_sec Block Methods + + @subsection oauthmethods_sec OAuth Methods + + @subsection helpmethods_sec Help Methods + + @subsection groupmethods_sec Group Methods + + @page apiroot API Root + + The URLs for methods referred to in this API documentation are + relative to the StatusNet API root. The API root is determined by the + site's @b server and @b path variables, which are generally specified + in config.php. For example: + + @code + $config['site']['server'] = 'example.org'; + $config['site']['path'] = 'statusnet' + @endcode + + The pattern for a site's API root is: @c protocol://server/path/api E.g: + + @c http://example.org/statusnet/api + + The @b path can be empty. In that case the API root would simply be: + + @c http://example.org/api + +*/ + if (!defined('STATUSNET')) { exit(1); } @@ -63,9 +125,13 @@ class ApiAction extends Action var $count = null; var $max_id = null; var $since_id = null; + var $source = null; + var $callback = null; var $access = self::READ_ONLY; // read (default) or read-write + static $reserved_sources = array('web', 'omb', 'ostatus', 'mail', 'xmpp', 'api'); + /** * Initialization. * @@ -80,6 +146,7 @@ class ApiAction extends Action parent::prepare($args); $this->format = $this->arg('format'); + $this->callback = $this->arg('callback'); $this->page = (int)$this->arg('page', 1); $this->count = (int)$this->arg('count', 20); $this->max_id = (int)$this->arg('max_id', 0); @@ -89,6 +156,12 @@ class ApiAction extends Action header('X-StatusNet-Warning: since parameter is disabled; use since_id'); } + $this->source = $this->trimmed('source'); + + if (empty($this->source) || in_array($this->source, self::$reserved_sources)) { + $this->source = 'api'; + } + return true; } @@ -102,6 +175,7 @@ class ApiAction extends Action function handle($args) { + header('Access-Control-Allow-Origin: *'); parent::handle($args); } @@ -199,11 +273,13 @@ class ApiAction extends Action // Is the requesting user following this user? $twitter_user['following'] = false; + $twitter_user['statusnet:blocking'] = false; $twitter_user['notifications'] = false; if (isset($this->auth_user)) { $twitter_user['following'] = $this->auth_user->isSubscribed($profile); + $twitter_user['statusnet:blocking'] = $this->auth_user->hasBlocked($profile); // Notifications on? $sub = Subscription::pkeyGet(array('subscriber' => @@ -223,6 +299,10 @@ class ApiAction extends Action } } + // StatusNet-specific + + $twitter_user['statusnet:profile_url'] = $profile->profileurl; + return $twitter_user; } @@ -251,7 +331,23 @@ class ApiAction extends Action $twitter_status['created_at'] = $this->dateTwitter($notice->created); $twitter_status['in_reply_to_status_id'] = ($notice->reply_to) ? intval($notice->reply_to) : null; - $twitter_status['source'] = $this->sourceLink($notice->source); + + $source = null; + + $ns = $notice->getSource(); + if ($ns) { + if (!empty($ns->name) && !empty($ns->url)) { + $source = '<a href="' + . htmlspecialchars($ns->url) + . '" rel="nofollow">' + . htmlspecialchars($ns->name) + . '</a>'; + } else { + $source = $ns->code; + } + } + + $twitter_status['source'] = $source; $twitter_status['id'] = intval($notice->id); $replier_profile = null; @@ -308,26 +404,41 @@ class ApiAction extends Action $twitter_status['user'] = $twitter_user; } + // StatusNet-specific + + $twitter_status['statusnet:html'] = $notice->rendered; + return $twitter_status; } function twitterGroupArray($group) { - $twitter_group=array(); - $twitter_group['id']=$group->id; - $twitter_group['url']=$group->permalink(); - $twitter_group['nickname']=$group->nickname; - $twitter_group['fullname']=$group->fullname; - $twitter_group['homepage_url']=$group->homepage_url; - $twitter_group['original_logo']=$group->original_logo; - $twitter_group['homepage_logo']=$group->homepage_logo; - $twitter_group['stream_logo']=$group->stream_logo; - $twitter_group['mini_logo']=$group->mini_logo; - $twitter_group['homepage']=$group->homepage; - $twitter_group['description']=$group->description; - $twitter_group['location']=$group->location; - $twitter_group['created']=$this->dateTwitter($group->created); - $twitter_group['modified']=$this->dateTwitter($group->modified); + $twitter_group = array(); + + $twitter_group['id'] = $group->id; + $twitter_group['url'] = $group->permalink(); + $twitter_group['nickname'] = $group->nickname; + $twitter_group['fullname'] = $group->fullname; + + if (isset($this->auth_user)) { + $twitter_group['member'] = $this->auth_user->isMember($group); + $twitter_group['blocked'] = Group_block::isBlocked( + $group, + $this->auth_user->getProfile() + ); + } + + $twitter_group['member_count'] = $group->getMemberCount(); + $twitter_group['original_logo'] = $group->original_logo; + $twitter_group['homepage_logo'] = $group->homepage_logo; + $twitter_group['stream_logo'] = $group->stream_logo; + $twitter_group['mini_logo'] = $group->mini_logo; + $twitter_group['homepage'] = $group->homepage; + $twitter_group['description'] = $group->description; + $twitter_group['location'] = $group->location; + $twitter_group['created'] = $this->dateTwitter($group->created); + $twitter_group['modified'] = $this->dateTwitter($group->modified); + return $twitter_group; } @@ -352,6 +463,7 @@ class ApiAction extends Action function twitterRssEntryArray($notice) { $profile = $notice->getProfile(); + $entry = array(); // We trim() to avoid extraneous whitespace in the output @@ -476,9 +588,13 @@ class ApiAction extends Action } } - function showTwitterXmlStatus($twitter_status, $tag='status') + function showTwitterXmlStatus($twitter_status, $tag='status', $namespaces=false) { - $this->elementStart($tag); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart($tag, $attrs); foreach($twitter_status as $element => $value) { switch ($element) { case 'user': @@ -512,9 +628,13 @@ class ApiAction extends Action $this->elementEnd('group'); } - function showTwitterXmlUser($twitter_user, $role='user') + function showTwitterXmlUser($twitter_user, $role='user', $namespaces=false) { - $this->elementStart($role); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart($role, $attrs); foreach($twitter_user as $element => $value) { if ($element == 'status') { $this->showTwitterXmlStatus($twitter_user['status']); @@ -596,7 +716,7 @@ class ApiAction extends Action { $this->initDocument('xml'); $twitter_status = $this->twitterStatusArray($notice); - $this->showTwitterXmlStatus($twitter_status); + $this->showTwitterXmlStatus($twitter_status, 'status', true); $this->endDocument('xml'); } @@ -612,17 +732,20 @@ class ApiAction extends Action { $this->initDocument('xml'); - $this->elementStart('statuses', array('type' => 'array')); + $this->elementStart('statuses', array('type' => 'array', + 'xmlns:statusnet' => 'http://status.net/schema/api/1/')); if (is_array($notice)) { - foreach ($notice as $n) { - $twitter_status = $this->twitterStatusArray($n); - $this->showTwitterXmlStatus($twitter_status); - } - } else { - while ($notice->fetch()) { + $notice = new ArrayWrapper($notice); + } + + while ($notice->fetch()) { + try { $twitter_status = $this->twitterStatusArray($notice); $this->showTwitterXmlStatus($twitter_status); + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + continue; } } @@ -670,14 +793,16 @@ class ApiAction extends Action $this->element('ttl', null, '40'); if (is_array($notice)) { - foreach ($notice as $n) { - $entry = $this->twitterRssEntryArray($n); - $this->showTwitterRssItem($entry); - } - } else { - while ($notice->fetch()) { + $notice = new ArrayWrapper($notice); + } + + while ($notice->fetch()) { + try { $entry = $this->twitterRssEntryArray($notice); $this->showTwitterRssItem($entry); + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + // continue on exceptions } } @@ -713,12 +838,15 @@ class ApiAction extends Action $this->element('subtitle', null, $subtitle); if (is_array($notice)) { - foreach ($notice as $n) { - $this->raw($n->asAtomEntry()); - } - } else { - while ($notice->fetch()) { + $notice = new ArrayWrapper($notice); + } + + while ($notice->fetch()) { + try { $this->raw($notice->asAtomEntry()); + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + continue; } } @@ -779,9 +907,13 @@ class ApiAction extends Action $this->elementEnd('entry'); } - function showXmlDirectMessage($dm) + function showXmlDirectMessage($dm, $namespaces=false) { - $this->elementStart('direct_message'); + $attrs = array(); + if ($namespaces) { + $attrs['xmlns:statusnet'] = 'http://status.net/schema/api/1/'; + } + $this->elementStart('direct_message', $attrs); foreach($dm as $element => $value) { switch ($element) { case 'sender': @@ -858,7 +990,7 @@ class ApiAction extends Action { $this->initDocument('xml'); $dmsg = $this->directMessageArray($message); - $this->showXmlDirectMessage($dmsg); + $this->showXmlDirectMessage($dmsg, true); $this->endDocument('xml'); } @@ -909,14 +1041,16 @@ class ApiAction extends Action $statuses = array(); if (is_array($notice)) { - foreach ($notice as $n) { - $twitter_status = $this->twitterStatusArray($n); - array_push($statuses, $twitter_status); - } - } else { - while ($notice->fetch()) { + $notice = new ArrayWrapper($notice); + } + + while ($notice->fetch()) { + try { $twitter_status = $this->twitterStatusArray($notice); array_push($statuses, $twitter_status); + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + continue; } } @@ -975,7 +1109,8 @@ class ApiAction extends Action { $this->initDocument('xml'); - $this->elementStart('users', array('type' => 'array')); + $this->elementStart('users', array('type' => 'array', + 'xmlns:statusnet' => 'http://status.net/schema/api/1/')); if (is_array($user)) { foreach ($user as $u) { @@ -1052,9 +1187,8 @@ class ApiAction extends Action header('Content-Type: application/json; charset=utf-8'); // Check for JSONP callback - $callback = $this->arg('callback'); - if ($callback) { - print $callback . '('; + if (isset($this->callback)) { + print $this->callback . '('; } break; case 'rss': @@ -1066,6 +1200,7 @@ class ApiAction extends Action $this->initTwitterAtom(); break; default: + // TRANS: Client error on an API request with an unsupported data format. $this->clientError(_('Not a supported data format.')); break; } @@ -1082,8 +1217,7 @@ class ApiAction extends Action case 'json': // Check for JSONP callback - $callback = $this->arg('callback'); - if ($callback) { + if (isset($this->callback)) { print ')'; } break; @@ -1094,6 +1228,7 @@ class ApiAction extends Action $this->endTwitterRss(); break; default: + // TRANS: Client error on an API request with an unsupported data format. $this->clientError(_('Not a supported data format.')); break; } @@ -1112,7 +1247,10 @@ class ApiAction extends Action $status_string = ClientErrorAction::$status[$code]; - header('HTTP/1.1 '.$code.' '.$status_string); + // Do not emit error header for JSONP + if (!isset($this->callback)) { + header('HTTP/1.1 '.$code.' '.$status_string); + } if ($format == 'xml') { $this->initDocument('xml'); @@ -1145,7 +1283,10 @@ class ApiAction extends Action $status_string = ServerErrorAction::$status[$code]; - header('HTTP/1.1 '.$code.' '.$status_string); + // Do not emit error header for JSONP + if (!isset($this->callback)) { + header('HTTP/1.1 '.$code.' '.$status_string); + } if ($content_type == 'xml') { $this->initDocument('xml'); @@ -1210,6 +1351,7 @@ class ApiAction extends Action $this->showJsonObjects($profile_array); break; default: + // TRANS: Client error on an API request with an unsupported data format. $this->clientError(_('Not a supported data format.')); return; } @@ -1248,6 +1390,34 @@ class ApiAction extends Action } } + function getTargetProfile($id) + { + if (empty($id)) { + + // Twitter supports these other ways of passing the user ID + if (is_numeric($this->arg('id'))) { + return Profile::staticGet($this->arg('id')); + } else if ($this->arg('id')) { + $nickname = common_canonical_nickname($this->arg('id')); + return Profile::staticGet('nickname', $nickname); + } else if ($this->arg('user_id')) { + // This is to ensure that a non-numeric user_id still + // overrides screen_name even if it doesn't get used + if (is_numeric($this->arg('user_id'))) { + return Profile::staticGet('id', $this->arg('user_id')); + } + } else if ($this->arg('screen_name')) { + $nickname = common_canonical_nickname($this->arg('screen_name')); + return Profile::staticGet('nickname', $nickname); + } + } else if (is_numeric($id)) { + return Profile::staticGet($id); + } else { + $nickname = common_canonical_nickname($id); + return Profile::staticGet('nickname', $nickname); + } + } + function getTargetGroup($id) { if (empty($id)) { @@ -1273,7 +1443,7 @@ class ApiAction extends Action if (empty($local)) { return null; } else { - return User_group::staticGet('id', $local->id); + return User_group::staticGet('id', $local->group_id); } } @@ -1290,43 +1460,6 @@ class ApiAction extends Action } } - function sourceLink($source) - { - $source_name = _($source); - switch ($source) { - case 'web': - case 'xmpp': - case 'mail': - case 'omb': - case 'api': - break; - default: - - $name = null; - $url = null; - - $ns = Notice_source::staticGet($source); - - if ($ns) { - $name = $ns->name; - $url = $ns->url; - } else { - $app = Oauth_application::staticGet('name', $source); - if ($app) { - $name = $app->name; - $url = $app->source_url; - } - } - - if (!empty($name) && !empty($url)) { - $source_name = '<a href="' . $url . '">' . $name . '</a>'; - } - - break; - } - return $source_name; - } - /** * Returns query argument or default value if not found. Certain * parameters used throughout the API are lightly scrubbed and diff --git a/lib/apiauth.php b/lib/apiauth.php index 17f803a1c..cf7a2692c 100644 --- a/lib/apiauth.php +++ b/lib/apiauth.php @@ -30,10 +30,29 @@ * @author Sarven Capadisli <csarven@status.net> * @author Zach Copley <zach@status.net> * @copyright 2009-2010 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ +/* External API usage documentation. Please update when you change how this method works. */ + +/*! @page authentication Authentication + + StatusNet supports HTTP Basic Authentication and OAuth for API calls. + + @warning Currently, users who have created accounts without setting a + password via OpenID, Facebook Connect, etc., cannot use the API until + they set a password with their account settings panel. + + @section HTTP Basic Auth + + + + @section OAuth + +*/ + if (!defined('STATUSNET')) { exit(1); } @@ -54,7 +73,6 @@ class ApiAuthAction extends ApiAction { var $auth_user_nickname = null; var $auth_user_password = null; - var $oauth_source = null; /** * Take arguments for running, looks for an OAuth request, @@ -91,6 +109,7 @@ class ApiAuthAction extends ApiAction if ($this->isReadOnly($args) == false) { if ($this->access != self::READ_WRITE) { + // TRANS: Client error 401. $msg = _('API resource requires read-write access, ' . 'but you only have read access.'); $this->clientError($msg, 401, $this->format); @@ -162,7 +181,7 @@ class ApiAuthAction extends ApiAction // set the source attr - $this->oauth_source = $app->name; + $this->source = $app->name; $appUser = Oauth_application_user::staticGet('token', $access_token); @@ -208,7 +227,7 @@ class ApiAuthAction extends ApiAction } catch (OAuthException $e) { common_log(LOG_WARNING, 'API OAuthException - ' . $e->getMessage()); - $this->showAuthError(); + $this->clientError($e->getMessage(), 401, $this->format); exit; } } @@ -246,7 +265,7 @@ class ApiAuthAction extends ApiAction // show error if the user clicks 'cancel' - $this->showAuthError(); + $this->clientError("Could not authenticate you.", 401, $this->format); exit; } else { @@ -267,19 +286,19 @@ class ApiAuthAction extends ApiAction $this->access = self::READ_WRITE; - if (empty($this->auth_user) && $required) { + if (empty($this->auth_user) && ($required || isset($_SERVER['PHP_AUTH_USER']))) { // basic authentication failed list($proxy, $ip) = common_client_ip(); - $msg = sprintf(_('Failed API auth attempt, nickname = %1$s, ' . - 'proxy = %2$s, ip = %3$s'), + $msg = sprintf( 'Failed API auth attempt, nickname = %1$s, ' . + 'proxy = %2$s, ip = %3$s', $this->auth_user_nickname, $proxy, $ip); common_log(LOG_WARNING, $msg); - $this->showAuthError(); + $this->clientError("Could not authenticate you.", 401, $this->format); exit; } } @@ -326,36 +345,4 @@ class ApiAuthAction extends ApiAction } } } - - /** - * Output an authentication error message. Use XML or JSON if one - * of those formats is specified, otherwise output plain text - * - * @return void - */ - - function showAuthError() - { - header('HTTP/1.1 401 Unauthorized'); - $msg = 'Could not authenticate you.'; - - if ($this->format == 'xml') { - header('Content-Type: application/xml; charset=utf-8'); - $this->startXML(); - $this->elementStart('hash'); - $this->element('error', null, $msg); - $this->element('request', null, $_SERVER['REQUEST_URI']); - $this->elementEnd('hash'); - $this->endXML(); - } elseif ($this->format == 'json') { - header('Content-Type: application/json; charset=utf-8'); - $error_array = array('error' => $msg, - 'request' => $_SERVER['REQUEST_URI']); - print(json_encode($error_array)); - } else { - header('Content-type: text/plain'); - print "$msg\n"; - } - } - } diff --git a/lib/apibareauth.php b/lib/apibareauth.php index 2d29c1ddd..da7af1261 100644 --- a/lib/apibareauth.php +++ b/lib/apibareauth.php @@ -32,6 +32,7 @@ * @author Sarven Capadisli <csarven@status.net> * @author Zach Copley <zach@status.net> * @copyright 2009 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ @@ -106,4 +107,4 @@ class ApiBareAuthAction extends ApiAuthAction return false; } -}
\ No newline at end of file +} diff --git a/lib/apiprivateauth.php b/lib/apiprivateauth.php index 5d0033005..5e78c65a1 100644 --- a/lib/apiprivateauth.php +++ b/lib/apiprivateauth.php @@ -31,6 +31,7 @@ * @author Sarven Capadisli <csarven@status.net> * @author Zach Copley <zach@status.net> * @copyright 2009 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ diff --git a/lib/applicationeditform.php b/lib/applicationeditform.php index 9b7d05861..81c8fb018 100644 --- a/lib/applicationeditform.php +++ b/lib/applicationeditform.php @@ -133,6 +133,7 @@ class ApplicationEditForm extends Form function formLegend() { + // TRANS: Form legend. $this->out->element('legend', null, _('Edit application')); } @@ -177,10 +178,12 @@ class ApplicationEditForm extends Form } $this->out->element('label', array('for' => 'app_icon'), + // TRANS: Form input field label for application icon. _('Icon')); $this->out->element('input', array('name' => 'app_icon', 'type' => 'file', 'id' => 'app_icon')); + // TRANS: Form guide. $this->out->element('p', 'form_guide', _('Icon for this application')); $this->out->element('input', array('name' => 'MAX_FILE_SIZE', 'type' => 'hidden', @@ -192,6 +195,7 @@ class ApplicationEditForm extends Form $this->out->hidden('application_id', $id); + // TRANS: Form input field label for application name. $this->out->input('name', _('Name'), ($this->out->arg('name')) ? $this->out->arg('name') : $name); @@ -201,11 +205,14 @@ class ApplicationEditForm extends Form $maxDesc = Oauth_application::maxDesc(); if ($maxDesc > 0) { + // TRANS: Form input field instructions. $descInstr = sprintf(_('Describe your application in %d characters'), $maxDesc); } else { + // TRANS: Form input field instructions. $descInstr = _('Describe your application'); } + // TRANS: Form input field label. $this->out->textarea('description', _('Description'), ($this->out->arg('description')) ? $this->out->arg('description') : $description, $descInstr); @@ -213,27 +220,39 @@ class ApplicationEditForm extends Form $this->out->elementEnd('li'); $this->out->elementStart('li'); + // TRANS: Form input field instructions. + $instruction = _('URL of the homepage of this application'); + // TRANS: Form input field label. $this->out->input('source_url', _('Source URL'), ($this->out->arg('source_url')) ? $this->out->arg('source_url') : $source_url, - _('URL of the homepage of this application')); + $instruction); $this->out->elementEnd('li'); $this->out->elementStart('li'); + // TRANS: Form input field instructions. + $instruction = _('Organization responsible for this application'); + // TRANS: Form input field label. $this->out->input('organization', _('Organization'), ($this->out->arg('organization')) ? $this->out->arg('organization') : $organization, - _('Organization responsible for this application')); + $instruction); $this->out->elementEnd('li'); $this->out->elementStart('li'); + // TRANS: Form input field instructions. + $instruction = _('URL for the homepage of the organization'); + // TRANS: Form input field label. $this->out->input('homepage', _('Homepage'), ($this->out->arg('homepage')) ? $this->out->arg('homepage') : $homepage, - _('URL for the homepage of the organization')); + $instruction); $this->out->elementEnd('li'); $this->out->elementStart('li'); + // TRANS: Form input field instructions. + $instruction = _('URL to redirect to after authentication'); + // TRANS: Form input field label. $this->out->input('callback_url', ('Callback URL'), ($this->out->arg('callback_url')) ? $this->out->arg('callback_url') : $callback_url, - _('URL to redirect to after authentication')); + $instruction); $this->out->elementEnd('li'); $this->out->elementStart('li', array('id' => 'application_types')); @@ -255,6 +274,7 @@ class ApplicationEditForm extends Form $this->out->element('label', array('for' => 'app_type-browser', 'class' => 'radio'), + // TRANS: Radio button label for application type _('Browser')); $attrs = array('name' => 'app_type', @@ -271,7 +291,9 @@ class ApplicationEditForm extends Form $this->out->element('label', array('for' => 'app_type-desktop', 'class' => 'radio'), + // TRANS: Radio button label for application type _('Desktop')); + // TRANS: Form guide. $this->out->element('p', 'form_guide', _('Type of application, browser or desktop')); $this->out->elementEnd('li'); @@ -294,6 +316,7 @@ class ApplicationEditForm extends Form $this->out->element('label', array('for' => 'default_access_type-ro', 'class' => 'radio'), + // TRANS: Radio button label for access type. _('Read-only')); $attrs = array('name' => 'default_access_type', @@ -312,7 +335,9 @@ class ApplicationEditForm extends Form $this->out->element('label', array('for' => 'default_access_type-rw', 'class' => 'radio'), + // TRANS: Radio button label for access type. _('Read-write')); + // TRANS: Form guide. $this->out->element('p', 'form_guide', _('Default access for this application: read-only, or read-write')); $this->out->elementEnd('li'); @@ -328,9 +353,13 @@ class ApplicationEditForm extends Form function formActions() { - $this->out->submit('cancel', _('Cancel'), 'submit form_action-primary', + // TRANS: Button label + $this->out->submit('cancel', _m('BUTTON','Cancel'), 'submit form_action-primary', + // TRANS: Submit button title 'cancel', _('Cancel')); - $this->out->submit('save', _('Save'), 'submit form_action-secondary', + // TRANS: Button label + $this->out->submit('save', _m('BUTTON','Save'), 'submit form_action-secondary', + // TRANS: Submit button title 'save', _('Save')); } } diff --git a/lib/applicationlist.php b/lib/applicationlist.php index 3abb1f8aa..904f8981d 100644 --- a/lib/applicationlist.php +++ b/lib/applicationlist.php @@ -88,7 +88,6 @@ class ApplicationList extends Widget function showApplication() { - $user = common_current_user(); $this->out->elementStart('li', array('class' => 'application', @@ -133,11 +132,16 @@ class ApplicationList extends Widget $this->out->elementStart('li'); - $access = ($this->application->access_type & Oauth_application::$writeAccess) - ? 'read-write' : 'read-only'; + // TRANS: Application access type + $readWriteText = _('read-write'); + // TRANS: Application access type + $readOnlyText = _('read-only'); - $txt = 'Approved ' . common_date_string($appUser->modified) . - " - $access access."; + $access = ($this->application->access_type & Oauth_application::$writeAccess) + ? $readWriteText : $readOnlyText; + $modifiedDate = common_date_string($appUser->modified); + // TRANS: Used in application list. %1$s is a modified date, %2$s is access type (read-write or read-only) + $txt = sprintf(_('Approved %1$s - "%2$s" access.'),$modifiedDate,$access); $this->out->raw($txt); $this->out->elementEnd('li'); @@ -151,7 +155,8 @@ class ApplicationList extends Widget $this->out->elementStart('fieldset'); $this->out->hidden('id', $this->application->id); $this->out->hidden('token', common_session_token()); - $this->out->submit('revoke', _('Revoke')); + // TRANS: Button label + $this->out->submit('revoke', _m('BUTTON','Revoke')); $this->out->elementEnd('fieldset'); $this->out->elementEnd('form'); $this->out->elementEnd('li'); diff --git a/lib/atomgroupnoticefeed.php b/lib/atomgroupnoticefeed.php index 08c1c707c..39a1fd456 100644 --- a/lib/atomgroupnoticefeed.php +++ b/lib/atomgroupnoticefeed.php @@ -50,19 +50,23 @@ class AtomGroupNoticeFeed extends AtomNoticeFeed * Constructor * * @param Group $group the group for the feed + * @param User $cur the current authenticated user, if any * @param boolean $indent flag to turn indenting on or off * * @return void */ - function __construct($group, $indent = true) { - parent::__construct($indent); + function __construct($group, $cur = null, $indent = true) { + parent::__construct($cur, $indent); $this->group = $group; + // TRANS: Title in atom group notice feed. %s is a group name. $title = sprintf(_("%s timeline"), $group->nickname); $this->setTitle($title); $sitename = common_config('site', 'name'); $subtitle = sprintf( + // TRANS: Message is used as a subtitle in atom group notice feed. + // TRANS: %1$s is a group name, %2$s is a site name. _('Updates from %1$s on %2$s!'), $group->nickname, $sitename @@ -92,4 +96,23 @@ class AtomGroupNoticeFeed extends AtomNoticeFeed return $this->group; } + function initFeed() + { + parent::initFeed(); + + $attrs = array(); + + if (!empty($this->cur)) { + $attrs['member'] = $this->cur->isMember($this->group) + ? 'true' : 'false'; + $attrs['blocked'] = Group_block::isBlocked( + $this->group, + $this->cur->getProfile() + ) ? 'true' : 'false'; + } + + $attrs['member_count'] = $this->group->getMemberCount(); + + $this->element('statusnet:group_info', $attrs, null); + } } diff --git a/lib/atomnoticefeed.php b/lib/atomnoticefeed.php index e4df731fe..b88217291 100644 --- a/lib/atomnoticefeed.php +++ b/lib/atomnoticefeed.php @@ -44,9 +44,22 @@ if (!defined('STATUSNET')) */ class AtomNoticeFeed extends Atom10Feed { - function __construct($indent = true) { + var $cur; + + /** + * Constructor - adds a bunch of XML namespaces we need in our + * notice-specific Atom feeds, and allows setting the current + * authenticated user (useful for API methods). + * + * @param User $cur the current authenticated user (optional) + * @param boolean $indent Whether to indent XML output + * + */ + function __construct($cur = null, $indent = true) { parent::__construct($indent); + $this->cur = $cur; + // Feeds containing notice info use these namespaces $this->addNamespace( @@ -79,6 +92,11 @@ class AtomNoticeFeed extends Atom10Feed 'ostatus', 'http://ostatus.org/schema/1.0' ); + + $this->addNamespace( + 'statusnet', + 'http://status.net/schema/api/1/' + ); } /** @@ -107,10 +125,17 @@ class AtomNoticeFeed extends Atom10Feed */ function addEntryFromNotice($notice) { - $source = $this->showSource(); - $author = $this->showAuthor(); + try { + $source = $this->showSource(); + $author = $this->showAuthor(); - $this->addEntryRaw($notice->asAtomEntry(false, $source, $author)); + $cur = empty($this->cur) ? common_current_user() : $this->cur; + + $this->addEntryRaw($notice->asAtomEntry(false, $source, $author, $cur)); + } catch (Exception $e) { + common_log(LOG_ERR, $e->getMessage()); + // we continue on exceptions + } } function showSource() diff --git a/lib/atomusernoticefeed.php b/lib/atomusernoticefeed.php index 428cc2de2..785db4915 100644 --- a/lib/atomusernoticefeed.php +++ b/lib/atomusernoticefeed.php @@ -50,13 +50,14 @@ class AtomUserNoticeFeed extends AtomNoticeFeed * Constructor * * @param User $user the user for the feed + * @param User $cur the current authenticated user, if any * @param boolean $indent flag to turn indenting on or off * * @return void */ - function __construct($user, $indent = true) { - parent::__construct($indent); + function __construct($user, $cur = null, $indent = true) { + parent::__construct($cur, $indent); $this->user = $user; if (!empty($user)) { $profile = $user->getProfile(); @@ -64,11 +65,14 @@ class AtomUserNoticeFeed extends AtomNoticeFeed $this->setActivitySubject($profile->asActivityNoun('subject')); } + // TRANS: Title in atom user notice feed. %s is a user name. $title = sprintf(_("%s timeline"), $user->nickname); $this->setTitle($title); $sitename = common_config('site', 'name'); $subtitle = sprintf( + // TRANS: Message is used as a subtitle in atom user notice feed. + // TRANS: %1$s is a user name, %2$s is a site name. _('Updates from %1$s on %2$s!'), $user->nickname, $sitename ); diff --git a/lib/attachmentlist.php b/lib/attachmentlist.php index 51ceca857..59cab9532 100644 --- a/lib/attachmentlist.php +++ b/lib/attachmentlist.php @@ -84,6 +84,7 @@ class AttachmentList extends Widget if (empty($att)) return 0; $this->out->elementStart('dl', array('id' =>'attachments', 'class' => 'entry-content')); + // TRANS: DT element label in attachment list. $this->out->element('dt', null, _('Attachments')); $this->out->elementStart('dd'); $this->out->elementStart('ol', array('class' => 'attachments')); @@ -248,9 +249,7 @@ class Attachment extends AttachmentListItem $this->out->elementStart('div', array('id' => 'attachment_view', 'class' => 'hentry')); $this->out->elementStart('div', 'entry-title'); - $this->out->elementStart('a', $this->linkAttr()); - $this->out->element('span', null, $this->linkTitle()); - $this->out->elementEnd('a'); + $this->out->element('a', $this->linkAttr(), $this->linkTitle()); $this->out->elementEnd('div'); $this->out->elementStart('div', 'entry-content'); @@ -262,6 +261,7 @@ class Attachment extends AttachmentListItem 'class' => 'entry-content')); if (!empty($this->oembed->author_name)) { $this->out->elementStart('dl', 'vcard author'); + // TRANS: DT element label in attachment list item. $this->out->element('dt', null, _('Author')); $this->out->elementStart('dd', 'fn'); if (empty($this->oembed->author_url)) { @@ -275,6 +275,7 @@ class Attachment extends AttachmentListItem } if (!empty($this->oembed->provider)) { $this->out->elementStart('dl', 'vcard'); + // TRANS: DT element label in attachment list item. $this->out->element('dt', null, _('Provider')); $this->out->elementStart('dd', 'fn'); if (empty($this->oembed->provider_url)) { @@ -296,7 +297,7 @@ class Attachment extends AttachmentListItem } function linkAttr() { - return array('class' => 'external', 'href' => $this->attachment->url); + return array('rel' => 'external', 'href' => $this->attachment->url); } function linkTitle() { @@ -306,7 +307,7 @@ class Attachment extends AttachmentListItem function showRepresentation() { if (empty($this->oembed->type)) { if (empty($this->attachment->mimetype)) { - $this->out->element('pre', null, 'oh well... not sure how to handle the following: ' . print_r($this->attachment, true)); + $this->showFallback(); } else { switch ($this->attachment->mimetype) { case 'image/gif': @@ -332,6 +333,17 @@ class Attachment extends AttachmentListItem $this->out->element('param', array('name' => 'autoStart', 'value' => 1)); $this->out->elementEnd('object'); break; + + case 'text/html': + if ($this->attachment->filename) { + // Locally-uploaded HTML. Scrub and display inline. + $this->showHtmlFile($this->attachment); + break; + } + // Fall through to default. + + default: + $this->showFallback(); } } } else { @@ -354,9 +366,76 @@ class Attachment extends AttachmentListItem break; default: - $this->out->element('pre', null, 'oh well... not sure how to handle the following oembed: ' . print_r($this->oembed, true)); + $this->showFallback(); } } } + + protected function showHtmlFile(File $attachment) + { + $body = $this->scrubHtmlFile($attachment); + if ($body) { + $this->out->raw($body); + } + } + + /** + * @return mixed false on failure, HTML fragment string on success + */ + protected function scrubHtmlFile(File $attachment) + { + $path = File::path($attachment->filename); + if (!file_exists($path) || !is_readable($path)) { + common_log(LOG_ERR, "Missing local HTML attachment $path"); + return false; + } + $raw = file_get_contents($path); + + // Normalize... + $dom = new DOMDocument(); + if(!$dom->loadHTML($raw)) { + common_log(LOG_ERR, "Bad HTML in local HTML attachment $path"); + return false; + } + + // Remove <script>s or htmlawed will dump their contents into output! + // Note: removing child nodes while iterating seems to mess things up, + // hence the double loop. + $scripts = array(); + foreach ($dom->getElementsByTagName('script') as $script) { + $scripts[] = $script; + } + foreach ($scripts as $script) { + common_log(LOG_DEBUG, $script->textContent); + $script->parentNode->removeChild($script); + } + + // Trim out everything outside the body... + $body = $dom->saveHTML(); + $body = preg_replace('/^.*<body[^>]*>/is', '', $body); + $body = preg_replace('/<\/body[^>]*>.*$/is', '', $body); + + require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php'; + $config = array('safe' => 1, + 'deny_attribute' => 'id,style,on*', + 'comment' => 1); // remove comments + $scrubbed = htmLawed($body, $config); + + return $scrubbed; + } + + function showFallback() + { + // If we don't know how to display an attachment inline, we probably + // shouldn't have gotten to this point. + // + // But, here we are... displaying details on a file or remote URL + // either on the main view or in an ajax-loaded lightbox. As a lesser + // of several evils, we'll try redirecting to the actual target via + // client-side JS. + + common_log(LOG_ERR, "Empty or unknown type for file id {$this->attachment->id}; falling back to client-side redirect."); + $this->out->raw('<script>window.location = ' . json_encode($this->attachment->url) . ';</script>'); + } } diff --git a/lib/authenticationplugin.php b/lib/authenticationplugin.php index 0a3763e2e..dbdf20629 100644 --- a/lib/authenticationplugin.php +++ b/lib/authenticationplugin.php @@ -22,6 +22,7 @@ * @category Plugin * @package StatusNet * @author Craig Andrews <candrews@integralblue.com> + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ diff --git a/lib/authorizationplugin.php b/lib/authorizationplugin.php index 07da9b2d1..d71f77243 100644 --- a/lib/authorizationplugin.php +++ b/lib/authorizationplugin.php @@ -22,6 +22,7 @@ * @category Plugin * @package StatusNet * @author Craig Andrews <candrews@integralblue.com> + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ @@ -67,7 +68,7 @@ abstract class AuthorizationPlugin extends Plugin //------------Below are the methods that connect StatusNet to the implementing Auth plugin------------\\ - function onStartSetUser(&$user) { + function onStartSetUser($user) { $loginAllowed = $this->loginAllowed($user); if($loginAllowed === true){ return; @@ -84,7 +85,7 @@ abstract class AuthorizationPlugin extends Plugin } } - function onStartSetApiUser(&$user) { + function onStartSetApiUser($user) { return $this->onStartSetUser($user); } diff --git a/lib/avatarlink.php b/lib/avatarlink.php index e67799e2e..7d4256d6e 100644 --- a/lib/avatarlink.php +++ b/lib/avatarlink.php @@ -76,8 +76,8 @@ class AvatarLink $alink = new AvatarLink(); $alink->url = $filename; $alink->height = $size; + $alink->width = $size; if (!empty($filename)) { - $alink->width = $size; $alink->type = self::mediatype($filename); } else { $alink->url = User_group::defaultLogo($size); diff --git a/lib/command.php b/lib/command.php index f7421269d..a769dc1fc 100644 --- a/lib/command.php +++ b/lib/command.php @@ -122,6 +122,8 @@ class Command } Event::handle('EndCommandGetProfile', array($this, $arg, &$profile)); if (!$profile) { + // TRANS: Message given requesting a profile for a non-existing user. + // TRANS: %s is the nickname of the user for which the profile could not be found. throw new CommandException(sprintf(_('Could not find a user with nickname %s'), $arg)); } return $profile; @@ -140,6 +142,8 @@ class Command } Event::handle('EndCommandGetUser', array($this, $arg, &$user)); if (!$user){ + // TRANS: Message given getting a non-existing user. + // TRANS: %s is the nickname of the user that could not be found. throw new CommandException(sprintf(_('Could not find a local user with nickname %s'), $arg)); } @@ -225,6 +229,8 @@ class NudgeCommand extends Command } // XXX: notify by IM // XXX: notify by SMS + // TRANS: Message given having nudged another user. + // TRANS: %s is the nickname of the user that was nudged. $channel->output($this->user, sprintf(_('Nudge sent to %s'), $recipient->nickname)); } @@ -273,7 +279,7 @@ class FavCommand extends Command function handle($channel) { $notice = $this->getNotice($this->other); - $fave = Fave::addNew($this->user, $notice); + $fave = Fave::addNew($this->user->getProfile(), $notice); if (!$fave) { $channel->error($this->user, _('Could not create favorite.')); @@ -328,12 +334,16 @@ class JoinCommand extends Command Event::handle('EndJoinGroup', array($group, $cur)); } } catch (Exception $e) { - $channel->error($cur, sprintf(_('Could not join user %s to group %s'), + // TRANS: Message given having failed to add a user to a group. + // TRANS: %1$s is the nickname of the user, %2$s is the nickname of the group. + $channel->error($cur, sprintf(_('Could not join user %1$s to group %2$s'), $cur->nickname, $group->nickname)); return; } - $channel->output($cur, sprintf(_('%s joined group %s'), + // TRANS: Message given having added a user to a group. + // TRANS: %1$s is the nickname of the user, %2$s is the nickname of the group. + $channel->output($cur, sprintf(_('%1$s joined group %2$s'), $cur->nickname, $group->nickname)); } @@ -370,12 +380,16 @@ class DropCommand extends Command Event::handle('EndLeaveGroup', array($group, $cur)); } } catch (Exception $e) { - $channel->error($cur, sprintf(_('Could not remove user %s to group %s'), + // TRANS: Message given having failed to remove a user from a group. + // TRANS: %1$s is the nickname of the user, %2$s is the nickname of the group. + $channel->error($cur, sprintf(_('Could not remove user %1$s from group %2$s'), $cur->nickname, $group->nickname)); return; } - $channel->output($cur, sprintf(_('%s left group %s'), + // TRANS: Message given having removed a user from a group. + // TRANS: %1$s is the nickname of the user, %2$s is the nickname of the group. + $channel->output($cur, sprintf(_('%1$s left group %2$s'), $cur->nickname, $group->nickname)); } @@ -395,18 +409,24 @@ class WhoisCommand extends Command { $recipient = $this->getProfile($this->other); + // TRANS: Whois output. + // TRANS: %1$s nickname of the queried user, %2$s is their profile URL. $whois = sprintf(_("%1\$s (%2\$s)"), $recipient->nickname, $recipient->profileurl); if ($recipient->fullname) { + // TRANS: Whois output. %s is the full name of the queried user. $whois .= "\n" . sprintf(_('Fullname: %s'), $recipient->fullname); } if ($recipient->location) { + // TRANS: Whois output. %s is the location of the queried user. $whois .= "\n" . sprintf(_('Location: %s'), $recipient->location); } if ($recipient->homepage) { + // TRANS: Whois output. %s is the homepage of the queried user. $whois .= "\n" . sprintf(_('Homepage: %s'), $recipient->homepage); } if ($recipient->bio) { + // TRANS: Whois output. %s is the bio information of the queried user. $whois .= "\n" . sprintf(_('About: %s'), $recipient->bio); } $channel->output($this->user, $whois); @@ -447,7 +467,9 @@ class MessageCommand extends Command $this->text = common_shorten_links($this->text); if (Message::contentTooLong($this->text)) { - $channel->error($this->user, sprintf(_('Message too long - maximum is %d characters, you sent %d'), + // TRANS: Message given if content is too long. + // TRANS: %1$d is the maximum number of characters, %2$d is the number of submitted characters. + $channel->error($this->user, sprintf(_('Message too long - maximum is %1$d characters, you sent %2$d'), Message::maxContent(), mb_strlen($this->text))); return; } @@ -465,6 +487,8 @@ class MessageCommand extends Command $message = Message::saveNew($this->user->id, $other->id, $this->text, $channel->source()); if ($message) { $message->notify(); + // TRANS: Message given have sent a direct message to another user. + // TRANS: %s is the name of the other user. $channel->output($this->user, sprintf(_('Direct message to %s sent'), $this->other)); } else { $channel->error($this->user, _('Error sending direct message.')); @@ -500,6 +524,8 @@ class RepeatCommand extends Command if ($repeat) { + // TRANS: Message given having repeated a notice from another user. + // TRANS: %s is the name of the user for which the notice was repeated. $channel->output($this->user, sprintf(_('Notice from %s repeated'), $recipient->nickname)); } else { $channel->error($this->user, _('Error repeating notice.')); diff --git a/lib/common.php b/lib/common.php index 334a88ffd..817434b97 100644 --- a/lib/common.php +++ b/lib/common.php @@ -22,10 +22,10 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } //exit with 200 response, if this is checking fancy from the installer if (isset($_REQUEST['p']) && $_REQUEST['p'] == 'check-fancy') { exit; } -define('STATUSNET_VERSION', '0.9.0'); +define('STATUSNET_VERSION', '0.9.3'); define('LACONICA_VERSION', STATUSNET_VERSION); // compatibility -define('STATUSNET_CODENAME', 'Stand'); +define('STATUSNET_CODENAME', 'Half a World Away'); define('AVATAR_PROFILE_SIZE', 96); define('AVATAR_STREAM_SIZE', 48); diff --git a/lib/dbqueuemanager.php b/lib/dbqueuemanager.php index 3032e4ec7..3dda9fd1a 100644 --- a/lib/dbqueuemanager.php +++ b/lib/dbqueuemanager.php @@ -135,9 +135,7 @@ class DBQueueManager extends QueueManager if (empty($qi->claimed)) { $this->_log(LOG_WARNING, "[$queue:item $qi->id] Ignoring failure for unclaimed queue item"); } else { - $orig = clone($qi); - $qi->claimed = null; - $qi->update($orig); + $qi->releaseClaim(); } $this->stats('error', $queue); diff --git a/lib/default.php b/lib/default.php index 10f3f1a97..45a4560ff 100644 --- a/lib/default.php +++ b/lib/default.php @@ -72,6 +72,7 @@ $default = 'quote_identifiers' => false, 'type' => 'mysql', 'schemacheck' => 'runtime', // 'runtime' or 'script' + 'annotate_queries' => false, // true to add caller comments to queries, eg /* POST Notice::saveNew */ 'log_queries' => false, // true to log all DB queries 'log_slow_queries' => 0), // if set, log queries taking over N seconds 'syslog' => @@ -87,6 +88,8 @@ $default = 'stomp_username' => null, 'stomp_password' => null, 'stomp_persistent' => true, // keep items across queue server restart, if persistence is enabled + 'stomp_transactions' => true, // use STOMP transactions to aid in detecting failures (supported by ActiveMQ, but not by all) + 'stomp_acks' => true, // send acknowledgements after successful processing (supported by ActiveMQ, but not by all) 'stomp_manual_failover' => true, // if multiple servers are listed, treat them as separate (enqueue on one randomly, listen on all) 'monitor' => null, // URL to monitor ping endpoint (work in progress) 'softlimit' => '90%', // total size or % of memory_limit at which to restart queue threads gracefully @@ -138,10 +141,17 @@ $default = 'dir' => null, 'path'=> null, 'ssl' => null), + 'theme_upload' => + array('enabled' => extension_loaded('zip')), 'javascript' => array('server' => null, 'path'=> null, 'ssl' => null), + 'local' => // To override path/server for themes in 'local' dir (not currently applied to local plugins) + array('server' => null, + 'dir' => null, + 'path' => null, + 'ssl' => null), 'throttle' => array('enabled' => false, // whether to throttle edits; false by default 'count' => 20, // number of allowed messages in timespan @@ -185,7 +195,8 @@ $default = 'cache' => array('base' => null), 'ping' => - array('notify' => array()), + array('notify' => array(), + 'timeout' => 2), 'inboxes' => array('enabled' => true), # ignored after 0.9.x 'newuser' => @@ -256,6 +267,9 @@ $default = 'linkcolor' => null, 'backgroundimage' => null, 'disposition' => null), + 'custom_css' => + array('enabled' => true, + 'css' => ''), 'notice' => array('contentlimit' => null), 'message' => @@ -296,4 +310,12 @@ $default = ), 'api' => array('realm' => null), + 'nofollow' => + array('subscribers' => true, + 'members' => true, + 'peopletag' => true), + 'http' => // HTTP client settings when contacting other sites + array('ssl_cafile' => false, // To enable SSL cert validation, point to a CA bundle (eg '/usr/lib/ssl/certs/ca-certificates.crt') + 'curl' => false, // Use CURL backend for HTTP fetches if available. (If not, PHP's socket streams will be used.) + ), ); diff --git a/lib/deluserqueuehandler.php b/lib/deluserqueuehandler.php index 4a1233a5e..710303938 100644 --- a/lib/deluserqueuehandler.php +++ b/lib/deluserqueuehandler.php @@ -49,9 +49,13 @@ class DelUserQueueHandler extends QueueHandler return true; } - if (!$user->hasRole(Profile_role::DELETED)) { - common_log(LOG_INFO, "User {$user->nickname} is not pending deletion; aborting."); - return true; + try { + if (!$user->hasRole(Profile_role::DELETED)) { + common_log(LOG_INFO, "User {$user->nickname} is not pending deletion; aborting."); + return true; + } + } catch (UserNoProfileException $unp) { + common_log(LOG_INFO, "Deleting user {$user->nickname} with no profile... probably a good idea!"); } $notice = $this->getNextBatch($user); diff --git a/lib/distribqueuehandler.php b/lib/distribqueuehandler.php index d2be7a92c..8f4b72d5c 100644 --- a/lib/distribqueuehandler.php +++ b/lib/distribqueuehandler.php @@ -49,19 +49,22 @@ class DistribQueueHandler } /** - * Here's the meat of your queue handler -- you're handed a Notice - * object, which you may do as you will with. + * Handle distribution of a notice after we've saved it: + * @li add to local recipient inboxes + * @li send email notifications to local @-reply targets + * @li run final EndNoticeSave plugin events + * @li put any remaining post-processing into the queues * * If this function indicates failure, a warning will be logged * and the item is placed back in the queue to be re-run. * + * @fixme addToInboxes is known to fail sometimes with large recipient sets + * * @param Notice $notice * @return boolean true on success, false on failure */ function handle($notice) { - // XXX: do we need to change this for remote users? - try { $notice->addToInboxes(); } catch (Exception $e) { @@ -69,6 +72,12 @@ class DistribQueueHandler } try { + $notice->sendReplyNotifications(); + } catch (Exception $e) { + $this->logit($notice, $e); + } + + try { Event::handle('EndNoticeSave', array($notice)); // Enqueue for other handlers } catch (Exception $e) { diff --git a/lib/htmloutputter.php b/lib/htmloutputter.php index 7786b5941..5dc2b38da 100644 --- a/lib/htmloutputter.php +++ b/lib/htmloutputter.php @@ -100,6 +100,7 @@ class HTMLOutputter extends XMLOutputter $type = common_negotiate_type($cp, $sp); if (!$type) { + // TRANS: Client exception 406 throw new ClientException(_('This page is not available in a '. 'media type you accept'), 406); } diff --git a/lib/httpclient.php b/lib/httpclient.php index 64a51353c..514a5afeb 100644 --- a/lib/httpclient.php +++ b/lib/httpclient.php @@ -43,6 +43,9 @@ require_once 'HTTP/Request2/Response.php'; * * This extends the HTTP_Request2_Response class with methods to get info * about any followed redirects. + * + * Originally used the name 'HTTPResponse' to match earlier code, but + * this conflicts with a class in in the PECL HTTP extension. * * @category HTTP * @package StatusNet @@ -51,7 +54,7 @@ require_once 'HTTP/Request2/Response.php'; * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ -class HTTPResponse extends HTTP_Request2_Response +class StatusNet_HTTPResponse extends HTTP_Request2_Response { function __construct(HTTP_Request2_Response $response, $url, $redirects=0) { @@ -129,7 +132,23 @@ class HTTPClient extends HTTP_Request2 // ought to be investigated to see if we can handle // it gracefully in that case as well. $this->config['protocol_version'] = '1.0'; - + + // Default state of OpenSSL seems to have no trusted + // SSL certificate authorities, which breaks hostname + // verification and means we have a hard time communicating + // with other sites' HTTPS interfaces. + // + // Turn off verification unless we've configured a CA bundle. + if (common_config('http', 'ssl_cafile')) { + $this->config['ssl_cafile'] = common_config('http', 'ssl_cafile'); + } else { + $this->config['ssl_verify_peer'] = false; + } + + if (common_config('http', 'curl') && extension_loaded('curl')) { + $this->config['adapter'] = 'HTTP_Request2_Adapter_Curl'; + } + parent::__construct($url, $method, $config); $this->setHeader('User-Agent', $this->userAgent()); } @@ -146,7 +165,7 @@ class HTTPClient extends HTTP_Request2 /** * Convenience function to run a GET request. * - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throws HTTP_Request2_Exception */ public function get($url, $headers=array()) @@ -157,7 +176,7 @@ class HTTPClient extends HTTP_Request2 /** * Convenience function to run a HEAD request. * - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throws HTTP_Request2_Exception */ public function head($url, $headers=array()) @@ -171,7 +190,7 @@ class HTTPClient extends HTTP_Request2 * @param string $url * @param array $headers optional associative array of HTTP headers * @param array $data optional associative array or blob of form data to submit - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throws HTTP_Request2_Exception */ public function post($url, $headers=array(), $data=array()) @@ -183,12 +202,21 @@ class HTTPClient extends HTTP_Request2 } /** - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throws HTTP_Request2_Exception */ protected function doRequest($url, $method, $headers) { $this->setUrl($url); + + // Workaround for HTTP_Request2 not setting up SNI in socket contexts; + // This fixes cert validation for SSL virtual hosts using SNI. + // Requires PHP 5.3.2 or later and OpenSSL with SNI support. + if ($this->url->getScheme() == 'https' && defined('OPENSSL_TLSEXT_SERVER_NAME')) { + $this->config['ssl_SNI_enabled'] = true; + $this->config['ssl_SNI_server_name'] = $this->url->getHost(); + } + $this->setMethod($method); if ($headers) { foreach ($headers as $header) { @@ -217,12 +245,12 @@ class HTTPClient extends HTTP_Request2 } /** - * Actually performs the HTTP request and returns an HTTPResponse object - * with response body and header info. + * Actually performs the HTTP request and returns a + * StatusNet_HTTPResponse object with response body and header info. * * Wraps around parent send() to add logging and redirection processing. * - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throw HTTP_Request2_Exception */ public function send() @@ -265,6 +293,6 @@ class HTTPClient extends HTTP_Request2 } break; } while ($maxRedirs); - return new HTTPResponse($response, $this->getUrl(), $redirs); + return new StatusNet_HTTPResponse($response, $this->getUrl(), $redirs); } } diff --git a/lib/installer.php b/lib/installer.php new file mode 100644 index 000000000..ff2bed140 --- /dev/null +++ b/lib/installer.php @@ -0,0 +1,585 @@ +<?php + +/** + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2009, StatusNet, Inc. + * + * 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 Installation + * @package Installation + * + * @author Adrian Lang <mail@adrianlang.de> + * @author Brenda Wallace <shiny@cpan.org> + * @author Brett Taylor <brett@webfroot.co.nz> + * @author Brion Vibber <brion@pobox.com> + * @author CiaranG <ciaran@ciarang.com> + * @author Craig Andrews <candrews@integralblue.com> + * @author Eric Helgeson <helfire@Erics-MBP.local> + * @author Evan Prodromou <evan@status.net> + * @author Robin Millette <millette@controlyourself.ca> + * @author Sarven Capadisli <csarven@status.net> + * @author Tom Adams <tom@holizz.com> + * @author Zach Copley <zach@status.net> + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org + * @license GNU Affero General Public License http://www.gnu.org/licenses/ + * @version 0.9.x + * @link http://status.net + */ + +abstract class Installer +{ + /** Web site info */ + public $sitename, $server, $path, $fancy; + /** DB info */ + public $host, $dbname, $dbtype, $username, $password, $db; + /** Administrator info */ + public $adminNick, $adminPass, $adminEmail, $adminUpdates; + /** Should we skip writing the configuration file? */ + public $skipConfig = false; + + public static $dbModules = array( + 'mysql' => array( + 'name' => 'MySQL', + 'check_module' => 'mysqli', + 'installer' => 'mysql_db_installer', + ), + 'pgsql' => array( + 'name' => 'PostgreSQL', + 'check_module' => 'pgsql', + 'installer' => 'pgsql_db_installer', + ), + ); + + /** + * Attempt to include a PHP file and report if it worked, while + * suppressing the annoying warning messages on failure. + */ + private function haveIncludeFile($filename) { + $old = error_reporting(error_reporting() & ~E_WARNING); + $ok = include_once($filename); + error_reporting($old); + return $ok; + } + + /** + * Check if all is ready for installation + * + * @return void + */ + function checkPrereqs() + { + $pass = true; + + $config = INSTALLDIR.'/config.php'; + if (file_exists($config)) { + if (!is_writable($config) || filesize($config) > 0) { + $this->warning('Config file "config.php" already exists.'); + $pass = false; + } + } + + if (version_compare(PHP_VERSION, '5.2.3', '<')) { + $this->warning('Require PHP version 5.2.3 or greater.'); + $pass = false; + } + + // Look for known library bugs + $str = "abcdefghijklmnopqrstuvwxyz"; + $replaced = preg_replace('/[\p{Cc}\p{Cs}]/u', '*', $str); + if ($str != $replaced) { + $this->warning('PHP is linked to a version of the PCRE library ' . + 'that does not support Unicode properties. ' . + 'If you are running Red Hat Enterprise Linux / ' . + 'CentOS 5.4 or earlier, see <a href="' . + 'http://status.net/wiki/Red_Hat_Enterprise_Linux#PCRE_library' . + '">our documentation page</a> on fixing this.'); + $pass = false; + } + + $reqs = array('gd', 'curl', + 'xmlwriter', 'mbstring', 'xml', 'dom', 'simplexml'); + + foreach ($reqs as $req) { + if (!$this->checkExtension($req)) { + $this->warning(sprintf('Cannot load required extension: <code>%s</code>', $req)); + $pass = false; + } + } + + // Make sure we have at least one database module available + $missingExtensions = array(); + foreach (self::$dbModules as $type => $info) { + if (!$this->checkExtension($info['check_module'])) { + $missingExtensions[] = $info['check_module']; + } + } + + if (count($missingExtensions) == count(self::$dbModules)) { + $req = implode(', ', $missingExtensions); + $this->warning(sprintf('Cannot find a database extension. You need at least one of %s.', $req)); + $pass = false; + } + + // @fixme this check seems to be insufficient with Windows ACLs + if (!is_writable(INSTALLDIR)) { + $this->warning(sprintf('Cannot write config file to: <code>%s</code></p>', INSTALLDIR), + sprintf('On your server, try this command: <code>chmod a+w %s</code>', INSTALLDIR)); + $pass = false; + } + + // Check the subdirs used for file uploads + $fileSubdirs = array('avatar', 'background', 'file'); + foreach ($fileSubdirs as $fileSubdir) { + $fileFullPath = INSTALLDIR."/$fileSubdir/"; + if (!is_writable($fileFullPath)) { + $this->warning(sprintf('Cannot write to %s directory: <code>%s</code>', $fileSubdir, $fileFullPath), + sprintf('On your server, try this command: <code>chmod a+w %s</code>', $fileFullPath)); + $pass = false; + } + } + + return $pass; + } + + /** + * Checks if a php extension is both installed and loaded + * + * @param string $name of extension to check + * + * @return boolean whether extension is installed and loaded + */ + function checkExtension($name) + { + if (extension_loaded($name)) { + return true; + } elseif (function_exists('dl') && ini_get('enable_dl') && !ini_get('safe_mode')) { + // dl will throw a fatal error if it's disabled or we're in safe mode. + // More fun, it may not even exist under some SAPIs in 5.3.0 or later... + $soname = $name . '.' . PHP_SHLIB_SUFFIX; + if (PHP_SHLIB_SUFFIX == 'dll') { + $soname = "php_" . $soname; + } + return @dl($soname); + } else { + return false; + } + } + + /** + * Basic validation on the database paramters + * Side effects: error output if not valid + * + * @return boolean success + */ + function validateDb() + { + $fail = false; + + if (empty($this->host)) { + $this->updateStatus("No hostname specified.", true); + $fail = true; + } + + if (empty($this->database)) { + $this->updateStatus("No database specified.", true); + $fail = true; + } + + if (empty($this->username)) { + $this->updateStatus("No username specified.", true); + $fail = true; + } + + if (empty($this->sitename)) { + $this->updateStatus("No sitename specified.", true); + $fail = true; + } + + return !$fail; + } + + /** + * Basic validation on the administrator user paramters + * Side effects: error output if not valid + * + * @return boolean success + */ + function validateAdmin() + { + $fail = false; + + if (empty($this->adminNick)) { + $this->updateStatus("No initial StatusNet user nickname specified.", true); + $fail = true; + } + if ($this->adminNick && !preg_match('/^[0-9a-z]{1,64}$/', $this->adminNick)) { + $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) . + '" is invalid; should be plain letters and numbers no longer than 64 characters.', true); + $fail = true; + } + // @fixme hardcoded list; should use User::allowed_nickname() + // if/when it's safe to have loaded the infrastructure here + $blacklist = array('main', 'admin', 'twitter', 'settings', 'rsd.xml', 'favorited', 'featured', 'favoritedrss', 'featuredrss', 'rss', 'getfile', 'api', 'groups', 'group', 'peopletag', 'tag', 'user', 'message', 'conversation', 'bookmarklet', 'notice', 'attachment', 'search', 'index.php', 'doc', 'opensearch', 'robots.txt', 'xd_receiver.html', 'facebook'); + if (in_array($this->adminNick, $blacklist)) { + $this->updateStatus('The user nickname "' . htmlspecialchars($this->adminNick) . + '" is reserved.', true); + $fail = true; + } + + if (empty($this->adminPass)) { + $this->updateStatus("No initial StatusNet user password specified.", true); + $fail = true; + } + + return !$fail; + } + + /** + * Set up the database with the appropriate function for the selected type... + * Saves database info into $this->db. + * + * @return mixed array of database connection params on success, false on failure + */ + function setupDatabase() + { + if ($this->db) { + throw new Exception("Bad order of operations: DB already set up."); + } + $method = self::$dbModules[$this->dbtype]['installer']; + $db = call_user_func(array($this, $method), + $this->host, + $this->database, + $this->username, + $this->password); + $this->db = $db; + return $this->db; + } + + /** + * Set up a database on PostgreSQL. + * Will output status updates during the operation. + * + * @param string $host + * @param string $database + * @param string $username + * @param string $password + * @return mixed array of database connection params on success, false on failure + * + * @fixme escape things in the connection string in case we have a funny pass etc + */ + function Pgsql_Db_installer($host, $database, $username, $password) + { + $connstring = "dbname=$database host=$host user=$username"; + + //No password would mean trust authentication used. + if (!empty($password)) { + $connstring .= " password=$password"; + } + $this->updateStatus("Starting installation..."); + $this->updateStatus("Checking database..."); + $conn = pg_connect($connstring); + + if ($conn ===false) { + $this->updateStatus("Failed to connect to database: $connstring"); + return false; + } + + //ensure database encoding is UTF8 + $record = pg_fetch_object(pg_query($conn, 'SHOW server_encoding')); + if ($record->server_encoding != 'UTF8') { + $this->updateStatus("StatusNet requires UTF8 character encoding. Your database is ". htmlentities($record->server_encoding)); + return false; + } + + $this->updateStatus("Running database script..."); + //wrap in transaction; + pg_query($conn, 'BEGIN'); + $res = $this->runDbScript('statusnet_pg.sql', $conn, 'pgsql'); + + if ($res === false) { + $this->updateStatus("Can't run database script.", true); + return false; + } + foreach (array('sms_carrier' => 'SMS carrier', + 'notice_source' => 'notice source', + 'foreign_services' => 'foreign service') + as $scr => $name) { + $this->updateStatus(sprintf("Adding %s data to database...", $name)); + $res = $this->runDbScript($scr.'.sql', $conn, 'pgsql'); + if ($res === false) { + $this->updateStatus(sprintf("Can't run %d script.", $name), true); + return false; + } + } + pg_query($conn, 'COMMIT'); + + if (empty($password)) { + $sqlUrl = "pgsql://$username@$host/$database"; + } else { + $sqlUrl = "pgsql://$username:$password@$host/$database"; + } + + $db = array('type' => 'pgsql', 'database' => $sqlUrl); + + return $db; + } + + /** + * Set up a database on MySQL. + * Will output status updates during the operation. + * + * @param string $host + * @param string $database + * @param string $username + * @param string $password + * @return mixed array of database connection params on success, false on failure + * + * @fixme escape things in the connection string in case we have a funny pass etc + */ + function Mysql_Db_installer($host, $database, $username, $password) + { + $this->updateStatus("Starting installation..."); + $this->updateStatus("Checking database..."); + + $conn = mysqli_init(); + if (!$conn->real_connect($host, $username, $password)) { + $this->updateStatus("Can't connect to server '$host' as '$username'.", true); + return false; + } + $this->updateStatus("Changing to database..."); + if (!$conn->select_db($database)) { + $this->updateStatus("Can't change to database.", true); + return false; + } + + $this->updateStatus("Running database script..."); + $res = $this->runDbScript('statusnet.sql', $conn); + if ($res === false) { + $this->updateStatus("Can't run database script.", true); + return false; + } + foreach (array('sms_carrier' => 'SMS carrier', + 'notice_source' => 'notice source', + 'foreign_services' => 'foreign service') + as $scr => $name) { + $this->updateStatus(sprintf("Adding %s data to database...", $name)); + $res = $this->runDbScript($scr.'.sql', $conn); + if ($res === false) { + $this->updateStatus(sprintf("Can't run %d script.", $name), true); + return false; + } + } + + $sqlUrl = "mysqli://$username:$password@$host/$database"; + $db = array('type' => 'mysql', 'database' => $sqlUrl); + return $db; + } + + /** + * Write a stock configuration file. + * + * @return boolean success + * + * @fixme escape variables in output in case we have funny chars, apostrophes etc + */ + function writeConf() + { + // assemble configuration file in a string + $cfg = "<?php\n". + "if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); }\n\n". + + // site name + "\$config['site']['name'] = '{$this->sitename}';\n\n". + + // site location + "\$config['site']['server'] = '{$this->server}';\n". + "\$config['site']['path'] = '{$this->path}'; \n\n". + + // checks if fancy URLs are enabled + ($this->fancy ? "\$config['site']['fancy'] = true;\n\n":''). + + // database + "\$config['db']['database'] = '{$this->db['database']}';\n\n". + ($this->db['type'] == 'pgsql' ? "\$config['db']['quote_identifiers'] = true;\n\n":''). + "\$config['db']['type'] = '{$this->db['type']}';\n\n"; + + // Normalize line endings for Windows servers + $cfg = str_replace("\n", PHP_EOL, $cfg); + + // write configuration file out to install directory + $res = file_put_contents(INSTALLDIR.'/config.php', $cfg); + + return $res; + } + + /** + * Install schema into the database + * + * @param string $filename location of database schema file + * @param dbconn $conn connection to database + * @param string $type type of database, currently mysql or pgsql + * + * @return boolean - indicating success or failure + */ + function runDbScript($filename, $conn, $type = 'mysqli') + { + $sql = trim(file_get_contents(INSTALLDIR . '/db/' . $filename)); + $stmts = explode(';', $sql); + foreach ($stmts as $stmt) { + $stmt = trim($stmt); + if (!mb_strlen($stmt)) { + continue; + } + // FIXME: use PEAR::DB or PDO instead of our own switch + switch ($type) { + case 'mysqli': + $res = $conn->query($stmt); + if ($res === false) { + $error = $conn->error; + } + break; + case 'pgsql': + $res = pg_query($conn, $stmt); + if ($res === false) { + $error = pg_last_error(); + } + break; + default: + $this->updateStatus("runDbScript() error: unknown database type ". $type ." provided."); + } + if ($res === false) { + $this->updateStatus("ERROR ($error) for SQL '$stmt'"); + return $res; + } + } + return true; + } + + /** + * Create the initial admin user account. + * Side effect: may load portions of StatusNet framework. + * Side effect: outputs program info + */ + function registerInitialUser() + { + define('STATUSNET', true); + define('LACONICA', true); // compatibility + + require_once INSTALLDIR . '/lib/common.php'; + + $data = array('nickname' => $this->adminNick, + 'password' => $this->adminPass, + 'fullname' => $this->adminNick); + if ($this->adminEmail) { + $data['email'] = $this->adminEmail; + } + $user = User::register($data); + + if (empty($user)) { + return false; + } + + // give initial user carte blanche + + $user->grantRole('owner'); + $user->grantRole('moderator'); + $user->grantRole('administrator'); + + // Attempt to do a remote subscribe to update@status.net + // Will fail if instance is on a private network. + + if ($this->adminUpdates && class_exists('Ostatus_profile')) { + try { + $oprofile = Ostatus_profile::ensureProfileURL('http://update.status.net/'); + Subscription::start($user->getProfile(), $oprofile->localProfile()); + $this->updateStatus("Set up subscription to <a href='http://update.status.net/'>update@status.net</a>."); + } catch (Exception $e) { + $this->updateStatus("Could not set up subscription to <a href='http://update.status.net/'>update@status.net</a>.", true); + } + } + + return true; + } + + /** + * The beef of the installer! + * Create database, config file, and admin user. + * + * Prerequisites: validation of input data. + * + * @return boolean success + */ + function doInstall() + { + $this->db = $this->setupDatabase(); + + if (!$this->db) { + // database connection failed, do not move on to create config file. + return false; + } + + if (!$this->skipConfig) { + $this->updateStatus("Writing config file..."); + $res = $this->writeConf(); + + if (!$res) { + $this->updateStatus("Can't write config file.", true); + return false; + } + } + + if (!empty($this->adminNick)) { + // Okay, cross fingers and try to register an initial user + if ($this->registerInitialUser()) { + $this->updateStatus( + "An initial user with the administrator role has been created." + ); + } else { + $this->updateStatus( + "Could not create initial StatusNet user (administrator).", + true + ); + return false; + } + } + + /* + TODO https needs to be considered + */ + $link = "http://".$this->server.'/'.$this->path; + + $this->updateStatus("StatusNet has been installed at $link"); + $this->updateStatus( + "<strong>DONE!</strong> You can visit your <a href='$link'>new StatusNet site</a> (login as '$this->adminNick'). If this is your first StatusNet install, you may want to poke around our <a href='http://status.net/wiki/Getting_started'>Getting Started guide</a>." + ); + + return true; + } + + /** + * Output a pre-install-time warning message + * @param string $message HTML ok, but should be plaintext-able + * @param string $submessage HTML ok, but should be plaintext-able + */ + abstract function warning($message, $submessage=''); + + /** + * Output an install-time progress message + * @param string $message HTML ok, but should be plaintext-able + * @param boolean $error true if this should be marked as an error condition + */ + abstract function updateStatus($status, $error=false); + +} diff --git a/lib/jabber.php b/lib/jabber.php index db4e2e9a7..cdcfc4423 100644 --- a/lib/jabber.php +++ b/lib/jabber.php @@ -34,39 +34,198 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { require_once 'XMPPHP/XMPP.php'; /** - * checks whether a string is a syntactically valid Jabber ID (JID) + * Splits a Jabber ID (JID) into node, domain, and resource portions. + * + * Based on validation routine submitted by: + * @copyright 2009 Patrick Georgi <patrick@georgi-clan.de> + * @license Licensed under ISC-L, which is compatible with everything else that keeps the copyright notice intact. * * @param string $jid string to check * + * @return array with "node", "domain", and "resource" indices + * @throws Exception if input is not valid + */ + +function jabber_split_jid($jid) +{ + $chars = ''; + /* the following definitions come from stringprep, Appendix C, + which is used in its entirety by nodeprop, Chapter 5, "Prohibited Output" */ + /* C1.1 ASCII space characters */ + $chars .= "\x{20}"; + /* C1.2 Non-ASCII space characters */ + $chars .= "\x{a0}\x{1680}\x{2000}-\x{200b}\x{202f}\x{205f}\x{3000a}"; + /* C2.1 ASCII control characters */ + $chars .= "\x{00}-\x{1f}\x{7f}"; + /* C2.2 Non-ASCII control characters */ + $chars .= "\x{80}-\x{9f}\x{6dd}\x{70f}\x{180e}\x{200c}\x{200d}\x{2028}\x{2029}\x{2060}-\x{2063}\x{206a}-\x{206f}\x{feff}\x{fff9}-\x{fffc}\x{1d173}-\x{1d17a}"; + /* C3 - Private Use */ + $chars .= "\x{e000}-\x{f8ff}\x{f0000}-\x{ffffd}\x{100000}-\x{10fffd}"; + /* C4 - Non-character code points */ + $chars .= "\x{fdd0}-\x{fdef}\x{fffe}\x{ffff}\x{1fffe}\x{1ffff}\x{2fffe}\x{2ffff}\x{3fffe}\x{3ffff}\x{4fffe}\x{4ffff}\x{5fffe}\x{5ffff}\x{6fffe}\x{6ffff}\x{7fffe}\x{7ffff}\x{8fffe}\x{8ffff}\x{9fffe}\x{9ffff}\x{afffe}\x{affff}\x{bfffe}\x{bffff}\x{cfffe}\x{cffff}\x{dfffe}\x{dffff}\x{efffe}\x{effff}\x{ffffe}\x{fffff}\x{10fffe}\x{10ffff}"; + /* C5 - Surrogate codes */ + $chars .= "\x{d800}-\x{dfff}"; + /* C6 - Inappropriate for plain text */ + $chars .= "\x{fff9}-\x{fffd}"; + /* C7 - Inappropriate for canonical representation */ + $chars .= "\x{2ff0}-\x{2ffb}"; + /* C8 - Change display properties or are deprecated */ + $chars .= "\x{340}\x{341}\x{200e}\x{200f}\x{202a}-\x{202e}\x{206a}-\x{206f}"; + /* C9 - Tagging characters */ + $chars .= "\x{e0001}\x{e0020}-\x{e007f}"; + + /* Nodeprep forbids some more characters */ + $nodeprepchars = $chars; + $nodeprepchars .= "\x{22}\x{26}\x{27}\x{2f}\x{3a}\x{3c}\x{3e}\x{40}"; + + $parts = explode("/", $jid, 2); + if (count($parts) > 1) { + $resource = $parts[1]; + if ($resource == '') { + // Warning: empty resource isn't legit. + // But if we're normalizing, we may as well take it... + } + } else { + $resource = null; + } + + $node = explode("@", $parts[0]); + if ((count($node) > 2) || (count($node) == 0)) { + throw new Exception("Invalid JID: too many @s"); + } else if (count($node) == 1) { + $domain = $node[0]; + $node = null; + } else { + $domain = $node[1]; + $node = $node[0]; + if ($node == '') { + throw new Exception("Invalid JID: @ but no node"); + } + } + + // Length limits per http://xmpp.org/rfcs/rfc3920.html#addressing + if ($node !== null) { + if (strlen($node) > 1023) { + throw new Exception("Invalid JID: node too long."); + } + if (preg_match("/[".$nodeprepchars."]/u", $node)) { + throw new Exception("Invalid JID node '$node'"); + } + } + + if (strlen($domain) > 1023) { + throw new Exception("Invalid JID: domain too long."); + } + if (!common_valid_domain($domain)) { + throw new Exception("Invalid JID domain name '$domain'"); + } + + if ($resource !== null) { + if (strlen($resource) > 1023) { + throw new Exception("Invalid JID: resource too long."); + } + if (preg_match("/[".$chars."]/u", $resource)) { + throw new Exception("Invalid JID resource '$resource'"); + } + } + + return array('node' => is_null($node) ? null : mb_strtolower($node), + 'domain' => is_null($domain) ? null : mb_strtolower($domain), + 'resource' => $resource); +} + +/** + * Checks whether a string is a syntactically valid Jabber ID (JID), + * either with or without a resource. + * + * Note that a bare domain can be a valid JID. + * + * @param string $jid string to check + * @param bool $check_domain whether we should validate that domain... + * * @return boolean whether the string is a valid JID */ +function jabber_valid_full_jid($jid, $check_domain=false) +{ + try { + $parts = jabber_split_jid($jid); + if ($check_domain) { + if (!jabber_check_domain($parts['domain'])) { + return false; + } + } + return $parts['resource'] !== ''; // missing or present; empty ain't kosher + } catch (Exception $e) { + return false; + } +} -function jabber_valid_base_jid($jid) +/** + * Checks whether a string is a syntactically valid base Jabber ID (JID). + * A base JID won't include a resource specifier on the end; since we + * take it off when reading input we can't really use them reliably + * to direct outgoing messages yet (sorry guys!) + * + * Note that a bare domain can be a valid JID. + * + * @param string $jid string to check + * @param bool $check_domain whether we should validate that domain... + * + * @return boolean whether the string is a valid JID + */ +function jabber_valid_base_jid($jid, $check_domain=false) { - // Cheap but effective - return Validate::email($jid); + try { + $parts = jabber_split_jid($jid); + if ($check_domain) { + if (!jabber_check_domain($parts['domain'])) { + return false; + } + } + return ($parts['resource'] === null); // missing; empty ain't kosher + } catch (Exception $e) { + return false; + } } /** - * normalizes a Jabber ID for comparison + * Normalizes a Jabber ID for comparison, dropping the resource component if any. * * @param string $jid JID to check + * @param bool $check_domain if true, reject if the domain isn't findable * * @return string an equivalent JID in normalized (lowercase) form */ function jabber_normalize_jid($jid) { - if (preg_match("/(?:([^\@]+)\@)?([^\/]+)(?:\/(.*))?$/", $jid, $matches)) { - $node = $matches[1]; - $server = $matches[2]; - return strtolower($node.'@'.$server); - } else { + try { + $parts = jabber_split_jid($jid); + if ($parts['node'] !== null) { + return $parts['node'] . '@' . $parts['domain']; + } else { + return $parts['domain']; + } + } catch (Exception $e) { return null; } } /** + * Check if this domain's got some legit DNS record + */ +function jabber_check_domain($domain) +{ + if (checkdnsrr("_xmpp-server._tcp." . $domain, "SRV")) { + return true; + } + if (checkdnsrr($domain, "ANY")) { + return true; + } + return false; +} + +/** * the JID of the Jabber daemon for this StatusNet instance * * @return string JID of the Jabber daemon diff --git a/lib/language.php b/lib/language.php index 64b59e739..d93e4e0ad 100644 --- a/lib/language.php +++ b/lib/language.php @@ -61,7 +61,7 @@ if (!function_exists('dpgettext')) { * Not currently exposed in PHP's gettext module; implemented to be compat * with gettext.h's macros. * - * @param string $domain domain identifier, or null for default domain + * @param string $domain domain identifier * @param string $context context identifier, should be some key like "menu|file" * @param string $msgid English source text * @return string original or translated message @@ -106,7 +106,7 @@ if (!function_exists('dnpgettext')) { * Not currently exposed in PHP's gettext module; implemented to be compat * with gettext.h's macros. * - * @param string $domain domain identifier, or null for default domain + * @param string $domain domain identifier * @param string $context context identifier, should be some key like "menu|file" * @param string $msg singular English source text * @param string $plural plural English source text @@ -180,7 +180,11 @@ function _m($msg/*, ...*/) } /** - * Looks for which plugin we've been called from to set the gettext domain. + * Looks for which plugin we've been called from to set the gettext domain; + * if not in a plugin subdirectory, we'll use the default 'statusnet'. + * + * Note: we can't return null for default domain since most of the PHP gettext + * wrapper functions turn null into "" before passing to the backend library. * * @param array $backtrace debug_backtrace() output * @return string @@ -202,16 +206,26 @@ function _mdomain($backtrace) static $cached; $path = $backtrace[0]['file']; if (!isset($cached[$path])) { + $final = 'statusnet'; // assume default domain if (DIRECTORY_SEPARATOR !== '/') { $path = strtr($path, DIRECTORY_SEPARATOR, '/'); } - $cut = strpos($path, '/plugins/') + 9; - $cut2 = strpos($path, '/', $cut); - if ($cut && $cut2) { - $cached[$path] = substr($path, $cut, $cut2 - $cut); + $plug = strpos($path, '/plugins/'); + if ($plug === false) { + // We're not in a plugin; return default domain. + $final = 'statusnet'; } else { - return null; + $cut = $plug + 9; + $cut2 = strpos($path, '/', $cut); + if ($cut2) { + $final = substr($path, $cut, $cut2 - $cut); + } else { + // We might be running directly from the plugins dir? + // If so, there's no place to store locale info. + $final = 'statusnet'; + } } + $cached[$path] = $final; } return $cached[$path]; } @@ -286,12 +300,14 @@ function get_nice_language_list() */ function get_all_languages() { return array( + 'af' => array('q' => 0.8, 'lang' => 'af', 'name' => 'Afrikaans', 'direction' => 'ltr'), 'ar' => array('q' => 0.8, 'lang' => 'ar', 'name' => 'Arabic', 'direction' => 'rtl'), 'arz' => array('q' => 0.8, 'lang' => 'arz', 'name' => 'Egyptian Spoken Arabic', 'direction' => 'rtl'), 'bg' => array('q' => 0.8, 'lang' => 'bg', 'name' => 'Bulgarian', 'direction' => 'ltr'), 'br' => array('q' => 0.8, 'lang' => 'br', 'name' => 'Breton', 'direction' => 'ltr'), 'ca' => array('q' => 0.5, 'lang' => 'ca', 'name' => 'Catalan', 'direction' => 'ltr'), 'cs' => array('q' => 0.5, 'lang' => 'cs', 'name' => 'Czech', 'direction' => 'ltr'), + 'da' => array('q' => 0.8, 'lang' => 'da', 'name' => 'Danish', 'direction' => 'ltr'), 'de' => array('q' => 0.8, 'lang' => 'de', 'name' => 'German', 'direction' => 'ltr'), 'el' => array('q' => 0.1, 'lang' => 'el', 'name' => 'Greek', 'direction' => 'ltr'), 'en-us' => array('q' => 1, 'lang' => 'en', 'name' => 'English (US)', 'direction' => 'ltr'), @@ -301,7 +317,8 @@ function get_all_languages() { 'fi' => array('q' => 1, 'lang' => 'fi', 'name' => 'Finnish', 'direction' => 'ltr'), 'fa' => array('q' => 1, 'lang' => 'fa', 'name' => 'Persian', 'direction' => 'rtl'), 'fr-fr' => array('q' => 1, 'lang' => 'fr', 'name' => 'French', 'direction' => 'ltr'), - 'ga' => array('q' => 0.5, 'lang' => 'ga', 'name' => 'Galician', 'direction' => 'ltr'), + 'ga' => array('q' => 0.5, 'lang' => 'ga', 'name' => 'Irish', 'direction' => 'ltr'), + 'gl' => array('q' => 0.8, 'lang' => 'gl', 'name' => 'Galician', 'direction' => 'ltr'), 'he' => array('q' => 0.5, 'lang' => 'he', 'name' => 'Hebrew', 'direction' => 'rtl'), 'hsb' => array('q' => 0.8, 'lang' => 'hsb', 'name' => 'Upper Sorbian', 'direction' => 'ltr'), 'ia' => array('q' => 0.8, 'lang' => 'ia', 'name' => 'Interlingua', 'direction' => 'ltr'), diff --git a/lib/liberalstomp.php b/lib/liberalstomp.php index 3d38953fd..70c22c17e 100644 --- a/lib/liberalstomp.php +++ b/lib/liberalstomp.php @@ -147,5 +147,30 @@ class LiberalStomp extends Stomp } return $frame; } -} + + /** + * Write frame to server + * + * @param StompFrame $stompFrame + */ + protected function _writeFrame (StompFrame $stompFrame) + { + if (!is_resource($this->_socket)) { + require_once 'Stomp/Exception.php'; + throw new StompException('Socket connection hasn\'t been established'); + } + + $data = $stompFrame->__toString(); + + // Make sure the socket's in a writable state; if not, wait a bit. + stream_set_blocking($this->_socket, 1); + + $r = fwrite($this->_socket, $data, strlen($data)); + stream_set_blocking($this->_socket, 0); + if ($r === false || $r == 0) { + $this->_reconnect(); + $this->_writeFrame($stompFrame); + } + } + } diff --git a/lib/mail.php b/lib/mail.php index 807b6a363..ab5742e33 100644 --- a/lib/mail.php +++ b/lib/mail.php @@ -170,8 +170,10 @@ function mail_to_user(&$user, $subject, $body, $headers=array(), $address=null) function mail_confirm_address($user, $code, $nickname, $address) { + // TRANS: Subject for address confirmation email $subject = _('Email address confirmation'); + // TRANS: Body for address confirmation email. $body = sprintf(_("Hey, %s.\n\n". "Someone just entered this email address on %s.\n\n" . "If it was you, and you want to confirm your entry, ". @@ -222,9 +224,6 @@ function mail_subscribe_notify_profile($listenee, $other) if ($other->hasRight(Right::EMAILONSUBSCRIBE) && $listenee->email && $listenee->emailnotifysub) { - // use the recipient's localization - common_init_locale($listenee->language); - $profile = $listenee->getProfile(); $name = $profile->getBestName(); @@ -234,14 +233,24 @@ function mail_subscribe_notify_profile($listenee, $other) $recipients = $listenee->email; + // use the recipient's localization + common_switch_locale($listenee->language); + $headers = _mail_prepare_headers('subscribe', $listenee->nickname, $other->nickname); $headers['From'] = mail_notify_from(); $headers['To'] = $name . ' <' . $listenee->email . '>'; + // TRANS: Subject of new-subscriber notification e-mail $headers['Subject'] = sprintf(_('%1$s is now listening to '. 'your notices on %2$s.'), $other->getBestName(), common_config('site', 'name')); + $blocklink = sprintf(_("If you believe this account is being used abusively, " . + "you can block them from your subscribers list and " . + "report as spam to site administrators at %s"), + common_local_url('block', array('profileid' => $other->id))); + + // TRANS: Main body of new-subscriber notification e-mail $body = sprintf(_('%1$s is now listening to your notices on %2$s.'."\n\n". "\t".'%3$s'."\n\n". '%4$s'. @@ -255,16 +264,20 @@ function mail_subscribe_notify_profile($listenee, $other) common_config('site', 'name'), $other->profileurl, ($other->location) ? + // TRANS: Profile info line in new-subscriber notification e-mail sprintf(_("Location: %s"), $other->location) . "\n" : '', ($other->homepage) ? + // TRANS: Profile info line in new-subscriber notification e-mail sprintf(_("Homepage: %s"), $other->homepage) . "\n" : '', - ($other->bio) ? - sprintf(_("Bio: %s"), $other->bio) . "\n\n" : '', + (($other->bio) ? + // TRANS: Profile info line in new-subscriber notification e-mail + sprintf(_("Bio: %s"), $other->bio) . "\n" : '') . + "\n\n" . $blocklink . "\n", common_config('site', 'name'), common_local_url('emailsettings')); // reset localization - common_init_locale(); + common_switch_locale(); mail_send($recipients, $headers, $body); } } @@ -287,9 +300,11 @@ function mail_new_incoming_notify($user) $headers['From'] = $user->incomingemail; $headers['To'] = $name . ' <' . $user->email . '>'; + // TRANS: Subject of notification mail for new posting email address $headers['Subject'] = sprintf(_('New email address for posting to %s'), common_config('site', 'name')); + // TRANS: Body of notification mail for new posting email address $body = sprintf(_("You have a new posting address on %1\$s.\n\n". "Send email to %2\$s to post new messages.\n\n". "More email instructions at %3\$s.\n\n". @@ -414,6 +429,7 @@ function mail_send_sms_notice_address($notice, $smsemail, $incomingemail) $headers['From'] = ($incomingemail) ? $incomingemail : mail_notify_from(); $headers['To'] = $to; + // TRANS: Subject line for SMS-by-email notification messages $headers['Subject'] = sprintf(_('%s status'), $other->getBestName()); @@ -440,11 +456,11 @@ function mail_confirm_sms($code, $nickname, $address) $headers['From'] = mail_notify_from(); $headers['To'] = $nickname . ' <' . $address . '>'; + // TRANS: Subject line for SMS-by-email address confirmation message $headers['Subject'] = _('SMS confirmation'); - // FIXME: I18N - - $body = "$nickname: confirm you own this phone number with this code:"; + // TRANS: Main body heading for SMS-by-email address confirmation message + $body = sprintf(_("%s: confirm you own this phone number with this code:"), $nickname); $body .= "\n\n"; $body .= $code; $body .= "\n\n"; @@ -463,11 +479,13 @@ function mail_confirm_sms($code, $nickname, $address) function mail_notify_nudge($from, $to) { - common_init_locale($to->language); + common_switch_locale($to->language); + // TRANS: Subject for 'nudge' notification email $subject = sprintf(_('You\'ve been nudged by %s'), $from->nickname); $from_profile = $from->getProfile(); + // TRANS: Body for 'nudge' notification email $body = sprintf(_("%1\$s (%2\$s) is wondering what you are up to ". "these days and is inviting you to post some news.\n\n". "So let's hear from you :)\n\n". @@ -479,7 +497,7 @@ function mail_notify_nudge($from, $to) $from->nickname, common_local_url('all', array('nickname' => $to->nickname)), common_config('site', 'name')); - common_init_locale(); + common_switch_locale(); $headers = _mail_prepare_headers('nudge', $to->nickname, $from->nickname); @@ -513,11 +531,13 @@ function mail_notify_message($message, $from=null, $to=null) return true; } - common_init_locale($to->language); + common_switch_locale($to->language); + // TRANS: Subject for direct-message notification email $subject = sprintf(_('New private message from %s'), $from->nickname); $from_profile = $from->getProfile(); + // TRANS: Body for direct-message notification email $body = sprintf(_("%1\$s (%2\$s) sent you a private message:\n\n". "------------------------------------------------------\n". "%3\$s\n". @@ -535,7 +555,7 @@ function mail_notify_message($message, $from=null, $to=null) $headers = _mail_prepare_headers('message', $to->nickname, $from->nickname); - common_init_locale(); + common_switch_locale(); return mail_to_user($to, $subject, $body, $headers); } @@ -563,10 +583,12 @@ function mail_notify_fave($other, $user, $notice) $bestname = $profile->getBestName(); - common_init_locale($other->language); + common_switch_locale($other->language); + // TRANS: Subject for favorite notification email $subject = sprintf(_('%s (@%s) added your notice as a favorite'), $bestname, $user->nickname); + // TRANS: Body for favorite notification email $body = sprintf(_("%1\$s (@%7\$s) just added your notice from %2\$s". " as one of their favorites.\n\n" . "The URL of your notice is:\n\n" . @@ -589,7 +611,7 @@ function mail_notify_fave($other, $user, $notice) $headers = _mail_prepare_headers('fave', $other->nickname, $user->nickname); - common_init_locale(); + common_switch_locale(); mail_to_user($other, $subject, $body, $headers); } @@ -620,26 +642,27 @@ function mail_notify_attn($user, $notice) $bestname = $sender->getBestName(); - common_init_locale($user->language); + common_switch_locale($user->language); - if ($notice->conversation != $notice->id) { - $conversationEmailText = "The full conversation can be read here:\n\n". - "\t%5\$s\n\n "; - $conversationUrl = common_local_url('conversation', - array('id' => $notice->conversation)).'#notice-'.$notice->id; - } else { - $conversationEmailText = "%5\$s"; - $conversationUrl = null; - } + if ($notice->hasConversation()) { + $conversationUrl = common_local_url('conversation', + array('id' => $notice->conversation)).'#notice-'.$notice->id; + // TRANS: Line in @-reply notification e-mail. %s is conversation URL. + $conversationEmailText = sprintf(_("The full conversation can be read here:\n\n". + "\t%s"), $conversationUrl) . "\n\n"; + } else { + $conversationEmailText = ''; + } $subject = sprintf(_('%s (@%s) sent a notice to your attention'), $bestname, $sender->nickname); + // TRANS: Body of @-reply notification e-mail. $body = sprintf(_("%1\$s (@%9\$s) just sent a notice to your attention (an '@-reply') on %2\$s.\n\n". "The notice is here:\n\n". "\t%3\$s\n\n" . "It reads:\n\n". "\t%4\$s\n\n" . - $conversationEmailText . + "%5\$s" . "You can reply back here:\n\n". "\t%6\$s\n\n" . "The list of all @-replies for you here:\n\n" . @@ -652,7 +675,7 @@ function mail_notify_attn($user, $notice) common_local_url('shownotice', array('notice' => $notice->id)),//%3 $notice->content,//%4 - $conversationUrl,//%5 + $conversationEmailText,//%5 common_local_url('newnotice', array('replyto' => $sender->nickname, 'inreplyto' => $notice->id)),//%6 common_local_url('replies', @@ -662,7 +685,7 @@ function mail_notify_attn($user, $notice) $headers = _mail_prepare_headers('mention', $user->nickname, $sender->nickname); - common_init_locale(); + common_switch_locale(); mail_to_user($user, $subject, $body, $headers); } diff --git a/lib/mailbox.php b/lib/mailbox.php index 90a58b4c4..2b00f5ffd 100644 --- a/lib/mailbox.php +++ b/lib/mailbox.php @@ -224,6 +224,7 @@ class MailboxAction extends CurrentUserDesignAction if ($message->source) { $this->elementStart('span', 'source'); + // FIXME: bad i18n. Device should be a parameter (from %s). $this->text(_('from')); $this->element('span', 'device', $this->showSource($message->source)); $this->elementEnd('span'); diff --git a/lib/mailhandler.php b/lib/mailhandler.php index 890f6d5b4..e9ba41839 100644 --- a/lib/mailhandler.php +++ b/lib/mailhandler.php @@ -265,6 +265,10 @@ class MailHandler if (preg_match('/^\s*Begin\s+forward/', $line)) { break; } + // skip everything after a blank line if we already have content + if ($output !== '' && $line === '') { + break; + } $output .= ' ' . $line; } diff --git a/lib/mediafile.php b/lib/mediafile.php index 10d90d008..c96c78ab5 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -171,7 +171,7 @@ class MediaFile return; } - if (!MediaFile::respectsQuota($user, $_FILES['attach']['size'])) { + if (!MediaFile::respectsQuota($user, $_FILES[$param]['size'])) { // Should never actually get here @@ -180,7 +180,8 @@ class MediaFile return; } - $mimetype = MediaFile::getUploadedFileType($_FILES[$param]['tmp_name']); + $mimetype = MediaFile::getUploadedFileType($_FILES[$param]['tmp_name'], + $_FILES[$param]['name']); $filename = null; @@ -241,19 +242,41 @@ class MediaFile return new MediaFile($user, $filename, $mimetype); } - static function getUploadedFileType($f) { + /** + * Attempt to identify the content type of a given file. + * + * @param mixed $f file handle resource, or filesystem path as string + * @param string $originalFilename (optional) for extension-based detection + * @return string + * + * @fixme is this an internal or public method? It's called from GetFileAction + * @fixme this seems to tie a front-end error message in, kinda confusing + * @fixme this looks like it could return a PEAR_Error in some cases, if + * type can't be identified and $config['attachments']['supported'] is true + * + * @throws ClientException if type is known, but not supported for local uploads + */ + static function getUploadedFileType($f, $originalFilename=false) { require_once 'MIME/Type.php'; + require_once 'MIME/Type/Extension.php'; + $mte = new MIME_Type_Extension(); $cmd = &PEAR::getStaticProperty('MIME_Type', 'fileCmd'); $cmd = common_config('attachments', 'filecommand'); $filetype = null; + // If we couldn't get a clear type from the file extension, + // we'll go ahead and try checking the content. Content checks + // are unambiguous for most image files, but nearly useless + // for office document formats. + if (is_string($f)) { // assuming a filename $filetype = MIME_Type::autoDetect($f); + } else { // assuming a filehandle @@ -262,7 +285,32 @@ class MediaFile $filetype = MIME_Type::autoDetect($stream['uri']); } - if (common_config('attachments', 'supported') === true || in_array($filetype, common_config('attachments', 'supported'))) { + // The content-based sources for MIME_Type::autoDetect() + // are wildly unreliable for office-type documents. If we've + // gotten an unclear reponse back or just couldn't identify it, + // we'll try detecting a type from its extension... + $unclearTypes = array('application/octet-stream', + 'application/vnd.ms-office', + 'application/zip'); + + if ($originalFilename && (!$filetype || in_array($filetype, $unclearTypes))) { + $type = $mte->getMIMEType($originalFilename); + if (is_string($type)) { + $filetype = $type; + } + } + + $supported = common_config('attachments', 'supported'); + if (is_array($supported)) { + // Normalize extensions to mime types + foreach ($supported as $i => $entry) { + if (strpos($entry, '/') === false) { + common_log(LOG_INFO, "sample.$entry"); + $supported[$i] = $mte->getMIMEType("sample.$entry"); + } + } + } + if ($supported === true || in_array($filetype, $supported)) { return $filetype; } $media = MIME_Type::getMedia($filetype); diff --git a/lib/mysqlschema.php b/lib/mysqlschema.php index 455695366..f9552c1dc 100644 --- a/lib/mysqlschema.php +++ b/lib/mysqlschema.php @@ -50,21 +50,6 @@ class MysqlSchema extends Schema static $_single = null; protected $conn = null; - /** - * Constructor. Only run once for singleton object. - */ - - protected function __construct() - { - // XXX: there should be an easier way to do this. - $user = new User(); - - $this->conn = $user->getDatabaseConnection(); - - $user->free(); - - unset($user); - } /** * Main public entry point. Use this to get @@ -348,7 +333,7 @@ class MysqlSchema extends Schema } if (empty($name)) { - $name = "$table_".implode("_", $columnNames)."_idx"; + $name = "{$table}_".implode("_", $columnNames)."_idx"; } $res = $this->conn->query("ALTER TABLE $table ". diff --git a/lib/noticeform.php b/lib/noticeform.php index 7278c41a9..84c20a5b3 100644 --- a/lib/noticeform.php +++ b/lib/noticeform.php @@ -212,8 +212,8 @@ class NoticeForm extends Form $this->out->checkbox('notice_data-geo', _('Share my location'), true); $this->out->elementEnd('div'); $this->out->inlineScript(' var NoticeDataGeo_text = {'. - 'ShareDisable: "'._('Do not share my location').'",'. - 'ErrorTimeout: "'._('Sorry, retrieving your geo location is taking longer than expected, please try again later').'"'. + 'ShareDisable: ' .json_encode(_('Do not share my location')).','. + 'ErrorTimeout: ' .json_encode(_('Sorry, retrieving your geo location is taking longer than expected, please try again later')). '}'); } diff --git a/lib/noticelist.php b/lib/noticelist.php index 811b7e4f1..dbc5bfb51 100644 --- a/lib/noticelist.php +++ b/lib/noticelist.php @@ -96,8 +96,14 @@ class NoticeList extends Widget break; } - $item = $this->newListItem($this->notice); - $item->show(); + try { + $item = $this->newListItem($this->notice); + $item->show(); + } catch (Exception $e) { + // we log exceptions and continue + common_log(LOG_ERR, $e->getMessage()); + continue; + } } $this->out->elementEnd('ol'); @@ -340,8 +346,9 @@ class NoticeListItem extends Widget function showNickname() { - $this->out->element('span', array('class' => 'nickname fn'), - $this->profile->nickname); + $this->out->raw('<span class="nickname fn">' . + htmlspecialchars($this->profile->nickname) . + '</span>'); } /** @@ -425,10 +432,18 @@ class NoticeListItem extends Widget if (empty($name)) { $latdms = $this->decimalDegreesToDMS(abs($lat)); $londms = $this->decimalDegreesToDMS(abs($lon)); + // TRANS: Used in coordinates as abbreviation of north + $north = _('N'); + // TRANS: Used in coordinates as abbreviation of south + $south = _('S'); + // TRANS: Used in coordinates as abbreviation of east + $east = _('E'); + // TRANS: Used in coordinates as abbreviation of west + $west = _('W'); $name = sprintf( _('%1$u°%2$u\'%3$u"%4$s %5$u°%6$u\'%7$u"%8$s'), - $latdms['deg'],$latdms['min'], $latdms['sec'],($lat>0?_('N'):_('S')), - $londms['deg'],$londms['min'], $londms['sec'],($lon>0?_('E'):_('W'))); + $latdms['deg'],$latdms['min'], $latdms['sec'],($lat>0? $north:$south), + $londms['deg'],$londms['min'], $londms['sec'],($lon>0? $east:$west)); } $url = $location->getUrl(); @@ -443,7 +458,8 @@ class NoticeListItem extends Widget $name); } else { $xstr = new XMLStringer(false); - $xstr->elementStart('a', array('href' => $url)); + $xstr->elementStart('a', array('href' => $url, + 'rel' => 'external')); $xstr->element('abbr', array('class' => 'geo', 'title' => $latlon), $name); @@ -453,12 +469,14 @@ class NoticeListItem extends Widget $this->out->elementEnd('span'); } + /** + * @param number $dec decimal degrees + * @return array split into 'deg', 'min', and 'sec' + */ function decimalDegreesToDMS($dec) { - - $vars = explode(".",$dec); - $deg = $vars[0]; - $tempma = "0.".$vars[1]; + $deg = intval($dec); + $tempma = abs($dec) - abs($deg); $tempma = $tempma * 3600; $min = floor($tempma / 60); @@ -478,54 +496,47 @@ class NoticeListItem extends Widget function showNoticeSource() { - if ($this->notice->source) { + $ns = $this->notice->getSource(); + + if ($ns) { + $source_name = (empty($ns->name)) ? _($ns->code) : _($ns->name); $this->out->text(' '); $this->out->elementStart('span', 'source'); + // FIXME: probably i18n issue. If "from" is followed by text, that should be a parameter to "from" (from %s). $this->out->text(_('from')); - $source_name = _($this->notice->source); $this->out->text(' '); - switch ($this->notice->source) { - case 'web': - case 'xmpp': - case 'mail': - case 'omb': - case 'system': - case 'api': - $this->out->element('span', 'device', $source_name); - break; - default: + $name = $source_name; + $url = $ns->url; + $title = null; + + if (Event::handle('StartNoticeSourceLink', array($this->notice, &$name, &$url, &$title))) { $name = $source_name; - $url = null; - - if (Event::handle('StartNoticeSourceLink', array($this->notice, &$name, &$url, &$title))) { - $ns = Notice_source::staticGet($this->notice->source); - - if ($ns) { - $name = $ns->name; - $url = $ns->url; - } else { - $app = Oauth_application::staticGet('name', $this->notice->source); - if ($app) { - $name = $app->name; - $url = $app->source_url; - } - } - } - Event::handle('EndNoticeSourceLink', array($this->notice, &$name, &$url, &$title)); - - if (!empty($name) && !empty($url)) { - $this->out->elementStart('span', 'device'); - $this->out->element('a', array('href' => $url, - 'rel' => 'external', - 'title' => $title), - $name); - $this->out->elementEnd('span'); - } else { - $this->out->element('span', 'device', $name); + $url = $ns->url; + } + Event::handle('EndNoticeSourceLink', array($this->notice, &$name, &$url, &$title)); + + // if $ns->name and $ns->url are populated we have + // configured a source attr somewhere + if (!empty($name) && !empty($url)) { + + $this->out->elementStart('span', 'device'); + + $attrs = array( + 'href' => $url, + 'rel' => 'external' + ); + + if (!empty($title)) { + $attrs['title'] = $title; } - break; + + $this->out->element('a', $attrs, $name); + $this->out->elementEnd('span'); + } else { + $this->out->element('span', 'device', $name); } + $this->out->elementEnd('span'); } } @@ -541,18 +552,7 @@ class NoticeListItem extends Widget function showContext() { - $hasConversation = false; - if (!empty($this->notice->conversation)) { - $conversation = Notice::conversationStream( - $this->notice->conversation, - 1, - 1 - ); - if ($conversation->N > 0) { - $hasConversation = true; - } - } - if ($hasConversation) { + if ($this->notice->hasConversation()) { $conv = Conversation::staticGet( 'id', $this->notice->conversation diff --git a/lib/pgsqlschema.php b/lib/pgsqlschema.php index 715065d77..272f7eff6 100644 --- a/lib/pgsqlschema.php +++ b/lib/pgsqlschema.php @@ -41,6 +41,7 @@ if (!defined('STATUSNET')) { * @category Database * @package StatusNet * @author Evan Prodromou <evan@status.net> + * @author Brenda Wallace <shiny@cpan.org> * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ @@ -79,7 +80,6 @@ class PgsqlSchema extends Schema $row = array(); while ($res->fetchInto($row, DB_FETCHMODE_ASSOC)) { -// var_dump($row); $cd = new ColumnDef(); $cd->name = $row['field']; @@ -143,6 +143,7 @@ class PgsqlSchema extends Schema $uniques = array(); $primary = array(); $indices = array(); + $onupdate = array(); $sql = "CREATE TABLE $name (\n"; @@ -155,7 +156,6 @@ class PgsqlSchema extends Schema } $sql .= $this->_columnSql($cd); - switch ($cd->key) { case 'UNI': $uniques[] = $cd->name; @@ -170,13 +170,7 @@ class PgsqlSchema extends Schema } if (count($primary) > 0) { // it really should be... - $sql .= ",\n primary key (" . implode(',', $primary) . ")"; - } - - - - foreach ($indices as $i) { - $sql .= ",\nindex {$name}_{$i}_idx ($i)"; + $sql .= ",\n PRIMARY KEY (" . implode(',', $primary) . ")"; } $sql .= "); "; @@ -185,10 +179,14 @@ class PgsqlSchema extends Schema foreach ($uniques as $u) { $sql .= "\n CREATE index {$name}_{$u}_idx ON {$name} ($u); "; } + + foreach ($indices as $i) { + $sql .= "CREATE index {$name}_{$i}_idx ON {$name} ($i)"; + } $res = $this->conn->query($sql); if (PEAR::isError($res)) { - throw new Exception($res->getMessage()); + throw new Exception($res->getMessage(). ' SQL was '. $sql); } return true; @@ -223,7 +221,7 @@ class PgsqlSchema extends Schema */ private function _columnTypeTranslation($type) { $map = array( - 'datetime' => 'timestamp' + 'datetime' => 'timestamp', ); if(!empty($map[$type])) { return $map[$type]; @@ -324,7 +322,7 @@ class PgsqlSchema extends Schema public function modifyColumn($table, $columndef) { - $sql = "ALTER TABLE $table MODIFY COLUMN " . + $sql = "ALTER TABLE $table ALTER COLUMN TYPE " . $this->_columnSql($columndef); $res = $this->conn->query($sql); @@ -397,16 +395,17 @@ class PgsqlSchema extends Schema $todrop = array_diff($cur, $new); $same = array_intersect($new, $cur); $tomod = array(); - foreach ($same as $m) { $curCol = $this->_byName($td->columns, $m); $newCol = $this->_byName($columns, $m); + if (!$newCol->equals($curCol)) { - $tomod[] = $newCol->name; + // BIG GIANT TODO! + // stop it detecting different types and trying to modify on every page request +// $tomod[] = $newCol->name; } } - if (count($toadd) + count($todrop) + count($tomod) == 0) { // nothing to do return true; @@ -430,11 +429,12 @@ class PgsqlSchema extends Schema foreach ($tomod as $columnName) { $cd = $this->_byName($columns, $columnName); - $phrase[] = 'MODIFY COLUMN ' . $this->_columnSql($cd); + /* brute force */ + $phrase[] = 'DROP COLUMN ' . $columnName; + $phrase[] = 'ADD COLUMN ' . $this->_columnSql($cd); } $sql = 'ALTER TABLE ' . $tableName . ' ' . implode(', ', $phrase); - $res = $this->conn->query($sql); if (PEAR::isError($res)) { @@ -496,12 +496,21 @@ class PgsqlSchema extends Schema * * @return string correct SQL for that column */ - private function _columnSql($cd) { $sql = "{$cd->name} "; $type = $this->_columnTypeTranslation($cd->type); + //handle those mysql enum fields that postgres doesn't support + if (preg_match('!^enum!', $type)) { + $allowed_values = preg_replace('!^enum!', '', $type); + $sql .= " text check ({$cd->name} in $allowed_values)"; + return $sql; + } + if (!empty($cd->auto_increment)) { + $type = "bigserial"; // FIXME: creates the wrong name for the sequence for some internal sequence-lookup function, so better fix this to do the real 'create sequence' dance. + } + if (!empty($cd->size)) { $sql .= "{$type}({$cd->size}) "; } else { @@ -513,14 +522,10 @@ class PgsqlSchema extends Schema } else { $sql .= ($cd->nullable) ? "null " : "not null "; } - - if (!empty($cd->auto_increment)) { - $sql .= " auto_increment "; - } - if (!empty($cd->extra)) { - $sql .= "{$cd->extra} "; - } +// if (!empty($cd->extra)) { +// $sql .= "{$cd->extra} "; +// } return $sql; } diff --git a/lib/ping.php b/lib/ping.php index 735af9ef1..be2933ae3 100644 --- a/lib/ping.php +++ b/lib/ping.php @@ -45,7 +45,15 @@ function ping_broadcast_notice($notice) { $tags)); $request = HTTPClient::start(); - $httpResponse = $request->post($notify_url, array('Content-Type: text/xml'), $req); + $request->setConfig('connect_timeout', common_config('ping', 'timeout')); + $request->setConfig('timeout', common_config('ping', 'timeout')); + try { + $httpResponse = $request->post($notify_url, array('Content-Type: text/xml'), $req); + } catch (Exception $e) { + common_log(LOG_ERR, + "Exception pinging $notify_url: " . $e->getMessage()); + continue; + } if (!$httpResponse || mb_strlen($httpResponse->getBody()) == 0) { common_log(LOG_WARNING, diff --git a/lib/plugin.php b/lib/plugin.php index 65ccdafbb..f63bdf309 100644 --- a/lib/plugin.php +++ b/lib/plugin.php @@ -91,6 +91,7 @@ class Plugin $path = INSTALLDIR . "/plugins/$name/locale"; if (file_exists($path) && is_dir($path)) { bindtextdomain($name, $path); + bind_textdomain_codeset($name, 'UTF-8'); } } } diff --git a/lib/popularnoticesection.php b/lib/popularnoticesection.php index 296ddbbb5..f70a972ef 100644 --- a/lib/popularnoticesection.php +++ b/lib/popularnoticesection.php @@ -72,7 +72,7 @@ class PopularNoticeSection extends NoticeSection $qry .= ' GROUP BY notice.id,notice.profile_id,notice.content,notice.uri,' . 'notice.rendered,notice.url,notice.created,notice.modified,' . 'notice.reply_to,notice.is_local,notice.source,notice.conversation, ' . - 'notice.lat,notice.lon,location_id,location_ns' . + 'notice.lat,notice.lon,location_id,location_ns,notice.repeat_of' . ' ORDER BY weight DESC'; $offset = 0; diff --git a/lib/profileaction.php b/lib/profileaction.php index 029c21845..504b77566 100644 --- a/lib/profileaction.php +++ b/lib/profileaction.php @@ -139,25 +139,30 @@ class ProfileAction extends OwnerDesignAction $this->elementStart('div', array('id' => 'entity_subscribers', 'class' => 'section')); - $this->element('h2', null, _('Subscribers')); + if (Event::handle('StartShowSubscribersMiniList', array($this))) { - $cnt = 0; + $this->element('h2', null, _('Subscribers')); - if (!empty($profile)) { - $pml = new ProfileMiniList($profile, $this); - $cnt = $pml->show(); - if ($cnt == 0) { - $this->element('p', null, _('(None)')); + $cnt = 0; + + if (!empty($profile)) { + $sml = new SubscribersMiniList($profile, $this); + $cnt = $sml->show(); + if ($cnt == 0) { + $this->element('p', null, _('(None)')); + } + } + + if ($cnt > PROFILES_PER_MINILIST) { + $this->elementStart('p'); + $this->element('a', array('href' => common_local_url('subscribers', + array('nickname' => $this->profile->nickname)), + 'class' => 'more'), + _('All subscribers')); + $this->elementEnd('p'); } - } - if ($cnt > PROFILES_PER_MINILIST) { - $this->elementStart('p'); - $this->element('a', array('href' => common_local_url('subscribers', - array('nickname' => $this->profile->nickname)), - 'class' => 'more'), - _('All subscribers')); - $this->elementEnd('p'); + Event::handle('EndShowSubscribersMiniList', array($this)); } $this->elementEnd('div'); @@ -169,6 +174,12 @@ class ProfileAction extends OwnerDesignAction $subbed_count = $this->profile->subscriberCount(); $notice_count = $this->profile->noticeCount(); $group_count = $this->user->getGroups()->N; + $age_days = (time() - strtotime($this->profile->created)) / 86400; + if ($age_days < 1) { + // Rather than extrapolating out to a bajillion... + $age_days = 1; + } + $daily_count = round($notice_count / $age_days); $this->elementStart('div', array('id' => 'entity_statistics', 'class' => 'section')); @@ -219,6 +230,12 @@ class ProfileAction extends OwnerDesignAction $this->element('dd', null, $notice_count); $this->elementEnd('dl'); + $this->elementStart('dl', 'entity_daily_notices'); + // TRANS: Average count of posts made per day since account registration + $this->element('dt', null, _('Daily average')); + $this->element('dd', null, $daily_count); + $this->elementEnd('dl'); + $this->elementEnd('div'); } @@ -254,3 +271,23 @@ class ProfileAction extends OwnerDesignAction } } +class SubscribersMiniList extends ProfileMiniList +{ + function newListItem($profile) + { + return new SubscribersMiniListItem($profile, $this->action); + } +} + +class SubscribersMiniListItem extends ProfileMiniListItem +{ + function linkAttributes() + { + $aAttrs = parent::linkAttributes(); + if (common_config('nofollow', 'subscribers')) { + $aAttrs['rel'] .= ' nofollow'; + } + return $aAttrs; + } +} + diff --git a/lib/profileformaction.php b/lib/profileformaction.php index 8a934666e..51c89a922 100644 --- a/lib/profileformaction.php +++ b/lib/profileformaction.php @@ -41,7 +41,7 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * @link http://status.net/ */ -class ProfileFormAction extends Action +class ProfileFormAction extends RedirectingAction { var $profile = null; @@ -60,7 +60,16 @@ class ProfileFormAction extends Action $this->checkSessionToken(); if (!common_logged_in()) { - $this->clientError(_('Not logged in.')); + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + $this->clientError(_('Not logged in.')); + } else { + // Redirect to login. + common_set_returnto($this->selfUrl()); + $user = common_current_user(); + if (Event::handle('RedirectToLogin', array($this, $user))) { + common_redirect(common_local_url('login'), 303); + } + } return false; } @@ -97,30 +106,7 @@ class ProfileFormAction extends Action if ($_SERVER['REQUEST_METHOD'] == 'POST') { $this->handlePost(); - $this->returnToArgs(); - } - } - - /** - * Return to the calling page based on hidden arguments - * - * @return void - */ - - function returnToArgs() - { - foreach ($this->args as $k => $v) { - if ($k == 'returnto-action') { - $action = $v; - } else if (substr($k, 0, 9) == 'returnto-') { - $args[substr($k, 9)] = $v; - } - } - - if ($action) { - common_redirect(common_local_url($action, $args), 303); - } else { - $this->clientError(_("No return-to arguments.")); + $this->returnToPrevious(); } } diff --git a/lib/profilelist.php b/lib/profilelist.php index d970e605a..b010fb724 100644 --- a/lib/profilelist.php +++ b/lib/profilelist.php @@ -181,9 +181,8 @@ class ProfileListItem extends Widget function showAvatar() { $avatar = $this->profile->getAvatar(AVATAR_STREAM_SIZE); - $this->out->elementStart('a', array('href' => $this->profile->profileurl, - 'class' => 'url entry-title', - 'rel' => 'contact')); + $aAttrs = $this->linkAttributes(); + $this->out->elementStart('a', $aAttrs); $this->out->element('img', array('src' => ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_STREAM_SIZE), 'class' => 'photo avatar', 'width' => AVATAR_STREAM_SIZE, @@ -213,7 +212,7 @@ class ProfileListItem extends Widget { if (!empty($this->profile->location)) { $this->out->text(' '); - $this->out->elementStart('span', 'location'); + $this->out->elementStart('span', 'label'); $this->out->raw($this->highlight($this->profile->location)); $this->out->elementEnd('span'); } @@ -223,8 +222,8 @@ class ProfileListItem extends Widget { if (!empty($this->profile->homepage)) { $this->out->text(' '); - $this->out->elementStart('a', array('href' => $this->profile->homepage, - 'class' => 'url')); + $aAttrs = $this->homepageAttributes(); + $this->out->elementStart('a', $aAttrs); $this->out->raw($this->highlight($this->profile->homepage)); $this->out->elementEnd('a'); } @@ -299,4 +298,17 @@ class ProfileListItem extends Widget { return htmlspecialchars($text); } + + function linkAttributes() + { + return array('href' => $this->profile->profileurl, + 'class' => 'url entry-title', + 'rel' => 'contact'); + } + + function homepageAttributes() + { + return array('href' => $this->profile->homepage, + 'class' => 'url'); + } } diff --git a/lib/profileminilist.php b/lib/profileminilist.php index 079170d80..a98953474 100644 --- a/lib/profileminilist.php +++ b/lib/profileminilist.php @@ -81,20 +81,36 @@ class ProfileMiniListItem extends ProfileListItem function show() { $this->out->elementStart('li', 'vcard'); - $this->out->elementStart('a', array('title' => $this->profile->getBestName(), - 'href' => $this->profile->profileurl, - 'rel' => 'contact member', - 'class' => 'url')); - $avatar = $this->profile->getAvatar(AVATAR_MINI_SIZE); - $this->out->element('img', array('src' => (($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_MINI_SIZE)), - 'width' => AVATAR_MINI_SIZE, - 'height' => AVATAR_MINI_SIZE, - 'class' => 'avatar photo', - 'alt' => ($this->profile->fullname) ? - $this->profile->fullname : - $this->profile->nickname)); - $this->out->element('span', 'fn nickname', $this->profile->nickname); - $this->out->elementEnd('a'); - $this->out->elementEnd('li'); + if (Event::handle('StartProfileListItemProfileElements', array($this))) { + if (Event::handle('StartProfileListItemAvatar', array($this))) { + $aAttrs = $this->linkAttributes(); + $this->out->elementStart('a', $aAttrs); + $avatar = $this->profile->getAvatar(AVATAR_MINI_SIZE); + $this->out->element('img', array('src' => (($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_MINI_SIZE)), + 'width' => AVATAR_MINI_SIZE, + 'height' => AVATAR_MINI_SIZE, + 'class' => 'avatar photo', + 'alt' => ($this->profile->fullname) ? + $this->profile->fullname : + $this->profile->nickname)); + $this->out->element('span', 'fn nickname', $this->profile->nickname); + $this->out->elementEnd('a'); + Event::handle('EndProfileListItemAvatar', array($this)); + } + $this->out->elementEnd('li'); + } + } + + // default; overridden for nofollow lists + + function linkAttributes() + { + $aAttrs = parent::linkAttributes(); + + $aAttrs['title'] = $this->profile->getBestName(); + $aAttrs['rel'] = 'contact member'; // @todo: member? always? + $aAttrs['class'] = 'url'; + + return $aAttrs; } } diff --git a/lib/redirectingaction.php b/lib/redirectingaction.php new file mode 100644 index 000000000..3a358f891 --- /dev/null +++ b/lib/redirectingaction.php @@ -0,0 +1,97 @@ +<?php +/** + * Superclass for actions that redirect to a given return-to page on completion. + * + * PHP version 5 + * + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2009-2010, StatusNet, Inc. + * + * 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 Action + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Brion Vibber <brion@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +/** + * Superclass for actions that redirect to a given return-to page on completion. + * + * @category Action + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Brion Vibber <brion@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl.html AGPLv3 + * @link http://status.net/ + */ + + +class RedirectingAction extends Action +{ + + /** + * Redirect browser to the page our hidden parameters requested, + * or if none given, to the url given by $this->defaultReturnTo(). + * + * To be called only after successful processing. + * + * Note: this was named returnToArgs() up through 0.9.2, which + * caused problems because there's an Action::returnToArgs() + * already which does something different. + * + * @return void + */ + function returnToPrevious() + { + // Now, gotta figure where we go back to + $action = false; + $args = array(); + $params = array(); + foreach ($this->args as $k => $v) { + if ($k == 'returnto-action') { + $action = $v; + } else if (substr($k, 0, 15) == 'returnto-param-') { + $params[substr($k, 15)] = $v; + } elseif (substr($k, 0, 9) == 'returnto-') { + $args[substr($k, 9)] = $v; + } + } + + if ($action) { + common_redirect(common_local_url($action, $args, $params), 303); + } else { + $url = $this->defaultReturnTo(); + } + common_redirect($url, 303); + } + + /** + * If we reached this form without returnto arguments, where should + * we go? May be overridden by subclasses to a reasonable destination + * for that action; default implementation throws an exception. + * + * @return string URL + */ + function defaultReturnTo() + { + $this->clientError(_("No return-to arguments.")); + } +} diff --git a/lib/router.php b/lib/router.php index a48ee875e..7e1e6a2a4 100644 --- a/lib/router.php +++ b/lib/router.php @@ -33,6 +33,33 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { require_once 'Net/URL/Mapper.php'; +class StatusNet_URL_Mapper extends Net_URL_Mapper { + + private static $_singleton = null; + + private function __construct() + { + } + + public static function getInstance($id = '__default__') + { + if (empty(self::$_singleton)) { + self::$_singleton = new StatusNet_URL_Mapper(); + } + return self::$_singleton; + } + + public function connect($path, $defaults = array(), $rules = array()) + { + $result = null; + if (Event::handle('StartConnectPath', array(&$path, &$defaults, &$rules, &$result))) { + $result = parent::connect($path, $defaults, $rules); + Event::handle('EndConnectPath', array($path, $defaults, $rules, $result)); + } + return $result; + } +} + /** * URL Router * @@ -69,7 +96,7 @@ class Router function initialize() { - $m = Net_URL_Mapper::getInstance(); + $m = StatusNet_URL_Mapper::getInstance(); if (Event::handle('StartInitializeRouter', array(&$m))) { @@ -109,6 +136,11 @@ class Router $m->connect('main/'.$a, array('action' => $a)); } + // Also need a block variant accepting ID on URL for mail links + $m->connect('main/block/:profileid', + array('action' => 'block'), + array('profileid' => '[0-9]+')); + $m->connect('main/sup/:seconds', array('action' => 'sup'), array('seconds' => '[0-9]+')); @@ -231,7 +263,7 @@ class Router $m->connect('tag', array('action' => 'publictagcloud')); $m->connect('tag/:tag/rss', array('action' => 'tagrss'), - array('tag' => '[a-zA-Z0-9]+')); + array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect('tag/:tag', array('action' => 'tag'), array('tag' => '[\pL\pN_\-\.]{1,64}')); @@ -508,7 +540,7 @@ class Router $m->connect('api/favorites/:id.:format', array('action' => 'ApiTimelineFavorites', 'id' => '[a-zA-Z0-9]+', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); $m->connect('api/favorites/create/:id.:format', array('action' => 'ApiFavoriteCreate', @@ -565,7 +597,7 @@ class Router $m->connect('api/statusnet/groups/timeline/:id.:format', array('action' => 'ApiTimelineGroup', 'id' => '[a-zA-Z0-9]+', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); $m->connect('api/statusnet/groups/show.:format', array('action' => 'ApiGroupShow', @@ -626,7 +658,7 @@ class Router // Tags $m->connect('api/statusnet/tags/timeline/:tag.:format', array('action' => 'ApiTimelineTag', - 'format' => '(xmljson|rss|atom)')); + 'format' => '(xml|json|rss|atom)')); // media related $m->connect( @@ -635,9 +667,9 @@ class Router ); // search - $m->connect('api/search.atom', array('action' => 'twitapisearchatom')); - $m->connect('api/search.json', array('action' => 'twitapisearchjson')); - $m->connect('api/trends.json', array('action' => 'twitapitrends')); + $m->connect('api/search.atom', array('action' => 'ApiSearchAtom')); + $m->connect('api/search.json', array('action' => 'ApiSearchJSON')); + $m->connect('api/trends.json', array('action' => 'ApiTrends')); $m->connect('api/oauth/request_token', array('action' => 'apioauthrequesttoken')); @@ -717,12 +749,12 @@ class Router $m->connect('tag/:tag/rss', array('action' => 'userrss', 'nickname' => $nickname), - array('tag' => '[a-zA-Z0-9]+')); + array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect('tag/:tag', array('action' => 'showstream', 'nickname' => $nickname), - array('tag' => '[a-zA-Z0-9]+')); + array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect('rsd.xml', array('action' => 'rsd', @@ -783,12 +815,12 @@ class Router $m->connect(':nickname/tag/:tag/rss', array('action' => 'userrss'), array('nickname' => '[a-zA-Z0-9]{1,64}'), - array('tag' => '[a-zA-Z0-9]+')); + array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect(':nickname/tag/:tag', array('action' => 'showstream'), array('nickname' => '[a-zA-Z0-9]{1,64}'), - array('tag' => '[a-zA-Z0-9]+')); + array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect(':nickname/rsd.xml', array('action' => 'rsd'), diff --git a/lib/rssaction.php b/lib/rssaction.php index 62e3f21b6..f366db972 100644 --- a/lib/rssaction.php +++ b/lib/rssaction.php @@ -178,7 +178,13 @@ class Rss10Action extends Action if (count($this->notices)) { foreach ($this->notices as $n) { - $this->showItem($n); + try { + $this->showItem($n); + } catch (Exception $e) { + // log exceptions and continue + common_log(LOG_ERR, $e->getMessage()); + continue; + } } } @@ -232,7 +238,7 @@ class Rss10Action extends Action function showItem($notice) { - $profile = Profile::staticGet($notice->profile_id); + $profile = $notice->getProfile(); $nurl = common_local_url('shownotice', array('notice' => $notice->id)); $creator_uri = common_profile_uri($profile); $this->elementStart('item', array('rdf:about' => $notice->uri, diff --git a/lib/schema.php b/lib/schema.php index 1503c96d4..e5def514e 100644 --- a/lib/schema.php +++ b/lib/schema.php @@ -47,40 +47,47 @@ if (!defined('STATUSNET')) { class Schema { - static $_single = null; + static $_static = null; protected $conn = null; /** * Constructor. Only run once for singleton object. */ - protected function __construct() + protected function __construct($conn = null) { - // XXX: there should be an easier way to do this. - $user = new User(); - - $this->conn = $user->getDatabaseConnection(); - - $user->free(); + if (is_null($conn)) { + // XXX: there should be an easier way to do this. + $user = new User(); + $conn = $user->getDatabaseConnection(); + $user->free(); + unset($user); + } - unset($user); + $this->conn = $conn; } /** * Main public entry point. Use this to get - * the singleton object. + * the schema object. * - * @return Schema the (single) Schema object + * @return Schema the Schema object for the connection */ - static function get() + static function get($conn = null) { + if (is_null($conn)) { + $key = 'default'; + } else { + $key = md5(serialize($conn->dsn)); + } + $type = common_config('db', 'type'); - if (empty(self::$_single)) { + if (empty(self::$_static[$key])) { $schemaClass = ucfirst($type).'Schema'; - self::$_single = new $schemaClass(); + self::$_static[$key] = new $schemaClass($conn); } - return self::$_single; + return self::$_static[$key]; } /** diff --git a/lib/servererroraction.php b/lib/servererroraction.php index 0993a63bc..9b5a553dc 100644 --- a/lib/servererroraction.php +++ b/lib/servererroraction.php @@ -62,15 +62,18 @@ class ServerErrorAction extends ErrorAction 504 => 'Gateway Timeout', 505 => 'HTTP Version Not Supported'); - function __construct($message='Error', $code=500) + function __construct($message='Error', $code=500, $ex=null) { parent::__construct($message, $code); $this->default = 500; // Server errors must be logged. - - common_log(LOG_ERR, "ServerErrorAction: $code $message"); + $log = "ServerErrorAction: $code $message"; + if ($ex) { + $log .= "\n" . $ex->getTraceAsString(); + } + common_log(LOG_ERR, $log); } // XXX: Should these error actions even be invokable via URI? diff --git a/lib/statusnet.php b/lib/statusnet.php index eba9ab9b8..2aa73486e 100644 --- a/lib/statusnet.php +++ b/lib/statusnet.php @@ -31,6 +31,7 @@ class StatusNet { protected static $have_config; protected static $is_api; + protected static $plugins = array(); /** * Configure and instantiate a plugin into the current configuration. @@ -74,10 +75,23 @@ class StatusNet $inst->$aname = $avalue; } } + + // Record activated plugins for later display/config dump + self::$plugins[] = array($name, $attrs); + return true; } /** + * Get a list of activated plugins in this process. + * @return array of (string $name, array $args) pairs + */ + public static function getActivePlugins() + { + return self::$plugins; + } + + /** * Initialize, or re-initialize, StatusNet global configuration * and plugins. * @@ -232,6 +246,7 @@ class StatusNet global $_server, $_path, $config; Event::clearHandlers(); + self::$plugins = array(); // try to figure out where we are. $server and $path // can be set by including module, else we guess based diff --git a/lib/stompqueuemanager.php b/lib/stompqueuemanager.php index 9af8b2f48..91faa8c36 100644 --- a/lib/stompqueuemanager.php +++ b/lib/stompqueuemanager.php @@ -39,7 +39,8 @@ class StompQueueManager extends QueueManager protected $base; protected $control; - protected $useTransactions = true; + protected $useTransactions; + protected $useAcks; protected $sites = array(); protected $subscriptions = array(); @@ -59,11 +60,13 @@ class StompQueueManager extends QueueManager } else { $this->servers = array($server); } - $this->username = common_config('queue', 'stomp_username'); - $this->password = common_config('queue', 'stomp_password'); - $this->base = common_config('queue', 'queue_basename'); - $this->control = common_config('queue', 'control_channel'); - $this->breakout = common_config('queue', 'breakout'); + $this->username = common_config('queue', 'stomp_username'); + $this->password = common_config('queue', 'stomp_password'); + $this->base = common_config('queue', 'queue_basename'); + $this->control = common_config('queue', 'control_channel'); + $this->breakout = common_config('queue', 'breakout'); + $this->useTransactions = common_config('queue', 'stomp_transactions'); + $this->useAcks = common_config('queue', 'stomp_acks'); } /** @@ -112,14 +115,27 @@ class StompQueueManager extends QueueManager * * @param mixed $object * @param string $queue + * @param string $siteNickname optional override to drop into another site's queue * * @return boolean true on success * @throws StompException on connection or send error */ - public function enqueue($object, $queue) + public function enqueue($object, $queue, $siteNickname=null) { $this->_connect(); - return $this->_doEnqueue($object, $queue, $this->defaultIdx); + if (common_config('queue', 'stomp_enqueue_on')) { + // We're trying to force all writes to a single server. + // WARNING: this might do odd things if that server connection dies. + $idx = array_search(common_config('queue', 'stomp_enqueue_on'), + $this->servers); + if ($idx === false) { + common_log(LOG_ERR, 'queue stomp_enqueue_on setting does not match our server list.'); + $idx = $this->defaultIdx; + } + } else { + $idx = $this->defaultIdx; + } + return $this->_doEnqueue($object, $queue, $idx, $siteNickname); } /** @@ -129,10 +145,10 @@ class StompQueueManager extends QueueManager * @return boolean true on success * @throws StompException on connection or send error */ - protected function _doEnqueue($object, $queue, $idx) + protected function _doEnqueue($object, $queue, $idx, $siteNickname=null) { $rep = $this->logrep($object); - $envelope = array('site' => common_config('site', 'nickname'), + $envelope = array('site' => $siteNickname ? $siteNickname : common_config('site', 'nickname'), 'handler' => $queue, 'payload' => $this->encode($object)); $msg = serialize($envelope); @@ -703,13 +719,15 @@ class StompQueueManager extends QueueManager protected function ack($idx, $frame) { - if ($this->useTransactions) { - if (empty($this->transaction[$idx])) { - throw new Exception("Tried to ack but not in a transaction"); + if ($this->useAcks) { + if ($this->useTransactions) { + if (empty($this->transaction[$idx])) { + throw new Exception("Tried to ack but not in a transaction"); + } + $this->cons[$idx]->ack($frame, $this->transaction[$idx]); + } else { + $this->cons[$idx]->ack($frame); } - $this->cons[$idx]->ack($frame, $this->transaction[$idx]); - } else { - $this->cons[$idx]->ack($frame); } } diff --git a/lib/subscriptionlist.php b/lib/subscriptionlist.php index e1207774f..fc8f33f2e 100644 --- a/lib/subscriptionlist.php +++ b/lib/subscriptionlist.php @@ -113,12 +113,13 @@ class SubscriptionListItem extends ProfileListItem $this->out->elementStart('ul', 'tags xoxo'); foreach ($tags as $tag) { $this->out->elementStart('li'); - $this->out->element('span', 'mark_hash', '#'); - $this->out->element('a', array('rel' => 'tag', - 'href' => common_local_url($this->action->trimmed('action'), - array('nickname' => $this->owner->nickname, - 'tag' => $tag))), - $tag); + // Avoid space by using raw output. + $pt = '<span class="mark_hash">#</span><a rel="tag" href="' . + common_local_url($this->action->trimmed('action'), + array('nickname' => $this->owner->nickname, + 'tag' => $tag)) . + '">' . $tag . '</a>'; + $this->out->raw($pt); $this->out->elementEnd('li'); } $this->out->elementEnd('ul'); diff --git a/lib/theme.php b/lib/theme.php index 0be8c3b9d..a9d0cbc84 100644 --- a/lib/theme.php +++ b/lib/theme.php @@ -38,6 +38,9 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * Themes are directories with some expected sub-directories and files * in them. They're found in either local/theme (for locally-installed themes) * or theme/ subdir of installation dir. + * + * Note that the 'local' directory can be overridden as $config['local']['path'] + * and $config['local']['dir'] etc. * * This used to be a couple of functions, but for various reasons it's nice * to have a class instead. @@ -76,7 +79,7 @@ class Theme if (file_exists($fulldir) && is_dir($fulldir)) { $this->dir = $fulldir; - $this->path = common_path('local/theme/'.$name.'/'); + $this->path = $this->relativeThemePath('local', 'local', 'theme/' . $name); return; } @@ -89,42 +92,63 @@ class Theme if (file_exists($fulldir) && is_dir($fulldir)) { $this->dir = $fulldir; + $this->path = $this->relativeThemePath('theme', 'theme', $name); + } + } - $path = common_config('theme', 'path'); + /** + * Build a full URL to the given theme's base directory, possibly + * using an offsite theme server path. + * + * @param string $group configuration section name to pull paths from + * @param string $fallbackSubdir default subdirectory under INSTALLDIR + * @param string $name theme name + * + * @return string URL + * + * @todo consolidate code with that for other customizable paths + */ - if (empty($path)) { - $path = common_config('site', 'path') . '/theme/'; - } + protected function relativeThemePath($group, $fallbackSubdir, $name) + { + $path = common_config($group, 'path'); - if ($path[strlen($path)-1] != '/') { - $path .= '/'; + if (empty($path)) { + $path = common_config('site', 'path') . '/'; + if ($fallbackSubdir) { + $path .= $fallbackSubdir . '/'; } + } - if ($path[0] != '/') { - $path = '/'.$path; - } + if ($path[strlen($path)-1] != '/') { + $path .= '/'; + } - $server = common_config('theme', 'server'); + if ($path[0] != '/') { + $path = '/'.$path; + } - if (empty($server)) { - $server = common_config('site', 'server'); - } + $server = common_config($group, 'server'); - $ssl = common_config('theme', 'ssl'); + if (empty($server)) { + $server = common_config('site', 'server'); + } - if (is_null($ssl)) { // null -> guess - if (common_config('site', 'ssl') == 'always' && - !common_config('theme', 'server')) { - $ssl = true; - } else { - $ssl = false; - } + $ssl = common_config($group, 'ssl'); + + if (is_null($ssl)) { // null -> guess + if (common_config('site', 'ssl') == 'always' && + !common_config($group, 'server')) { + $ssl = true; + } else { + $ssl = false; } + } - $protocol = ($ssl) ? 'https' : 'http'; + $protocol = ($ssl) ? 'https' : 'http'; - $this->path = $protocol . '://'.$server.$path.$name; - } + $path = $protocol . '://'.$server.$path.$name; + return $path; } /** @@ -236,7 +260,13 @@ class Theme protected static function localRoot() { - return INSTALLDIR.'/local/theme'; + $basedir = common_config('local', 'dir'); + + if (empty($basedir)) { + $basedir = INSTALLDIR . '/local'; + } + + return $basedir . '/theme'; } /** diff --git a/lib/themeuploader.php b/lib/themeuploader.php new file mode 100644 index 000000000..370965db0 --- /dev/null +++ b/lib/themeuploader.php @@ -0,0 +1,311 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Utilities for theme files and paths + * + * PHP version 5 + * + * LICENCE: 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 Paths + * @package StatusNet + * @author Brion Vibber <brion@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') && !defined('LACONICA')) { + exit(1); +} + +/** + * Encapsulation of the validation-and-save process when dealing with + * a user-uploaded StatusNet theme archive... + * + * @todo extract theme metadata from css/display.css + * @todo allow saving multiple themes + */ +class ThemeUploader +{ + protected $sourceFile; + protected $isUpload; + private $prevErrorReporting; + + public function __construct($filename) + { + if (!class_exists('ZipArchive')) { + throw new Exception(_("This server cannot handle theme uploads without ZIP support.")); + } + $this->sourceFile = $filename; + } + + public static function fromUpload($name) + { + if (!isset($_FILES[$name]['error'])) { + throw new ServerException(_("The theme file is missing or the upload failed.")); + } + if ($_FILES[$name]['error'] != UPLOAD_ERR_OK) { + throw new ServerException(_("The theme file is missing or the upload failed.")); + } + return new ThemeUploader($_FILES[$name]['tmp_name']); + } + + /** + * @param string $destDir + * @throws Exception on bogus files + */ + public function extract($destDir) + { + $zip = $this->openArchive(); + + // First pass: validate but don't save anything to disk. + // Any errors will trip an exception. + $this->traverseArchive($zip); + + // Second pass: now that we know we're good, actually extract! + $tmpDir = $destDir . '.tmp' . getmypid(); + $this->traverseArchive($zip, $tmpDir); + + $zip->close(); + + if (file_exists($destDir)) { + $killDir = $tmpDir . '.old'; + $this->quiet(); + $ok = rename($destDir, $killDir); + $this->loud(); + if (!$ok) { + common_log(LOG_ERR, "Could not move old custom theme from $destDir to $killDir"); + throw new ServerException(_("Failed saving theme.")); + } + } else { + $killDir = false; + } + + $this->quiet(); + $ok = rename($tmpDir, $destDir); + $this->loud(); + if (!$ok) { + common_log(LOG_ERR, "Could not move saved theme from $tmpDir to $destDir"); + throw new ServerException(_("Failed saving theme.")); + } + + if ($killDir) { + $this->recursiveRmdir($killDir); + } + } + + /** + * + */ + protected function traverseArchive($zip, $outdir=false) + { + $sizeLimit = 2 * 1024 * 1024; // 2 megabyte space limit? + $blockSize = 4096; // estimated; any entry probably takes this much space + + $totalSize = 0; + $hasMain = false; + $commonBaseDir = false; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $data = $zip->statIndex($i); + $name = str_replace('\\', '/', $data['name']); + + if (substr($name, -1) == '/') { + // A raw directory... skip! + continue; + } + + // Check the directory structure... + $path = pathinfo($name); + $dirs = explode('/', $path['dirname']); + $baseDir = array_shift($dirs); + if ($commonBaseDir === false) { + $commonBaseDir = $baseDir; + } else { + if ($commonBaseDir != $baseDir) { + throw new ClientException(_("Invalid theme: bad directory structure.")); + } + } + + foreach ($dirs as $dir) { + $this->validateFileOrFolder($dir); + } + + // Is this a safe or skippable file? + if ($this->skippable($path['filename'], $path['extension'])) { + // Documentation and such... booooring + continue; + } else { + $this->validateFile($path['filename'], $path['extension']); + } + + $fullPath = $dirs; + $fullPath[] = $path['basename']; + $localFile = implode('/', $fullPath); + if ($localFile == 'css/display.css') { + $hasMain = true; + } + + $size = $data['size']; + $estSize = $blockSize * max(1, intval(ceil($size / $blockSize))); + $totalSize += $estSize; + if ($totalSize > $sizeLimit) { + $msg = sprintf(_("Uploaded theme is too large; " . + "must be less than %d bytes uncompressed."), + $sizeLimit); + throw new ClientException($msg); + } + + if ($outdir) { + $this->extractFile($zip, $data['name'], "$outdir/$localFile"); + } + } + + if (!$hasMain) { + throw new ClientException(_("Invalid theme archive: " . + "missing file css/display.css")); + } + } + + protected function skippable($filename, $ext) + { + $skip = array('txt', 'rtf', 'doc', 'docx', 'odt'); + if (strtolower($filename) == 'readme') { + return true; + } + if (in_array(strtolower($ext), $skip)) { + return true; + } + return false; + } + + protected function validateFile($filename, $ext) + { + $this->validateFileOrFolder($filename); + $this->validateExtension($ext); + // @fixme validate content + } + + protected function validateFileOrFolder($name) + { + if (!preg_match('/^[a-z0-9_-]+$/i', $name)) { + $msg = _("Theme contains invalid file or folder name. " . + "Stick with ASCII letters, digits, underscore, and minus sign."); + throw new ClientException($msg); + } + return true; + } + + protected function validateExtension($ext) + { + $allowed = array('css', 'png', 'gif', 'jpg', 'jpeg'); + if (!in_array(strtolower($ext), $allowed)) { + $msg = sprintf(_("Theme contains file of type '.%s', " . + "which is not allowed."), + $ext); + throw new ClientException($msg); + } + return true; + } + + /** + * @return ZipArchive + */ + protected function openArchive() + { + $zip = new ZipArchive; + $ok = $zip->open($this->sourceFile); + if ($ok !== true) { + common_log(LOG_ERR, "Error opening theme zip archive: " . + "{$this->sourceFile} code: {$ok}"); + throw new Exception(_("Error opening theme archive.")); + } + return $zip; + } + + /** + * @param ZipArchive $zip + * @param string $from original path inside ZIP archive + * @param string $to final destination path in filesystem + */ + protected function extractFile($zip, $from, $to) + { + $dir = dirname($to); + if (!file_exists($dir)) { + $this->quiet(); + $ok = mkdir($dir, 0755, true); + $this->loud(); + if (!$ok) { + common_log(LOG_ERR, "Failed to mkdir $dir while uploading theme"); + throw new ServerException(_("Failed saving theme.")); + } + } else if (!is_dir($dir)) { + common_log(LOG_ERR, "Output directory $dir not a directory while uploading theme"); + throw new ServerException(_("Failed saving theme.")); + } + + // ZipArchive::extractTo would be easier, but won't let us alter + // the directory structure. + $in = $zip->getStream($from); + if (!$in) { + common_log(LOG_ERR, "Couldn't open archived file $from while uploading theme"); + throw new ServerException(_("Failed saving theme.")); + } + $this->quiet(); + $out = fopen($to, "wb"); + $this->loud(); + if (!$out) { + common_log(LOG_ERR, "Couldn't open output file $to while uploading theme"); + throw new ServerException(_("Failed saving theme.")); + } + while (!feof($in)) { + $buffer = fread($in, 65536); + fwrite($out, $buffer); + } + fclose($in); + fclose($out); + } + + private function quiet() + { + $this->prevErrorReporting = error_reporting(); + error_reporting($this->prevErrorReporting & ~E_WARNING); + } + + private function loud() + { + error_reporting($this->prevErrorReporting); + } + + private function recursiveRmdir($dir) + { + $list = dir($dir); + while (($file = $list->read()) !== false) { + if ($file == '.' || $file == '..') { + continue; + } + $full = "$dir/$file"; + if (is_dir($full)) { + $this->recursiveRmdir($full); + } else { + unlink($full); + } + } + $list->close(); + rmdir($dir); + } + +} diff --git a/lib/userprofile.php b/lib/userprofile.php index 2c3b1ea45..ca060842b 100644 --- a/lib/userprofile.php +++ b/lib/userprofile.php @@ -71,7 +71,8 @@ class UserProfile extends Widget { if (Event::handle('StartProfilePageProfileSection', array(&$this->out, $this->profile))) { - $this->out->elementStart('div', 'entity_profile vcard author'); + $this->out->elementStart('div', array('id' => 'i', + 'class' => 'entity_profile vcard author')); $this->out->element('h2', null, _('User profile')); if (Event::handle('StartProfilePageProfileElements', array(&$this->out, $this->profile))) { diff --git a/lib/util.php b/lib/util.php index a30d69100..9f62097d5 100644 --- a/lib/util.php +++ b/lib/util.php @@ -34,6 +34,14 @@ function common_user_error($msg, $code=400) $err->showPage(); } +/** + * This should only be used at setup; processes switching languages + * to send text to other users should use common_switch_locale(). + * + * @param string $language Locale language code (optional; empty uses + * current user's preference or site default) + * @return mixed success + */ function common_init_locale($language=null) { if(!$language) { @@ -41,13 +49,24 @@ function common_init_locale($language=null) } putenv('LANGUAGE='.$language); putenv('LANG='.$language); - return setlocale(LC_ALL, $language . ".utf8", + $ok = setlocale(LC_ALL, $language . ".utf8", $language . ".UTF8", $language . ".utf-8", $language . ".UTF-8", $language); + + return $ok; } +/** + * Initialize locale and charset settings and gettext with our message catalog, + * using the current user's language preference or the site default. + * + * This should generally only be run at framework initialization; code switching + * languages at runtime should call common_switch_language(). + * + * @access private + */ function common_init_language() { mb_internal_encoding('UTF-8'); @@ -69,8 +88,8 @@ function common_init_language() // don't do the job. en_US.UTF-8 should be there most of the // time, but not guaranteed. $ok = common_init_locale("en_US"); - if (!$ok) { - // Try to find a complete, working locale... + if (!$ok && strtolower(substr(PHP_OS, 0, 3)) != 'win') { + // Try to find a complete, working locale on Unix/Linux... // @fixme shelling out feels awfully inefficient // but I don't think there's a more standard way. $all = `locale -a`; @@ -82,13 +101,39 @@ function common_init_language() } } } - if (!$ok) { - common_log(LOG_ERR, "Unable to find a UTF-8 locale on this system; UI translations may not work."); - } + } + if (!$ok) { + common_log(LOG_ERR, "Unable to find a UTF-8 locale on this system; UI translations may not work."); } $locale_set = common_init_locale($language); } + common_init_gettext(); +} + +/** + * @access private + */ +function common_init_gettext() +{ + setlocale(LC_CTYPE, 'C'); + // So we do not have to make people install the gettext locales + $path = common_config('site','locale_path'); + bindtextdomain("statusnet", $path); + bind_textdomain_codeset("statusnet", "UTF-8"); + textdomain("statusnet"); +} + +/** + * Switch locale during runtime, and poke gettext until it cries uncle. + * Otherwise, sometimes it doesn't actually switch away from the old language. + * + * @param string $language code for locale ('en', 'fr', 'pt_BR' etc) + */ +function common_switch_locale($language=null) +{ + common_init_locale($language); + setlocale(LC_CTYPE, 'C'); // So we do not have to make people install the gettext locales $path = common_config('site','locale_path'); @@ -97,6 +142,7 @@ function common_init_language() textdomain("statusnet"); } + function common_timezone() { if (common_logged_in()) { @@ -803,7 +849,7 @@ function common_linkify($url) { } if (!empty($f)) { - if ($f->getEnclosure()) { + if ($f->getEnclosure() || File_oembed::staticGet('file_id',$f->id)) { $is_attachment = true; $attachment_id = $f->id; @@ -826,10 +872,10 @@ function common_linkify($url) { return XMLStringer::estring('a', $attrs, $url); } -function common_shorten_links($text) +function common_shorten_links($text, $always = false) { $maxLength = Notice::maxContent(); - if ($maxLength == 0 || mb_strlen($text) <= $maxLength) return $text; + if (!$always && ($maxLength == 0 || mb_strlen($text) <= $maxLength)) return $text; return common_replace_urls_callback($text, array('File_redirection', 'makeShort')); } @@ -862,7 +908,14 @@ function common_xml_safe_str($str) function common_tag_link($tag) { $canonical = common_canonical_tag($tag); - $url = common_local_url('tag', array('tag' => $canonical)); + if (common_config('singleuser', 'enabled')) { + // regular TagAction isn't set up in 1user mode + $url = common_local_url('showstream', + array('nickname' => common_config('singleuser', 'nickname'), + 'tag' => $canonical)); + } else { + $url = common_local_url('tag', array('tag' => $canonical)); + } $xs = new XMLStringer(); $xs->elementStart('span', 'tag'); $xs->element('a', array('href' => $url, @@ -1043,24 +1096,38 @@ function common_date_string($dt) if ($now < $t) { // that shouldn't happen! return common_exact_date($dt); } else if ($diff < 60) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('a few seconds ago'); } else if ($diff < 92) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('about a minute ago'); } else if ($diff < 3300) { + // XXX: should support plural. + // TRANS: Used in notices to indicate when the notice was made compared to now. return sprintf(_('about %d minutes ago'), round($diff/60)); } else if ($diff < 5400) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('about an hour ago'); } else if ($diff < 22 * 3600) { + // XXX: should support plural. + // TRANS: Used in notices to indicate when the notice was made compared to now. return sprintf(_('about %d hours ago'), round($diff/3600)); } else if ($diff < 37 * 3600) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('about a day ago'); } else if ($diff < 24 * 24 * 3600) { + // XXX: should support plural. + // TRANS: Used in notices to indicate when the notice was made compared to now. return sprintf(_('about %d days ago'), round($diff/(24*3600))); } else if ($diff < 46 * 24 * 3600) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('about a month ago'); } else if ($diff < 330 * 24 * 3600) { + // XXX: should support plural. + // TRANS: Used in notices to indicate when the notice was made compared to now. return sprintf(_('about %d months ago'), round($diff/(30*24*3600))); } else if ($diff < 480 * 24 * 3600) { + // TRANS: Used in notices to indicate when the notice was made compared to now. return _('about a year ago'); } else { return common_exact_date($dt); @@ -1182,9 +1249,8 @@ function common_enqueue_notice($notice) $transports[] = 'jabber'; } - // @fixme move these checks into QueueManager and/or individual handlers - if ($notice->is_local == Notice::LOCAL_PUBLIC || - $notice->is_local == Notice::LOCAL_NONPUBLIC) { + // We can skip these for gatewayed notices. + if ($notice->isLocal()) { $transports = array_merge($transports, $localTransports); if ($xmpp) { $transports[] = 'public'; @@ -1272,12 +1338,38 @@ function common_mtrand($bytes) return $enc; } +/** + * Record the given URL as the return destination for a future + * form submission, to be read by common_get_returnto(). + * + * @param string $url + * + * @fixme as a session-global setting, this can allow multiple forms + * to conflict and overwrite each others' returnto destinations if + * the user has multiple tabs or windows open. + * + * Should refactor to index with a token or otherwise only pass the + * data along its intended path. + */ function common_set_returnto($url) { common_ensure_session(); $_SESSION['returnto'] = $url; } +/** + * Fetch a return-destination URL previously recorded by + * common_set_returnto(). + * + * @return mixed URL string or null + * + * @fixme as a session-global setting, this can allow multiple forms + * to conflict and overwrite each others' returnto destinations if + * the user has multiple tabs or windows open. + * + * Should refactor to index with a token or otherwise only pass the + * data along its intended path. + */ function common_get_returnto() { common_ensure_session(); @@ -1303,7 +1395,7 @@ function common_log_line($priority, $msg) { static $syslog_priorities = array('LOG_EMERG', 'LOG_ALERT', 'LOG_CRIT', 'LOG_ERR', 'LOG_WARNING', 'LOG_NOTICE', 'LOG_INFO', 'LOG_DEBUG'); - return date('Y-m-d H:i:s') . ' ' . $syslog_priorities[$priority] . ': ' . $msg . "\n"; + return date('Y-m-d H:i:s') . ' ' . $syslog_priorities[$priority] . ': ' . $msg . PHP_EOL; } function common_request_id() @@ -1397,6 +1489,55 @@ function common_valid_tag($tag) return false; } +/** + * Determine if given domain or address literal is valid + * eg for use in JIDs and URLs. Does not check if the domain + * exists! + * + * @param string $domain + * @return boolean valid or not + */ +function common_valid_domain($domain) +{ + $octet = "(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9][0-9]|[0-9])"; + $ipv4 = "(?:$octet(?:\.$octet){3})"; + if (preg_match("/^$ipv4$/u", $domain)) return true; + + $group = "(?:[0-9a-f]{1,4})"; + $ipv6 = "(?:\[($group(?::$group){0,7})?(::)?($group(?::$group){0,7})?\])"; // http://tools.ietf.org/html/rfc3513#section-2.2 + + if (preg_match("/^$ipv6$/ui", $domain, $matches)) { + $before = explode(":", $matches[1]); + $zeroes = $matches[2]; + $after = explode(":", $matches[3]); + if ($zeroes) { + $min = 0; + $max = 7; + } else { + $min = 1; + $max = 8; + } + $explicit = count($before) + count($after); + if ($explicit < $min || $explicit > $max) { + return false; + } + return true; + } + + try { + require_once "Net/IDNA.php"; + $idn = Net_IDNA::getInstance(); + $domain = $idn->encode($domain); + } catch (Exception $e) { + return false; + } + + $subdomain = "(?:[a-z0-9][a-z0-9-]*)"; // @fixme + $fqdn = "(?:$subdomain(?:\.$subdomain)*\.?)"; + + return preg_match("/^$fqdn$/ui", $domain); +} + /* Following functions are copied from MediaWiki GlobalFunctions.php * and written by Evan Prodromou. */ @@ -1529,7 +1670,8 @@ function common_user_uri(&$user) function common_notice_uri(&$notice) { return common_local_url('shownotice', - array('notice' => $notice->id)); + array('notice' => $notice->id), + null, null, false); } // 36 alphanums - lookalikes (0, O, 1, I) = 32 chars = 5 bits @@ -1796,6 +1938,15 @@ function common_url_to_nickname($url) $path = preg_replace('@/$@', '', $parts['path']); $path = preg_replace('@^/@', '', $path); $path = basename($path); + + // Hack for MediaWiki user pages, in the form: + // http://example.com/wiki/User:Myname + // ('User' may be localized.) + if (strpos($path, ':')) { + $parts = array_filter(explode(':', $path)); + $path = $parts[count($parts) - 1]; + } + if ($path) { return common_nicknamize($path); } diff --git a/lib/xmppmanager.php b/lib/xmppmanager.php index cca54db08..829eaa36c 100644 --- a/lib/xmppmanager.php +++ b/lib/xmppmanager.php @@ -253,12 +253,12 @@ class XmppManager extends IoManager $from = jabber_normalize_jid($pl['from']); if ($pl['type'] != 'chat') { - $this->log(LOG_WARNING, "Ignoring message of type ".$pl['type']." from $from."); + $this->log(LOG_WARNING, "Ignoring message of type ".$pl['type']." from $from: " . $pl['xml']->toString()); return; } if (mb_strlen($pl['body']) == 0) { - $this->log(LOG_WARNING, "Ignoring message with empty body from $from."); + $this->log(LOG_WARNING, "Ignoring message with empty body from $from: " . $pl['xml']->toString()); return; } diff --git a/lib/xrdsoutputter.php b/lib/xrdsoutputter.php index 4b77ed5a3..95dc73300 100644 --- a/lib/xrdsoutputter.php +++ b/lib/xrdsoutputter.php @@ -23,6 +23,7 @@ * @package StatusNet * @author Craig Andrews <candrews@integralblue.com> * @copyright 2008 StatusNet, Inc. + * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ |