diff options
Diffstat (limited to 'lib')
77 files changed, 5567 insertions, 1958 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 a804b8e82..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)); + } + } } } @@ -420,14 +431,8 @@ class Action extends HTMLOutputter // lawsuit function showPrimaryNav() { $user = common_current_user(); - $connect = ''; - if (common_config('xmpp', 'enabled')) { - $connect = 'imsettings'; - } else if (common_config('sms', 'enabled')) { - $connect = 'smssettings'; - } - $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')); @@ -435,33 +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'), - _m('MENU', 'Account'), $tooltip, false, 'nav_account'); - if ($connect) { - // 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($connect), - _m('MENU', 'Connect'), $tooltip, false, 'nav_connect'); - } + // 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'); + $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')), @@ -469,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" @@ -585,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(); @@ -651,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))) { @@ -753,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')); @@ -805,13 +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); 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); @@ -827,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; @@ -852,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; } @@ -1153,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')); @@ -1167,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'); } @@ -1175,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'); } @@ -1218,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 b20153213..8e2da99bb 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -32,967 +32,6 @@ if (!defined('STATUSNET')) { exit(1); } -class PoCoURL -{ - const URLS = 'urls'; - const TYPE = 'type'; - const VALUE = 'value'; - const PRIMARY = 'primary'; - - public $type; - public $value; - public $primary; - - function __construct($type, $value, $primary = false) - { - $this->type = $type; - $this->value = $value; - $this->primary = $primary; - } - - function asString() - { - $xs = new XMLStringer(true); - $xs->elementStart('poco:urls'); - $xs->element('poco:type', null, $this->type); - $xs->element('poco:value', null, $this->value); - if (!empty($this->primary)) { - $xs->element('poco:primary', null, 'true'); - } - $xs->elementEnd('poco:urls'); - return $xs->getString(); - } -} - -class PoCoAddress -{ - const ADDRESS = 'address'; - const FORMATTED = 'formatted'; - - public $formatted; - - // @todo Other address fields - - function asString() - { - if (!empty($this->formatted)) { - $xs = new XMLStringer(true); - $xs->elementStart('poco:address'); - $xs->element('poco:formatted', null, $this->formatted); - $xs->elementEnd('poco:address'); - return $xs->getString(); - } - - return null; - } -} - -class PoCo -{ - const NS = 'http://portablecontacts.net/spec/1.0'; - - const USERNAME = 'preferredUsername'; - const DISPLAYNAME = 'displayName'; - const NOTE = 'note'; - - public $preferredUsername; - public $displayName; - public $note; - public $address; - public $urls = array(); - - function __construct($element = null) - { - if (empty($element)) { - return; - } - - $this->preferredUsername = ActivityUtils::childContent( - $element, - self::USERNAME, - self::NS - ); - - $this->displayName = ActivityUtils::childContent( - $element, - self::DISPLAYNAME, - self::NS - ); - - $this->note = ActivityUtils::childContent( - $element, - self::NOTE, - self::NS - ); - - $this->address = $this->_getAddress($element); - $this->urls = $this->_getURLs($element); - } - - private function _getURLs($element) - { - $urlEls = $element->getElementsByTagnameNS(self::NS, PoCoURL::URLS); - $urls = array(); - - foreach ($urlEls as $urlEl) { - - $type = ActivityUtils::childContent( - $urlEl, - PoCoURL::TYPE, - PoCo::NS - ); - - $value = ActivityUtils::childContent( - $urlEl, - PoCoURL::VALUE, - PoCo::NS - ); - - $primary = ActivityUtils::childContent( - $urlEl, - PoCoURL::PRIMARY, - PoCo::NS - ); - - $isPrimary = false; - - if (isset($primary) && $primary == 'true') { - $isPrimary = true; - } - - // @todo check to make sure a primary hasn't already been added - - array_push($urls, new PoCoURL($type, $value, $isPrimary)); - } - return $urls; - } - - private function _getAddress($element) - { - $addressEl = ActivityUtils::child( - $element, - PoCoAddress::ADDRESS, - PoCo::NS - ); - - if (!empty($addressEl)) { - $formatted = ActivityUtils::childContent( - $addressEl, - PoCoAddress::FORMATTED, - self::NS - ); - - if (!empty($formatted)) { - $address = new PoCoAddress(); - $address->formatted = $formatted; - return $address; - } - } - - return null; - } - - function fromProfile($profile) - { - if (empty($profile)) { - return null; - } - - $poco = new PoCo(); - - $poco->preferredUsername = $profile->nickname; - $poco->displayName = $profile->getBestName(); - - $poco->note = $profile->bio; - - $paddy = new PoCoAddress(); - $paddy->formatted = $profile->location; - $poco->address = $paddy; - - if (!empty($profile->homepage)) { - array_push( - $poco->urls, - new PoCoURL( - 'homepage', - $profile->homepage, - true - ) - ); - } - - return $poco; - } - - function fromGroup($group) - { - if (empty($group)) { - return null; - } - - $poco = new PoCo(); - - $poco->preferredUsername = $group->nickname; - $poco->displayName = $group->getBestName(); - - $poco->note = $group->description; - - $paddy = new PoCoAddress(); - $paddy->formatted = $group->location; - $poco->address = $paddy; - - if (!empty($group->homepage)) { - array_push( - $poco->urls, - new PoCoURL( - 'homepage', - $group->homepage, - true - ) - ); - } - - return $poco; - } - - function getPrimaryURL() - { - foreach ($this->urls as $url) { - if ($url->primary) { - return $url; - } - } - } - - function asString() - { - $xs = new XMLStringer(true); - $xs->element( - 'poco:preferredUsername', - null, - $this->preferredUsername - ); - - $xs->element( - 'poco:displayName', - null, - $this->displayName - ); - - if (!empty($this->note)) { - $xs->element('poco:note', null, $this->note); - } - - if (!empty($this->address)) { - $xs->raw($this->address->asString()); - } - - foreach ($this->urls as $url) { - $xs->raw($url->asString()); - } - - return $xs->getString(); - } -} - -/** - * Utilities for turning DOMish things into Activityish things - * - * Some common functions that I didn't have the bandwidth to try to factor - * into some kind of reasonable superclass, so just dumped here. Might - * be useful to have an ActivityObject parent class or something. - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityUtils -{ - const ATOM = 'http://www.w3.org/2005/Atom'; - - const LINK = 'link'; - const REL = 'rel'; - const TYPE = 'type'; - const HREF = 'href'; - - const CONTENT = 'content'; - const SRC = 'src'; - - /** - * Get the permalink for an Activity object - * - * @param DOMElement $element A DOM element - * - * @return string related link, if any - */ - - static function getPermalink($element) - { - return self::getLink($element, 'alternate', 'text/html'); - } - - /** - * Get the permalink for an Activity object - * - * @param DOMElement $element A DOM element - * - * @return string related link, if any - */ - - static function getLink(DOMNode $element, $rel, $type=null) - { - $links = $element->getElementsByTagnameNS(self::ATOM, self::LINK); - - foreach ($links as $link) { - - $linkRel = $link->getAttribute(self::REL); - $linkType = $link->getAttribute(self::TYPE); - - if ($linkRel == $rel && - (is_null($type) || $linkType == $type)) { - return $link->getAttribute(self::HREF); - } - } - - return null; - } - - static function getLinks(DOMNode $element, $rel, $type=null) - { - $links = $element->getElementsByTagnameNS(self::ATOM, self::LINK); - $out = array(); - - foreach ($links as $link) { - - $linkRel = $link->getAttribute(self::REL); - $linkType = $link->getAttribute(self::TYPE); - - if ($linkRel == $rel && - (is_null($type) || $linkType == $type)) { - $out[] = $link; - } - } - - return $out; - } - - /** - * Gets the first child element with the given tag - * - * @param DOMElement $element element to pick at - * @param string $tag tag to look for - * @param string $namespace Namespace to look under - * - * @return DOMElement found element or null - */ - - static function child(DOMNode $element, $tag, $namespace=self::ATOM) - { - $els = $element->childNodes; - if (empty($els) || $els->length == 0) { - return null; - } else { - for ($i = 0; $i < $els->length; $i++) { - $el = $els->item($i); - if ($el->localName == $tag && $el->namespaceURI == $namespace) { - return $el; - } - } - } - } - - /** - * Grab the text content of a DOM element child of the current element - * - * @param DOMElement $element Element whose children we examine - * @param string $tag Tag to look up - * @param string $namespace Namespace to use, defaults to Atom - * - * @return string content of the child - */ - - static function childContent(DOMNode $element, $tag, $namespace=self::ATOM) - { - $el = self::child($element, $tag, $namespace); - - if (empty($el)) { - return null; - } else { - return $el->textContent; - } - } - - /** - * Get the content of an atom:entry-like object - * - * @param DOMElement $element The element to examine. - * - * @return string unencoded HTML content of the element, like "This -< is <b>HTML</b>." - * - * @todo handle remote content - * @todo handle embedded XML mime types - * @todo handle base64-encoded non-XML and non-text mime types - */ - - static function getContent($element) - { - $contentEl = ActivityUtils::child($element, self::CONTENT); - - if (!empty($contentEl)) { - - $src = $contentEl->getAttribute(self::SRC); - - if (!empty($src)) { - throw new ClientException(_("Can't handle remote content yet.")); - } - - $type = $contentEl->getAttribute(self::TYPE); - - // slavishly following http://atompub.org/rfc4287.html#rfc.section.4.1.3.3 - - if ($type == 'text') { - return $contentEl->textContent; - } else if ($type == 'html') { - $text = $contentEl->textContent; - return htmlspecialchars_decode($text, ENT_QUOTES); - } else if ($type == 'xhtml') { - $divEl = ActivityUtils::child($contentEl, 'div'); - if (empty($divEl)) { - return null; - } - $doc = $divEl->ownerDocument; - $text = ''; - $children = $divEl->childNodes; - - for ($i = 0; $i < $children->length; $i++) { - $child = $children->item($i); - $text .= $doc->saveXML($child); - } - return trim($text); - } else if (in_array(array('text/xml', 'application/xml'), $type) || - preg_match('#(+|/)xml$#', $type)) { - throw new ClientException(_("Can't handle embedded XML content yet.")); - } else if (strncasecmp($type, 'text/', 5)) { - return $contentEl->textContent; - } else { - throw new ClientException(_("Can't handle embedded Base64 content yet.")); - } - } - } -} - -// XXX: Arg! This wouldn't be necessary if we used Avatars conistently -class AvatarLink -{ - public $url; - public $type; - public $size; - public $width; - public $height; - - function __construct($element=null) - { - if ($element) { - // @fixme use correct namespaces - $this->url = $element->getAttribute('href'); - $this->type = $element->getAttribute('type'); - $width = $element->getAttribute('media:width'); - if ($width != null) { - $this->width = intval($width); - } - $height = $element->getAttribute('media:height'); - if ($height != null) { - $this->height = intval($height); - } - } - } - - static function fromAvatar($avatar) - { - if (empty($avatar)) { - return null; - } - $alink = new AvatarLink(); - $alink->type = $avatar->mediatype; - $alink->height = $avatar->height; - $alink->width = $avatar->width; - $alink->url = $avatar->displayUrl(); - return $alink; - } - - static function fromFilename($filename, $size) - { - $alink = new AvatarLink(); - $alink->url = $filename; - $alink->height = $size; - if (!empty($filename)) { - $alink->width = $size; - $alink->type = self::mediatype($filename); - } else { - $alink->url = User_group::defaultLogo($size); - $alink->type = 'image/png'; - } - return $alink; - } - - // yuck! - static function mediatype($filename) { - $ext = strtolower(end(explode('.', $filename))); - if ($ext == 'jpeg') { - $ext = 'jpg'; - } - // hope we don't support any others - $types = array('png', 'gif', 'jpg', 'jpeg'); - if (in_array($ext, $types)) { - return 'image/' . $ext; - } - return null; - } -} - -/** - * A noun-ish thing in the activity universe - * - * The activity streams spec talks about activity objects, while also having - * a tag activity:object, which is in fact an activity object. Aaaaaah! - * - * This is just a thing in the activity universe. Can be the subject, object, - * or indirect object (target!) of an activity verb. Rotten name, and I'm - * propagating it. *sigh* - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityObject -{ - const ARTICLE = 'http://activitystrea.ms/schema/1.0/article'; - const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry'; - const NOTE = 'http://activitystrea.ms/schema/1.0/note'; - const STATUS = 'http://activitystrea.ms/schema/1.0/status'; - const FILE = 'http://activitystrea.ms/schema/1.0/file'; - const PHOTO = 'http://activitystrea.ms/schema/1.0/photo'; - const ALBUM = 'http://activitystrea.ms/schema/1.0/photo-album'; - const PLAYLIST = 'http://activitystrea.ms/schema/1.0/playlist'; - const VIDEO = 'http://activitystrea.ms/schema/1.0/video'; - const AUDIO = 'http://activitystrea.ms/schema/1.0/audio'; - const BOOKMARK = 'http://activitystrea.ms/schema/1.0/bookmark'; - const PERSON = 'http://activitystrea.ms/schema/1.0/person'; - const GROUP = 'http://activitystrea.ms/schema/1.0/group'; - const PLACE = 'http://activitystrea.ms/schema/1.0/place'; - const COMMENT = 'http://activitystrea.ms/schema/1.0/comment'; - // ^^^^^^^^^^ tea! - - // Atom elements we snarf - - const TITLE = 'title'; - const SUMMARY = 'summary'; - const ID = 'id'; - const SOURCE = 'source'; - - const NAME = 'name'; - const URI = 'uri'; - const EMAIL = 'email'; - - public $element; - public $type; - public $id; - public $title; - public $summary; - public $content; - public $link; - public $source; - public $avatarLinks = array(); - public $geopoint; - public $poco; - public $displayName; - - /** - * Constructor - * - * This probably needs to be refactored - * to generate a local class (ActivityPerson, ActivityFile, ...) - * based on the object type. - * - * @param DOMElement $element DOM thing to turn into an Activity thing - */ - - function __construct($element = null) - { - if (empty($element)) { - return; - } - - $this->element = $element; - - $this->geopoint = $this->_childContent( - $element, - ActivityContext::POINT, - ActivityContext::GEORSS - ); - - if ($element->tagName == 'author') { - - $this->type = self::PERSON; // XXX: is this fair? - $this->title = $this->_childContent($element, self::NAME); - $this->id = $this->_childContent($element, self::URI); - - if (empty($this->id)) { - $email = $this->_childContent($element, self::EMAIL); - if (!empty($email)) { - // XXX: acct: ? - $this->id = 'mailto:'.$email; - } - } - - } else { - - $this->type = $this->_childContent($element, Activity::OBJECTTYPE, - Activity::SPEC); - - if (empty($this->type)) { - $this->type = ActivityObject::NOTE; - } - - $this->id = $this->_childContent($element, self::ID); - $this->title = $this->_childContent($element, self::TITLE); - $this->summary = $this->_childContent($element, self::SUMMARY); - - $this->source = $this->_getSource($element); - - $this->content = ActivityUtils::getContent($element); - - $this->link = ActivityUtils::getPermalink($element); - - } - - // Some per-type attributes... - if ($this->type == self::PERSON || $this->type == self::GROUP) { - $this->displayName = $this->title; - - $avatars = ActivityUtils::getLinks($element, 'avatar'); - foreach ($avatars as $link) { - $this->avatarLinks[] = new AvatarLink($link); - } - - $this->poco = new PoCo($element); - } - } - - private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM) - { - return ActivityUtils::childContent($element, $tag, $namespace); - } - - // Try to get a unique id for the source feed - - private function _getSource($element) - { - $sourceEl = ActivityUtils::child($element, 'source'); - - if (empty($sourceEl)) { - return null; - } else { - $href = ActivityUtils::getLink($sourceEl, 'self'); - if (!empty($href)) { - return $href; - } else { - return ActivityUtils::childContent($sourceEl, 'id'); - } - } - } - - static function fromNotice($notice) - { - $object = new ActivityObject(); - - $object->type = ActivityObject::NOTE; - - $object->id = $notice->uri; - $object->title = $notice->content; - $object->content = $notice->rendered; - $object->link = $notice->bestUrl(); - - return $object; - } - - static function fromProfile($profile) - { - $object = new ActivityObject(); - - $object->type = ActivityObject::PERSON; - $object->id = $profile->getUri(); - $object->title = $profile->getBestName(); - $object->link = $profile->profileurl; - - $orig = $profile->getOriginalAvatar(); - - if (!empty($orig)) { - $object->avatarLinks[] = AvatarLink::fromAvatar($orig); - } - - $sizes = array( - AVATAR_PROFILE_SIZE, - AVATAR_STREAM_SIZE, - AVATAR_MINI_SIZE - ); - - foreach ($sizes as $size) { - - $alink = null; - $avatar = $profile->getAvatar($size); - - if (!empty($avatar)) { - $alink = AvatarLink::fromAvatar($avatar); - } else { - $alink = new AvatarLink(); - $alink->type = 'image/png'; - $alink->height = $size; - $alink->width = $size; - $alink->url = Avatar::defaultImage($size); - } - - $object->avatarLinks[] = $alink; - } - - if (isset($profile->lat) && isset($profile->lon)) { - $object->geopoint = (float)$profile->lat - . ' ' . (float)$profile->lon; - } - - $object->poco = PoCo::fromProfile($profile); - - return $object; - } - - static function fromGroup($group) - { - $object = new ActivityObject(); - - $object->type = ActivityObject::GROUP; - $object->id = $group->getUri(); - $object->title = $group->getBestName(); - $object->link = $group->getUri(); - - $object->avatarLinks[] = AvatarLink::fromFilename( - $group->homepage_logo, - AVATAR_PROFILE_SIZE - ); - - $object->avatarLinks[] = AvatarLink::fromFilename( - $group->stream_logo, - AVATAR_STREAM_SIZE - ); - - $object->avatarLinks[] = AvatarLink::fromFilename( - $group->mini_logo, - AVATAR_MINI_SIZE - ); - - $object->poco = PoCo::fromGroup($group); - - return $object; - } - - - function asString($tag='activity:object') - { - $xs = new XMLStringer(true); - - $xs->elementStart($tag); - - $xs->element('activity:object-type', null, $this->type); - - $xs->element(self::ID, null, $this->id); - - if (!empty($this->title)) { - $xs->element(self::TITLE, null, $this->title); - } - - if (!empty($this->summary)) { - $xs->element(self::SUMMARY, null, $this->summary); - } - - if (!empty($this->content)) { - // XXX: assuming HTML content here - $xs->element(ActivityUtils::CONTENT, array('type' => 'html'), $this->content); - } - - if (!empty($this->link)) { - $xs->element( - 'link', - array( - 'rel' => 'alternate', - 'type' => 'text/html', - 'href' => $this->link - ), - null - ); - } - - if ($this->type == ActivityObject::PERSON - || $this->type == ActivityObject::GROUP) { - - foreach ($this->avatarLinks as $avatar) { - $xs->element( - 'link', array( - 'rel' => 'avatar', - 'type' => $avatar->type, - 'media:width' => $avatar->width, - 'media:height' => $avatar->height, - 'href' => $avatar->url - ), - null - ); - } - } - - if (!empty($this->geopoint)) { - $xs->element( - 'georss:point', - null, - $this->geopoint - ); - } - - if (!empty($this->poco)) { - $xs->raw($this->poco->asString()); - } - - $xs->elementEnd($tag); - - return $xs->getString(); - } -} - -/** - * Utility class to hold a bunch of constant defining default verb types - * - * @category OStatus - * @package StatusNet - * @author Evan Prodromou <evan@status.net> - * @copyright 2010 StatusNet, Inc. - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 - * @link http://status.net/ - */ - -class ActivityVerb -{ - const POST = 'http://activitystrea.ms/schema/1.0/post'; - const SHARE = 'http://activitystrea.ms/schema/1.0/share'; - const SAVE = 'http://activitystrea.ms/schema/1.0/save'; - const FAVORITE = 'http://activitystrea.ms/schema/1.0/favorite'; - const PLAY = 'http://activitystrea.ms/schema/1.0/play'; - const FOLLOW = 'http://activitystrea.ms/schema/1.0/follow'; - const FRIEND = 'http://activitystrea.ms/schema/1.0/make-friend'; - const JOIN = 'http://activitystrea.ms/schema/1.0/join'; - const TAG = 'http://activitystrea.ms/schema/1.0/tag'; - - // Custom OStatus verbs for the flipside until they're standardized - const DELETE = 'http://ostatus.org/schema/1.0/unfollow'; - const UNFAVORITE = 'http://ostatus.org/schema/1.0/unfavorite'; - const UNFOLLOW = 'http://ostatus.org/schema/1.0/unfollow'; - const LEAVE = 'http://ostatus.org/schema/1.0/leave'; - - // For simple profile-update pings; no content to share. - const UPDATE_PROFILE = 'http://ostatus.org/schema/1.0/update-profile'; -} - -class ActivityContext -{ - public $replyToID; - public $replyToUrl; - public $location; - public $attention = array(); - public $conversation; - - const THR = 'http://purl.org/syndication/thread/1.0'; - const GEORSS = 'http://www.georss.org/georss'; - const OSTATUS = 'http://ostatus.org/schema/1.0'; - - const INREPLYTO = 'in-reply-to'; - const REF = 'ref'; - const HREF = 'href'; - - const POINT = 'point'; - - const ATTENTION = 'ostatus:attention'; - const CONVERSATION = 'ostatus:conversation'; - - function __construct($element) - { - $replyToEl = ActivityUtils::child($element, self::INREPLYTO, self::THR); - - if (!empty($replyToEl)) { - $this->replyToID = $replyToEl->getAttribute(self::REF); - $this->replyToUrl = $replyToEl->getAttribute(self::HREF); - } - - $this->location = $this->getLocation($element); - - $this->conversation = ActivityUtils::getLink($element, self::CONVERSATION); - - // Multiple attention links allowed - - $links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK); - - for ($i = 0; $i < $links->length; $i++) { - - $link = $links->item($i); - - $linkRel = $link->getAttribute(ActivityUtils::REL); - - if ($linkRel == self::ATTENTION) { - $this->attention[] = $link->getAttribute(self::HREF); - } - } - } - - /** - * Parse location given as a GeoRSS-simple point, if provided. - * http://www.georss.org/simple - * - * @param feed item $entry - * @return mixed Location or false - */ - function getLocation($dom) - { - $points = $dom->getElementsByTagNameNS(self::GEORSS, self::POINT); - - for ($i = 0; $i < $points->length; $i++) { - $point = $points->item($i)->textContent; - return self::locationFromPoint($point); - } - - return null; - } - - // XXX: Move to ActivityUtils or Location? - static function locationFromPoint($point) - { - $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace" - $point = preg_replace('/\s+/', ' ', $point); - $point = trim($point); - $coords = explode(' ', $point); - if (count($coords) == 2) { - list($lat, $lon) = $coords; - if (is_numeric($lat) && is_numeric($lon)) { - common_log(LOG_INFO, "Looking up location for $lat $lon from georss point"); - return Location::fromLatLon($lat, $lon); - } - } - common_log(LOG_ERR, "Ignoring bogus georss:point value $point"); - return null; - } -} - /** * An activity in the ActivityStrea.ms world * @@ -1014,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'; @@ -1029,9 +69,25 @@ class Activity const PUBLISHED = 'published'; const UPDATED = 'updated'; + const RSS = null; // no namespace! + + const PUBDATE = 'pubDate'; + const DESCRIPTION = 'description'; + const GUID = 'guid'; + const SELF = 'self'; + const IMAGE = 'image'; + const URL = 'url'; + + const DC = 'http://purl.org/dc/elements/1.1/'; + + 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 @@ -1044,6 +100,7 @@ class Activity public $id; // ID of the activity public $title; // title of the activity public $categories = array(); // list of AtomCategory objects + public $enclosures = array(); // list of enclosure URL references /** * Turns a regular old Atom <entry> into a magical activity @@ -1058,9 +115,30 @@ class Activity return; } + // Insist on a feed's root DOMElement; don't allow a DOMDocument + if ($feed instanceof DOMDocument) { + throw new ClientException( + // TRANS: Client exception thrown when a feed instance is a DOMDocument. + _('Expecting a root feed element but got a whole XML document.') + ); + } + $this->entry = $entry; $this->feed = $feed; + if ($entry->namespaceURI == Activity::ATOM && + $entry->localName == 'entry') { + $this->_fromAtomEntry($entry, $feed); + } else if ($entry->namespaceURI == Activity::RSS && + $entry->localName == 'item') { + $this->_fromRssItem($entry, $feed); + } else { + throw new Exception("Unknown DOM element: {$entry->namespaceURI} {$entry->localName}"); + } + } + + function _fromAtomEntry($entry, $feed) + { $pubEl = $this->_child($entry, self::PUBLISHED, self::ATOM); if (!empty($pubEl)) { @@ -1086,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); @@ -1100,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)) { @@ -1140,6 +232,80 @@ class Activity $this->categories[] = new AtomCategory($catEl); } } + + foreach (ActivityUtils::getLinks($entry, 'enclosure') as $link) { + $this->enclosures[] = $link->getAttribute('href'); + } + } + + function _fromRssItem($item, $channel) + { + $verbEl = $this->_child($item, self::VERB); + + if (!empty($verbEl)) { + $this->verb = trim($verbEl->textContent); + } else { + $this->verb = ActivityVerb::POST; + // XXX: do other implied stuff here + } + + $pubDateEl = $this->_child($item, self::PUBDATE, self::RSS); + + if (!empty($pubDateEl)) { + $this->time = strtotime($pubDateEl->textContent); + } + + if ($authorEl = $this->_child($item, self::AUTHOR, self::RSS)) { + $this->actor = ActivityObject::fromRssAuthor($authorEl); + } else if ($dcCreatorEl = $this->_child($item, self::CREATOR, self::DC)) { + $this->actor = ActivityObject::fromDcCreator($dcCreatorEl); + } else if ($posterousEl = $this->_child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS)) { + // Special case for Posterous.com + $this->actor = ActivityObject::fromPosterousAuthor($posterousEl); + } else if (!empty($channel)) { + $this->actor = ActivityObject::fromRssChannel($channel); + } else { + // No actor! + } + + $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, self::RSS); + + $contentEl = ActivityUtils::child($item, self::ENCODED, self::CONTENTNS); + + if (!empty($contentEl)) { + // <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)) { + // 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); + } + } + + $this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, self::RSS); + + // @fixme enclosures + // @fixme thumbnails... maybe + + $guidEl = ActivityUtils::child($item, self::GUID, self::RSS); + + if (!empty($guidEl)) { + $this->id = $guidEl->textContent; + + if ($guidEl->hasAttribute('isPermaLink') && $guidEl->getAttribute('isPermaLink') != 'false') { + // overwrites <link> + $this->link = $this->id; + } + } + + $this->objects[] = new ActivityObject($item); + $this->context = new ActivityContext($item); } /** @@ -1197,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) { @@ -1220,48 +388,3 @@ class Activity } } -class AtomCategory -{ - public $term; - public $scheme; - public $label; - - function __construct($element=null) - { - if ($element && $element->attributes) { - $this->term = $this->extract($element, 'term'); - $this->scheme = $this->extract($element, 'scheme'); - $this->label = $this->extract($element, 'label'); - } - } - - protected function extract($element, $attrib) - { - $node = $element->attributes->getNamedItemNS(Activity::ATOM, $attrib); - if ($node) { - return trim($node->textContent); - } - $node = $element->attributes->getNamedItem($attrib); - if ($node) { - return trim($node->textContent); - } - return null; - } - - function asString() - { - $attribs = array(); - if ($this->term !== null) { - $attribs['term'] = $this->term; - } - if ($this->scheme !== null) { - $attribs['scheme'] = $this->scheme; - } - if ($this->label !== null) { - $attribs['label'] = $this->label; - } - $xs = new XMLStringer(); - $xs->element('category', $attribs); - return $xs->asString(); - } -} diff --git a/lib/activitycontext.php b/lib/activitycontext.php new file mode 100644 index 000000000..2df7613f7 --- /dev/null +++ b/lib/activitycontext.php @@ -0,0 +1,121 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class ActivityContext +{ + public $replyToID; + public $replyToUrl; + public $location; + public $attention = array(); + public $conversation; + + const THR = 'http://purl.org/syndication/thread/1.0'; + const GEORSS = 'http://www.georss.org/georss'; + const OSTATUS = 'http://ostatus.org/schema/1.0'; + + const INREPLYTO = 'in-reply-to'; + const REF = 'ref'; + const HREF = 'href'; + + const POINT = 'point'; + + const ATTENTION = 'ostatus:attention'; + const CONVERSATION = 'ostatus:conversation'; + + function __construct($element) + { + $replyToEl = ActivityUtils::child($element, self::INREPLYTO, self::THR); + + if (!empty($replyToEl)) { + $this->replyToID = $replyToEl->getAttribute(self::REF); + $this->replyToUrl = $replyToEl->getAttribute(self::HREF); + } + + $this->location = $this->getLocation($element); + + $this->conversation = ActivityUtils::getLink($element, self::CONVERSATION); + + // Multiple attention links allowed + + $links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK); + + for ($i = 0; $i < $links->length; $i++) { + + $link = $links->item($i); + + $linkRel = $link->getAttribute(ActivityUtils::REL); + + if ($linkRel == self::ATTENTION) { + $this->attention[] = $link->getAttribute(self::HREF); + } + } + } + + /** + * Parse location given as a GeoRSS-simple point, if provided. + * http://www.georss.org/simple + * + * @param feed item $entry + * @return mixed Location or false + */ + function getLocation($dom) + { + $points = $dom->getElementsByTagNameNS(self::GEORSS, self::POINT); + + for ($i = 0; $i < $points->length; $i++) { + $point = $points->item($i)->textContent; + return self::locationFromPoint($point); + } + + return null; + } + + // XXX: Move to ActivityUtils or Location? + static function locationFromPoint($point) + { + $point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace" + $point = preg_replace('/\s+/', ' ', $point); + $point = trim($point); + $coords = explode(' ', $point); + if (count($coords) == 2) { + list($lat, $lon) = $coords; + if (is_numeric($lat) && is_numeric($lon)) { + common_log(LOG_INFO, "Looking up location for $lat $lon from georss point"); + return Location::fromLatLon($lat, $lon); + } + } + common_log(LOG_ERR, "Ignoring bogus georss:point value $point"); + return null; + } +} diff --git a/lib/activityobject.php b/lib/activityobject.php new file mode 100644 index 000000000..677a48197 --- /dev/null +++ b/lib/activityobject.php @@ -0,0 +1,557 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * A noun-ish thing in the activity universe + * + * The activity streams spec talks about activity objects, while also having + * a tag activity:object, which is in fact an activity object. Aaaaaah! + * + * This is just a thing in the activity universe. Can be the subject, object, + * or indirect object (target!) of an activity verb. Rotten name, and I'm + * propagating it. *sigh* + * + * @category OStatus + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class ActivityObject +{ + const ARTICLE = 'http://activitystrea.ms/schema/1.0/article'; + const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry'; + const NOTE = 'http://activitystrea.ms/schema/1.0/note'; + const STATUS = 'http://activitystrea.ms/schema/1.0/status'; + const FILE = 'http://activitystrea.ms/schema/1.0/file'; + const PHOTO = 'http://activitystrea.ms/schema/1.0/photo'; + const ALBUM = 'http://activitystrea.ms/schema/1.0/photo-album'; + const PLAYLIST = 'http://activitystrea.ms/schema/1.0/playlist'; + const VIDEO = 'http://activitystrea.ms/schema/1.0/video'; + const AUDIO = 'http://activitystrea.ms/schema/1.0/audio'; + const BOOKMARK = 'http://activitystrea.ms/schema/1.0/bookmark'; + const PERSON = 'http://activitystrea.ms/schema/1.0/person'; + const GROUP = 'http://activitystrea.ms/schema/1.0/group'; + const PLACE = 'http://activitystrea.ms/schema/1.0/place'; + const COMMENT = 'http://activitystrea.ms/schema/1.0/comment'; + // ^^^^^^^^^^ tea! + + // Atom elements we snarf + + const TITLE = 'title'; + const SUMMARY = 'summary'; + const ID = 'id'; + const SOURCE = 'source'; + + const NAME = 'name'; + const URI = 'uri'; + const EMAIL = 'email'; + + const POSTEROUS = 'http://posterous.com/help/rss/1.0'; + const AUTHOR = 'author'; + const USERIMAGE = 'userImage'; + const PROFILEURL = 'profileUrl'; + const NICKNAME = 'nickName'; + const DISPLAYNAME = 'displayName'; + + public $element; + public $type; + public $id; + public $title; + public $summary; + public $content; + public $link; + public $source; + public $avatarLinks = array(); + public $geopoint; + 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 + * + * This probably needs to be refactored + * to generate a local class (ActivityPerson, ActivityFile, ...) + * based on the object type. + * + * @param DOMElement $element DOM thing to turn into an Activity thing + */ + + function __construct($element = null) + { + if (empty($element)) { + return; + } + + $this->element = $element; + + $this->geopoint = $this->_childContent( + $element, + ActivityContext::POINT, + ActivityContext::GEORSS + ); + + if ($element->tagName == 'author') { + $this->_fromAuthor($element); + } else if ($element->tagName == 'item') { + $this->_fromRssItem($element); + } else { + $this->_fromAtomEntry($element); + } + + // Some per-type attributes... + if ($this->type == self::PERSON || $this->type == self::GROUP) { + $this->displayName = $this->title; + + $photos = ActivityUtils::getLinks($element, 'photo'); + if (count($photos)) { + foreach ($photos as $link) { + $this->avatarLinks[] = new AvatarLink($link); + } + } else { + $avatars = ActivityUtils::getLinks($element, 'avatar'); + foreach ($avatars as $link) { + $this->avatarLinks[] = new AvatarLink($link); + } + } + + $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); + + if (empty($this->id)) { + $email = $this->_childContent($element, self::EMAIL); + if (!empty($email)) { + // XXX: acct: ? + $this->id = 'mailto:'.$email; + } + } + } + + private function _fromAtomEntry($element) + { + $this->type = $this->_childContent($element, Activity::OBJECTTYPE, + Activity::SPEC); + + if (empty($this->type)) { + $this->type = ActivityObject::NOTE; + } + + $this->summary = ActivityUtils::childHtmlContent($element, self::SUMMARY); + $this->content = ActivityUtils::getContent($element); + + // We don't like HTML in our titles, although it's technically allowed + + $title = ActivityUtils::childHtmlContent($element, self::TITLE); + + $this->title = html_entity_decode(strip_tags($title)); + + $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() + + private function _fromRssItem($item) + { + $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, Activity::RSS); + + $contentEl = ActivityUtils::child($item, ActivityUtils::CONTENT, Activity::CONTENTNS); + + if (!empty($contentEl)) { + $this->content = htmlspecialchars_decode($contentEl->textContent, ENT_QUOTES); + } else { + $descriptionEl = ActivityUtils::child($item, Activity::DESCRIPTION, Activity::RSS); + if (!empty($descriptionEl)) { + $this->content = htmlspecialchars_decode($descriptionEl->textContent, ENT_QUOTES); + } + } + + $this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, Activity::RSS); + + $guidEl = ActivityUtils::child($item, Activity::GUID, Activity::RSS); + + if (!empty($guidEl)) { + $this->id = $guidEl->textContent; + + if ($guidEl->hasAttribute('isPermaLink')) { + // overwrites <link> + $this->link = $this->id; + } + } + } + + public static function fromRssAuthor($el) + { + $text = $el->textContent; + + if (preg_match('/^(.*?) \((.*)\)$/', $text, $match)) { + $email = $match[1]; + $name = $match[2]; + } else if (preg_match('/^(.*?) <(.*)>$/', $text, $match)) { + $name = $match[1]; + $email = $match[2]; + } else if (preg_match('/.*@.*/', $text)) { + $email = $text; + $name = null; + } else { + $name = $text; + $email = null; + } + + // Not really enough info + + $obj = new ActivityObject(); + + $obj->element = $el; + + $obj->type = ActivityObject::PERSON; + $obj->title = $name; + + if (!empty($email)) { + $obj->id = 'mailto:'.$email; + } + + return $obj; + } + + public static function fromDcCreator($el) + { + // Not really enough info + + $text = $el->textContent; + + $obj = new ActivityObject(); + + $obj->element = $el; + + $obj->title = $text; + $obj->type = ActivityObject::PERSON; + + return $obj; + } + + public static function fromRssChannel($el) + { + $obj = new ActivityObject(); + + $obj->element = $el; + + $obj->type = ActivityObject::PERSON; // @fixme guess better + + $obj->title = ActivityUtils::childContent($el, ActivityObject::TITLE, Activity::RSS); + $obj->link = ActivityUtils::childContent($el, ActivityUtils::LINK, Activity::RSS); + $obj->id = ActivityUtils::getLink($el, Activity::SELF); + + if (empty($obj->id)) { + $obj->id = $obj->link; + } + + $desc = ActivityUtils::childContent($el, Activity::DESCRIPTION, Activity::RSS); + + if (!empty($desc)) { + $obj->content = htmlspecialchars_decode($desc, ENT_QUOTES); + } + + $imageEl = ActivityUtils::child($el, Activity::IMAGE, Activity::RSS); + + if (!empty($imageEl)) { + $url = ActivityUtils::childContent($imageEl, Activity::URL, Activity::RSS); + $al = new AvatarLink(); + $al->url = $url; + $obj->avatarLinks[] = $al; + } + + return $obj; + } + + public static function fromPosterousAuthor($el) + { + $obj = new ActivityObject(); + + $obj->type = ActivityObject::PERSON; // @fixme any others...? + + $userImage = ActivityUtils::childContent($el, self::USERIMAGE, self::POSTEROUS); + + if (!empty($userImage)) { + $al = new AvatarLink(); + $al->url = $userImage; + $obj->avatarLinks[] = $al; + } + + $obj->link = ActivityUtils::childContent($el, self::PROFILEURL, self::POSTEROUS); + $obj->id = $obj->link; + + $obj->poco = new PoCo(); + + $obj->poco->preferredUsername = ActivityUtils::childContent($el, self::NICKNAME, self::POSTEROUS); + $obj->poco->displayName = ActivityUtils::childContent($el, self::DISPLAYNAME, self::POSTEROUS); + + $obj->title = $obj->poco->displayName; + + return $obj; + } + + private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM) + { + return ActivityUtils::childContent($element, $tag, $namespace); + } + + // Try to get a unique id for the source feed + + private function _getSource($element) + { + $sourceEl = ActivityUtils::child($element, 'source'); + + if (empty($sourceEl)) { + return null; + } else { + $href = ActivityUtils::getLink($sourceEl, 'self'); + if (!empty($href)) { + return $href; + } else { + return ActivityUtils::childContent($sourceEl, 'id'); + } + } + } + + static function fromNotice(Notice $notice) + { + $object = new ActivityObject(); + + $object->type = ActivityObject::NOTE; + + $object->id = $notice->uri; + $object->title = $notice->content; + $object->content = $notice->rendered; + $object->link = $notice->bestUrl(); + + return $object; + } + + static function fromProfile(Profile $profile) + { + $object = new ActivityObject(); + + $object->type = ActivityObject::PERSON; + $object->id = $profile->getUri(); + $object->title = $profile->getBestName(); + $object->link = $profile->profileurl; + + $orig = $profile->getOriginalAvatar(); + + if (!empty($orig)) { + $object->avatarLinks[] = AvatarLink::fromAvatar($orig); + } + + $sizes = array( + AVATAR_PROFILE_SIZE, + AVATAR_STREAM_SIZE, + AVATAR_MINI_SIZE + ); + + foreach ($sizes as $size) { + + $alink = null; + $avatar = $profile->getAvatar($size); + + if (!empty($avatar)) { + $alink = AvatarLink::fromAvatar($avatar); + } else { + $alink = new AvatarLink(); + $alink->type = 'image/png'; + $alink->height = $size; + $alink->width = $size; + $alink->url = Avatar::defaultImage($size); + } + + $object->avatarLinks[] = $alink; + } + + if (isset($profile->lat) && isset($profile->lon)) { + $object->geopoint = (float)$profile->lat + . ' ' . (float)$profile->lon; + } + + $object->poco = PoCo::fromProfile($profile); + + return $object; + } + + static function fromGroup($group) + { + $object = new ActivityObject(); + + $object->type = ActivityObject::GROUP; + $object->id = $group->getUri(); + $object->title = $group->getBestName(); + $object->link = $group->getUri(); + + $object->avatarLinks[] = AvatarLink::fromFilename( + $group->homepage_logo, + AVATAR_PROFILE_SIZE + ); + + $object->avatarLinks[] = AvatarLink::fromFilename( + $group->stream_logo, + AVATAR_STREAM_SIZE + ); + + $object->avatarLinks[] = AvatarLink::fromFilename( + $group->mini_logo, + AVATAR_MINI_SIZE + ); + + $object->poco = PoCo::fromGroup($group); + + return $object; + } + + function asString($tag='activity:object') + { + $xs = new XMLStringer(true); + + $xs->elementStart($tag); + + $xs->element('activity:object-type', null, $this->type); + + $xs->element(self::ID, null, $this->id); + + if (!empty($this->title)) { + $xs->element( + self::TITLE, + null, + common_xml_safe_str($this->title) + ); + } + + if (!empty($this->summary)) { + $xs->element( + self::SUMMARY, + null, + common_xml_safe_str($this->summary) + ); + } + + if (!empty($this->content)) { + // XXX: assuming HTML content here + $xs->element( + ActivityUtils::CONTENT, + array('type' => 'html'), + common_xml_safe_str($this->content) + ); + } + + if (!empty($this->link)) { + $xs->element( + 'link', + array( + 'rel' => 'alternate', + 'type' => 'text/html', + 'href' => $this->link + ), + null + ); + } + + if ($this->type == ActivityObject::PERSON + || $this->type == ActivityObject::GROUP) { + + foreach ($this->avatarLinks as $avatar) { + $xs->element( + 'link', array( + 'rel' => 'avatar', + 'type' => $avatar->type, + 'media:width' => $avatar->width, + 'media:height' => $avatar->height, + 'href' => $avatar->url + ), + null + ); + } + } + + if (!empty($this->geopoint)) { + $xs->element( + 'georss:point', + null, + $this->geopoint + ); + } + + if (!empty($this->poco)) { + $xs->raw($this->poco->asString()); + } + + $xs->elementEnd($tag); + + return $xs->getString(); + } +} diff --git a/lib/activityutils.php b/lib/activityutils.php new file mode 100644 index 000000000..401fd7fc2 --- /dev/null +++ b/lib/activityutils.php @@ -0,0 +1,273 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Utilities for turning DOMish things into Activityish things + * + * Some common functions that I didn't have the bandwidth to try to factor + * into some kind of reasonable superclass, so just dumped here. Might + * be useful to have an ActivityObject parent class or something. + * + * @category OStatus + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class ActivityUtils +{ + const ATOM = 'http://www.w3.org/2005/Atom'; + + const LINK = 'link'; + const REL = 'rel'; + const TYPE = 'type'; + const HREF = 'href'; + + const CONTENT = 'content'; + const SRC = 'src'; + + /** + * Get the permalink for an Activity object + * + * @param DOMElement $element A DOM element + * + * @return string related link, if any + */ + + static function getPermalink($element) + { + return self::getLink($element, 'alternate', 'text/html'); + } + + /** + * Get the permalink for an Activity object + * + * @param DOMElement $element A DOM element + * + * @return string related link, if any + */ + + static function getLink(DOMNode $element, $rel, $type=null) + { + $els = $element->childNodes; + + foreach ($els as $link) { + + if (!($link instanceof DOMElement)) { + continue; + } + + if ($link->localName == self::LINK && $link->namespaceURI == self::ATOM) { + + $linkRel = $link->getAttribute(self::REL); + $linkType = $link->getAttribute(self::TYPE); + + if ($linkRel == $rel && + (is_null($type) || $linkType == $type)) { + return $link->getAttribute(self::HREF); + } + } + } + + return null; + } + + static function getLinks(DOMNode $element, $rel, $type=null) + { + $els = $element->childNodes; + $out = array(); + + foreach ($els as $link) { + if ($link->localName == self::LINK && $link->namespaceURI == self::ATOM) { + + $linkRel = $link->getAttribute(self::REL); + $linkType = $link->getAttribute(self::TYPE); + + if ($linkRel == $rel && + (is_null($type) || $linkType == $type)) { + $out[] = $link; + } + } + } + + return $out; + } + + /** + * Gets the first child element with the given tag + * + * @param DOMElement $element element to pick at + * @param string $tag tag to look for + * @param string $namespace Namespace to look under + * + * @return DOMElement found element or null + */ + + static function child(DOMNode $element, $tag, $namespace=self::ATOM) + { + $els = $element->childNodes; + if (empty($els) || $els->length == 0) { + return null; + } else { + for ($i = 0; $i < $els->length; $i++) { + $el = $els->item($i); + if ($el->localName == $tag && $el->namespaceURI == $namespace) { + return $el; + } + } + } + } + + /** + * Grab the text content of a DOM element child of the current element + * + * @param DOMElement $element Element whose children we examine + * @param string $tag Tag to look up + * @param string $namespace Namespace to use, defaults to Atom + * + * @return string content of the child + */ + + static function childContent(DOMNode $element, $tag, $namespace=self::ATOM) + { + $el = self::child($element, $tag, $namespace); + + if (empty($el)) { + return null; + } else { + return $el->textContent; + } + } + + static function childHtmlContent(DOMNode $element, $tag, $namespace=self::ATOM) + { + $el = self::child($element, $tag, $namespace); + + if (empty($el)) { + return null; + } else { + return self::textConstruct($el); + } + } + + /** + * Get the content of an atom:entry-like object + * + * @param DOMElement $element The element to examine. + * + * @return string unencoded HTML content of the element, like "This -< is <b>HTML</b>." + * + * @todo handle remote content + * @todo handle embedded XML mime types + * @todo handle base64-encoded non-XML and non-text mime types + */ + + static function getContent($element) + { + return self::childHtmlContent($element, self::CONTENT, self::ATOM); + } + + static function textConstruct($el) + { + $src = $el->getAttribute(self::SRC); + + if (!empty($src)) { + throw new ClientException(_("Can't handle remote content yet.")); + } + + $type = $el->getAttribute(self::TYPE); + + // slavishly following http://atompub.org/rfc4287.html#rfc.section.4.1.3.3 + + if (empty($type) || $type == 'text') { + // 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 $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; + } + $doc = $divEl->ownerDocument; + $text = ''; + $children = $divEl->childNodes; + + for ($i = 0; $i < $children->length; $i++) { + $child = $children->item($i); + $text .= $doc->saveXML($child); + } + return trim($text); + } else if (in_array($type, array('text/xml', 'application/xml')) || + preg_match('#(+|/)xml$#', $type)) { + throw new ClientException(_("Can't handle embedded XML content yet.")); + } else if (strncasecmp($type, 'text/', 5)) { + return $el->textContent; + } else { + 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) + { + 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/activityverb.php b/lib/activityverb.php new file mode 100644 index 000000000..76f2b84e9 --- /dev/null +++ b/lib/activityverb.php @@ -0,0 +1,66 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Utility class to hold a bunch of constant defining default verb types + * + * @category OStatus + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class ActivityVerb +{ + const POST = 'http://activitystrea.ms/schema/1.0/post'; + const SHARE = 'http://activitystrea.ms/schema/1.0/share'; + const SAVE = 'http://activitystrea.ms/schema/1.0/save'; + const FAVORITE = 'http://activitystrea.ms/schema/1.0/favorite'; + const PLAY = 'http://activitystrea.ms/schema/1.0/play'; + const FOLLOW = 'http://activitystrea.ms/schema/1.0/follow'; + const FRIEND = 'http://activitystrea.ms/schema/1.0/make-friend'; + const JOIN = 'http://activitystrea.ms/schema/1.0/join'; + const TAG = 'http://activitystrea.ms/schema/1.0/tag'; + + // Custom OStatus verbs for the flipside until they're standardized + const DELETE = 'http://ostatus.org/schema/1.0/unfollow'; + const UNFAVORITE = 'http://ostatus.org/schema/1.0/unfavorite'; + const UNFOLLOW = 'http://ostatus.org/schema/1.0/unfollow'; + const LEAVE = 'http://ostatus.org/schema/1.0/leave'; + + // For simple profile-update pings; no content to share. + const UPDATE_PROFILE = 'http://ostatus.org/schema/1.0/update-profile'; +} diff --git a/lib/adminpanelaction.php b/lib/adminpanelaction.php index a0cdab8a4..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; } @@ -176,6 +176,24 @@ class AdminPanelAction extends Action } /** + * Show content block. Overrided just to add a special class + * to the content div to allow styling. + * + * @return nothing + */ + function showContentBlock() + { + $this->elementStart('div', array('id' => 'content', 'class' => 'admin')); + $this->showPageTitle(); + $this->showPageNoticeBlock(); + $this->elementStart('div', array('id' => 'content_inner')); + // show the actual content (forms, lists, whatever) + $this->showContent(); + $this->elementEnd('div'); + $this->elementEnd('div'); + } + + /** * show human-readable instructions for the page, or * a success/failure on save. * @@ -207,7 +225,7 @@ class AdminPanelAction extends Action function showForm() { - // TRANS: Client error message + // TRANS: Client error message. $this->clientError(_('showForm() not implemented.')); return; } @@ -261,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) @@ -345,32 +365,48 @@ class AdminPanelNav extends Widget // TRANS: Menu item title/tooltip $menu_title = _('User configuration'); // TRANS: Menu item for site administration - $this->out->menuItem(common_local_url('useradminpanel'), _m('MENU', 'User'), - $menu_title, $action_name == 'useradminpanel', 'nav_design_admin_panel'); + $this->out->menuItem(common_local_url('useradminpanel'), _('User'), + $menu_title, $action_name == 'useradminpanel', 'nav_user_admin_panel'); } if (AdminPanelAction::canAdmin('access')) { // TRANS: Menu item title/tooltip $menu_title = _('Access configuration'); // TRANS: Menu item for site administration - $this->out->menuItem(common_local_url('accessadminpanel'), _m('MENU', 'Access'), - $menu_title, $action_name == 'accessadminpanel', 'nav_design_admin_panel'); + $this->out->menuItem(common_local_url('accessadminpanel'), _('Access'), + $menu_title, $action_name == 'accessadminpanel', 'nav_access_admin_panel'); } if (AdminPanelAction::canAdmin('paths')) { // TRANS: Menu item title/tooltip $menu_title = _('Paths configuration'); // TRANS: Menu item for site administration - $this->out->menuItem(common_local_url('pathsadminpanel'), _m('MENU', 'Paths'), - $menu_title, $action_name == 'pathsadminpanel', 'nav_design_admin_panel'); + $this->out->menuItem(common_local_url('pathsadminpanel'), _('Paths'), + $menu_title, $action_name == 'pathsadminpanel', 'nav_paths_admin_panel'); } if (AdminPanelAction::canAdmin('sessions')) { // TRANS: Menu item title/tooltip $menu_title = _('Sessions configuration'); // TRANS: Menu item for site administration - $this->out->menuItem(common_local_url('sessionsadminpanel'), _m('MENU', 'Sessions'), - $menu_title, $action_name == 'sessionsadminpanel', 'nav_design_admin_panel'); + $this->out->menuItem(common_local_url('sessionsadminpanel'), _('Sessions'), + $menu_title, $action_name == 'sessionsadminpanel', 'nav_sessions_admin_panel'); + } + + if (AdminPanelAction::canAdmin('sitenotice')) { + // TRANS: Menu item title/tooltip + $menu_title = _('Edit site notice'); + // TRANS: Menu item for site administration + $this->out->menuItem(common_local_url('sitenoticeadminpanel'), _('Site notice'), + $menu_title, $action_name == 'sitenoticeadminpanel', 'nav_sitenotice_admin_panel'); + } + + if (AdminPanelAction::canAdmin('snapshot')) { + // TRANS: Menu item title/tooltip + $menu_title = _('Snapshots configuration'); + // TRANS: Menu item for site administration + $this->out->menuItem(common_local_url('snapshotadminpanel'), _('Snapshots'), + $menu_title, $action_name == 'snapshotadminpanel', 'nav_snapshot_admin_panel'); } Event::handle('EndAdminPanelNav', array($this)); diff --git a/lib/apiaction.php b/lib/apiaction.php index eef0ba637..8de13a62d 100644 --- a/lib/apiaction.php +++ b/lib/apiaction.php @@ -28,10 +28,72 @@ * @author Toby Inkster <mail@tobyinkster.co.uk> * @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/ */ +/* 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,12 @@ class ApiAction extends Action var $count = null; var $max_id = null; var $since_id = null; + var $source = null; var $access = self::READ_ONLY; // read (default) or read-write + static $reserved_sources = array('web', 'omb', 'ostatus', 'mail', 'xmpp', 'api'); + /** * Initialization. * @@ -86,7 +151,13 @@ class ApiAction extends Action $this->since_id = (int)$this->arg('since_id', 0); if ($this->arg('since')) { - $this->clientError(_("since parameter is disabled for performance; use since_id"), 403); + 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 +173,7 @@ class ApiAction extends Action function handle($args) { + header('Access-Control-Allow-Origin: *'); parent::handle($args); } @@ -199,11 +271,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 +297,10 @@ class ApiAction extends Action } } + // StatusNet-specific + + $twitter_user['statusnet:profile_url'] = $profile->profileurl; + return $twitter_user; } @@ -251,7 +329,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 +402,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; } @@ -476,9 +585,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': @@ -491,7 +604,7 @@ class ApiAction extends Action $this->showXmlAttachments($twitter_status['attachments']); break; case 'geo': - $this->showGeoRSS($value); + $this->showGeoXML($value); break; case 'retweeted_status': $this->showTwitterXmlStatus($value, 'retweeted_status'); @@ -512,9 +625,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']); @@ -539,7 +656,7 @@ class ApiAction extends Action } } - function showGeoRSS($geo) + function showGeoXML($geo) { if (empty($geo)) { // empty geo element @@ -551,6 +668,17 @@ class ApiAction extends Action } } + function showGeoRSS($geo) + { + if (!empty($geo)) { + $this->element( + 'georss:point', + null, + $geo['coordinates'][0] . ' ' . $geo['coordinates'][1] + ); + } + } + function showTwitterRssItem($entry) { $this->elementStart('item'); @@ -585,7 +713,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'); } @@ -601,7 +729,8 @@ 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) { @@ -619,13 +748,25 @@ class ApiAction extends Action $this->endDocument('xml'); } - function showRssTimeline($notice, $title, $link, $subtitle, $suplink=null, $logo=null) + function showRssTimeline($notice, $title, $link, $subtitle, $suplink = null, $logo = null, $self = null) { $this->initDocument('rss'); $this->element('title', null, $title); $this->element('link', null, $link); + + if (!is_null($self)) { + $this->element( + 'atom:link', + array( + 'type' => 'application/rss+xml', + 'href' => $self, + 'rel' => 'self' + ) + ); + } + if (!is_null($suplink)) { // For FriendFeed's SUP protocol $this->element('link', array('xmlns' => 'http://www.w3.org/2005/Atom', @@ -732,8 +873,12 @@ class ApiAction extends Action function showTwitterAtomEntry($entry) { $this->elementStart('entry'); - $this->element('title', null, $entry['title']); - $this->element('content', array('type' => 'html'), $entry['content']); + $this->element('title', null, common_xml_safe_str($entry['title'])); + $this->element( + 'content', + array('type' => 'html'), + common_xml_safe_str($entry['content']) + ); $this->element('id', null, $entry['id']); $this->element('published', null, $entry['published']); $this->element('updated', null, $entry['updated']); @@ -752,9 +897,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': @@ -831,7 +980,7 @@ class ApiAction extends Action { $this->initDocument('xml'); $dmsg = $this->directMessageArray($message); - $this->showXmlDirectMessage($dmsg); + $this->showXmlDirectMessage($dmsg, true); $this->endDocument('xml'); } @@ -848,7 +997,7 @@ class ApiAction extends Action $this->initDocument('atom'); - $this->element('title', null, $title); + $this->element('title', null, common_xml_safe_str($title)); $this->element('id', null, $id); $this->element('link', array('href' => $link, 'rel' => 'alternate', 'type' => 'text/html'), null); @@ -858,7 +1007,7 @@ class ApiAction extends Action } $this->element('updated', null, common_date_iso8601('now')); - $this->element('subtitle', null, $subtitle); + $this->element('subtitle', null, common_xml_safe_str($subtitle)); if (is_array($group)) { foreach ($group as $g) { @@ -948,7 +1097,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) { @@ -1039,6 +1189,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; } @@ -1067,6 +1218,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; } @@ -1138,7 +1290,14 @@ class ApiAction extends Action function initTwitterRss() { $this->startXML(); - $this->elementStart('rss', array('version' => '2.0', 'xmlns:atom'=>'http://www.w3.org/2005/Atom')); + $this->elementStart( + 'rss', + array( + 'version' => '2.0', + 'xmlns:atom' => 'http://www.w3.org/2005/Atom', + 'xmlns:georss' => 'http://www.georss.org/georss' + ) + ); $this->elementStart('channel'); Event::handle('StartApiRss', array($this)); } @@ -1176,6 +1335,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; } @@ -1214,6 +1374,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)) { @@ -1239,7 +1427,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); } } @@ -1256,43 +1444,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 @@ -1336,8 +1487,27 @@ class ApiAction extends Action } } - function getSelfUri($action, $aargs) + /** + * Calculate the complete URI that called up this action. Used for + * Atom rel="self" links. Warning: this is funky. + * + * @return string URL a URL suitable for rel="self" Atom links + */ + function getSelfUri() { + $action = mb_substr(get_class($this), 0, -6); // remove 'Action' + + $id = $this->arg('id'); + $aargs = array('format' => $this->format); + if (!empty($id)) { + $aargs['id'] = $id; + } + + $tag = $this->arg('tag'); + if (!empty($tag)) { + $aargs['tag'] = $tag; + } + parse_str($_SERVER['QUERY_STRING'], $params); $pstring = ''; if (!empty($params)) { diff --git a/lib/apiauth.php b/lib/apiauth.php index 5090871cf..91cb64262 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); @@ -235,9 +254,13 @@ class ApiAuthAction extends ApiAction { $this->basicAuthProcessHeader(); - $realm = common_config('site', 'name') . ' API'; + $realm = common_config('api', 'realm'); - if (!isset($this->auth_user_nickname) && $required) { + if (empty($realm)) { + $realm = common_config('site', 'name') . ' API'; + } + + if (empty($this->auth_user_nickname) && $required) { header('WWW-Authenticate: Basic realm="' . $realm . '"'); // show error if the user clicks 'cancel' @@ -263,14 +286,14 @@ 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); @@ -290,11 +313,15 @@ class ApiAuthAction extends ApiAction function basicAuthProcessHeader() { - if (isset($_SERVER['AUTHORIZATION']) - || isset($_SERVER['HTTP_AUTHORIZATION']) - ) { - $authorization_header = isset($_SERVER['HTTP_AUTHORIZATION']) - ? $_SERVER['HTTP_AUTHORIZATION'] : $_SERVER['AUTHORIZATION']; + $authHeaders = array('AUTHORIZATION', + 'HTTP_AUTHORIZATION', + 'REDIRECT_HTTP_AUTHORIZATION'); // rewrite for CGI + $authorization_header = null; + foreach ($authHeaders as $header) { + if (isset($_SERVER[$header])) { + $authorization_header = $_SERVER[$header]; + break; + } } if (isset($_SERVER['PHP_AUTH_USER'])) { 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/atom10entry.php b/lib/atom10entry.php deleted file mode 100644 index f8f16d594..000000000 --- a/lib/atom10entry.php +++ /dev/null @@ -1,105 +0,0 @@ -<?php -/** - * StatusNet, the distributed open-source microblogging tool - * - * Class for building / manipulating an Atom entry in memory - * - * 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 Feed - * @package StatusNet - * @author Zach Copley <zach@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')) { - exit(1); -} - -class Atom10EntryException extends Exception -{ -} - -/** - * Class for manipulating an Atom entry in memory. Get the entry as an XML - * string with Atom10Entry::getString(). - * - * @category Feed - * @package StatusNet - * @author Zach Copley <zach@status.net> - * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 - * @link http://status.net/ - */ -class Atom10Entry extends XMLStringer -{ - private $namespaces; - private $categories; - private $content; - private $contributors; - private $id; - private $links; - private $published; - private $rights; - private $source; - private $summary; - private $title; - - function __construct($indent = true) { - parent::__construct($indent); - $this->namespaces = array(); - } - - function addNamespace($namespace, $uri) - { - $ns = array($namespace => $uri); - $this->namespaces = array_merge($this->namespaces, $ns); - } - - function initEntry() - { - - } - - function endEntry() - { - - } - - /** - * Check that all required elements have been set, etc. - * Throws an Atom10EntryException if something's missing. - * - * @return void - */ - function validate() - { - - } - - function getString() - { - $this->validate(); - - $this->initEntry(); - $this->renderEntries(); - $this->endEntry(); - - return $this->xw->outputMemory(); - } - -}
\ No newline at end of file diff --git a/lib/atom10feed.php b/lib/atom10feed.php index 8842840d5..a46d49f35 100644 --- a/lib/atom10feed.php +++ b/lib/atom10feed.php @@ -49,6 +49,8 @@ class Atom10FeedException extends Exception class Atom10Feed extends XMLStringer { public $xw; + + // @fixme most of these should probably be read-only properties private $namespaces; private $authors; private $subject; @@ -57,10 +59,12 @@ class Atom10Feed extends XMLStringer private $generator; private $icon; private $links; - private $logo; + private $selfLink; + private $selfLinkType; + public $logo; private $rights; - private $subtitle; - private $title; + public $subtitle; + public $title; private $published; private $updated; private $entries; @@ -172,6 +176,14 @@ class Atom10Feed extends XMLStringer } $this->elementStart('feed', $commonAttrs); + $this->element( + 'generator', array( + 'uri' => 'http://status.net', + 'version' => STATUSNET_VERSION + ), + 'StatusNet' + ); + $this->element('id', null, $this->id); $this->element('title', null, $this->title); $this->element('subtitle', null, $this->subtitle); @@ -184,6 +196,10 @@ class Atom10Feed extends XMLStringer $this->renderAuthors(); + if ($this->selfLink) { + $this->addLink($this->selfLink, array('rel' => 'self', + 'type' => $this->selfLinkType)); + } $this->renderLinks(); } @@ -253,6 +269,12 @@ class Atom10Feed extends XMLStringer $this->id = $id; } + function setSelfLink($url, $type='application/atom+xml') + { + $this->selfLink = $url; + $this->selfLinkType = $type; + } + function setTitle($title) { $this->title = $title; diff --git a/lib/atomcategory.php b/lib/atomcategory.php new file mode 100644 index 000000000..4cc3b4f4d --- /dev/null +++ b/lib/atomcategory.php @@ -0,0 +1,77 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class AtomCategory +{ + public $term; + public $scheme; + public $label; + + function __construct($element=null) + { + if ($element && $element->attributes) { + $this->term = $this->extract($element, 'term'); + $this->scheme = $this->extract($element, 'scheme'); + $this->label = $this->extract($element, 'label'); + } + } + + protected function extract($element, $attrib) + { + $node = $element->attributes->getNamedItemNS(Activity::ATOM, $attrib); + if ($node) { + return trim($node->textContent); + } + $node = $element->attributes->getNamedItem($attrib); + if ($node) { + return trim($node->textContent); + } + return null; + } + + function asString() + { + $attribs = array(); + if ($this->term !== null) { + $attribs['term'] = $this->term; + } + if ($this->scheme !== null) { + $attribs['scheme'] = $this->scheme; + } + if ($this->label !== null) { + $attribs['label'] = $this->label; + } + $xs = new XMLStringer(); + $xs->element('category', $attribs); + return $xs->asString(); + } +} diff --git a/lib/atomgroupnoticefeed.php b/lib/atomgroupnoticefeed.php index 52ee4c7d6..39a1fd456 100644 --- a/lib/atomgroupnoticefeed.php +++ b/lib/atomgroupnoticefeed.php @@ -49,14 +49,46 @@ class AtomGroupNoticeFeed extends AtomNoticeFeed /** * Constructor * - * @param Group $group the group for the feed (optional) + * @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 = null, $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 + ); + $this->setSubtitle($subtitle); + + $avatar = $group->homepage_logo; + $logo = ($avatar) ? $avatar : User_group::defaultLogo(AVATAR_PROFILE_SIZE); + $this->setLogo($logo); + + $this->setUpdated('now'); + + $self = common_local_url('ApiTimelineGroup', + array('id' => $group->id, + 'format' => 'atom')); + $this->setId($self); + $this->setSelfLink($self); + + $this->addAuthorRaw($group->asAtomAuthor()); + $this->setActivitySubject($group->asActivitySubject()); + + $this->addLink($group->homeUrl()); } function getGroup() @@ -64,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 3c3556cb9..6ed803ce4 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,9 +125,21 @@ class AtomNoticeFeed extends Atom10Feed */ function addEntryFromNotice($notice) { - $this->addEntryRaw($notice->asAtomEntry()); - } + $source = $this->showSource(); + $author = $this->showAuthor(); -} + $cur = empty($this->cur) ? common_current_user() : $this->cur; + $this->addEntryRaw($notice->asAtomEntry(false, $source, $author, $cur)); + } + function showSource() + { + return true; + } + + function showAuthor() + { + return true; + } +} diff --git a/lib/atomusernoticefeed.php b/lib/atomusernoticefeed.php index 2ad8de455..785db4915 100644 --- a/lib/atomusernoticefeed.php +++ b/lib/atomusernoticefeed.php @@ -49,23 +49,75 @@ class AtomUserNoticeFeed extends AtomNoticeFeed /** * Constructor * - * @param User $user the user for the feed (optional) + * @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 = null, $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(); $this->addAuthor($profile->nickname, $user->uri); + $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 + ); + $this->setSubtitle($subtitle); + + $avatar = $profile->getAvatar(AVATAR_PROFILE_SIZE); + $logo = ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_PROFILE_SIZE); + $this->setLogo($logo); + + $this->setUpdated('now'); + + $this->addLink( + common_local_url( + 'showstream', + array('nickname' => $user->nickname) + ) + ); + + $self = common_local_url('ApiTimelineUser', + array('id' => $user->id, + 'format' => 'atom')); + $this->setId($self); + $this->setSelfLink($self); + + $this->addLink( + common_local_url('sup', null, null, $user->id), + array( + 'rel' => 'http://api.friendfeed.com/2008/03#sup', + 'type' => 'application/json' + ) + ); } function getUser() { return $this->user; } + + function showSource() + { + return false; + } + + function showAuthor() + { + return false; + } } 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 new file mode 100644 index 000000000..7d4256d6e --- /dev/null +++ b/lib/avatarlink.php @@ -0,0 +1,102 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +// XXX: Arg! This wouldn't be necessary if we used Avatars conistently +class AvatarLink +{ + public $url; + public $type; + public $size; + public $width; + public $height; + + function __construct($element=null) + { + if ($element) { + // @fixme use correct namespaces + $this->url = $element->getAttribute('href'); + $this->type = $element->getAttribute('type'); + $width = $element->getAttribute('media:width'); + if ($width != null) { + $this->width = intval($width); + } + $height = $element->getAttribute('media:height'); + if ($height != null) { + $this->height = intval($height); + } + } + } + + static function fromAvatar($avatar) + { + if (empty($avatar)) { + return null; + } + $alink = new AvatarLink(); + $alink->type = $avatar->mediatype; + $alink->height = $avatar->height; + $alink->width = $avatar->width; + $alink->url = $avatar->displayUrl(); + return $alink; + } + + static function fromFilename($filename, $size) + { + $alink = new AvatarLink(); + $alink->url = $filename; + $alink->height = $size; + $alink->width = $size; + if (!empty($filename)) { + $alink->type = self::mediatype($filename); + } else { + $alink->url = User_group::defaultLogo($size); + $alink->type = 'image/png'; + } + return $alink; + } + + // yuck! + static function mediatype($filename) { + $ext = strtolower(end(explode('.', $filename))); + if ($ext == 'jpeg') { + $ext = 'jpg'; + } + // hope we don't support any others + $types = array('png', 'gif', 'jpg', 'jpeg'); + if (in_array($ext, $types)) { + return 'image/' . $ext; + } + return null; + } +} diff --git a/lib/channel.php b/lib/channel.php index 3cd168786..689bca0be 100644 --- a/lib/channel.php +++ b/lib/channel.php @@ -47,6 +47,25 @@ class Channel } } +class CLIChannel extends Channel +{ + function source() + { + return 'cli'; + } + + function output($user, $text) + { + $site = common_config('site', 'name'); + print "[{$user->nickname}@{$site}] $text\n"; + } + + function error($user, $text) + { + $this->output($user, $text); + } +} + class XMPPChannel extends Channel { diff --git a/lib/command.php b/lib/command.php index db8e80030..a769dc1fc 100644 --- a/lib/command.php +++ b/lib/command.php @@ -1,7 +1,7 @@ <?php /* * StatusNet - the distributed open-source microblogging tool - * Copyright (C) 2008, 2009, StatusNet, Inc. + * Copyright (C) 2008, 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 @@ -31,15 +31,151 @@ class Command $this->user = $user; } - function execute($channel) + /** + * Execute the command and send success or error results + * back via the given communications channel. + * + * @param Channel + */ + public function execute($channel) + { + try { + $this->handle($channel); + } catch (CommandException $e) { + $channel->error($this->user, $e->getMessage()); + } catch (Exception $e) { + common_log(LOG_ERR, "Error handling " . get_class($this) . ": " . $e->getMessage()); + $channel->error($this->user, $e->getMessage()); + } + } + + + /** + * Override this with the meat! + * + * An error to send back to the user may be sent by throwing + * a CommandException with a formatted message. + * + * @param Channel + * @throws CommandException + */ + function handle($channel) { return false; } + + /** + * Look up a notice from an argument, by poster's name to get last post + * or notice_id prefixed with #. + * + * @return Notice + * @throws CommandException + */ + function getNotice($arg) + { + $notice = null; + if (Event::handle('StartCommandGetNotice', array($this, $arg, &$notice))) { + if(substr($this->other,0,1)=='#'){ + // A specific notice_id #123 + + $notice = Notice::staticGet(substr($arg,1)); + if (!$notice) { + throw new CommandException(_('Notice with that id does not exist')); + } + } + + if (Validate::uri($this->other)) { + // A specific notice by URI lookup + $notice = Notice::staticGet('uri', $arg); + } + + if (!$notice) { + // Local or remote profile name to get their last notice. + // May throw an exception and report 'no such user' + $recipient = $this->getProfile($arg); + + $notice = $recipient->getCurrentNotice(); + if (!$notice) { + throw new CommandException(_('User has no last notice')); + } + } + } + Event::handle('EndCommandGetNotice', array($this, $arg, &$notice)); + if (!$notice) { + throw new CommandException(_('Notice with that id does not exist')); + } + return $notice; + } + + /** + * Look up a local or remote profile by nickname. + * + * @return Profile + * @throws CommandException + */ + function getProfile($arg) + { + $profile = null; + if (Event::handle('StartCommandGetProfile', array($this, $arg, &$profile))) { + $profile = + common_relative_profile($this->user, common_canonical_nickname($arg)); + } + 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; + } + + /** + * Get a local user by name + * @return User + * @throws CommandException + */ + function getUser($arg) + { + $user = null; + if (Event::handle('StartCommandGetUser', array($this, $arg, &$user))) { + $user = User::staticGet('nickname', $arg); + } + 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)); + } + return $user; + } + + /** + * Get a local or remote group by name. + * @return User_group + * @throws CommandException + */ + function getGroup($arg) + { + $group = null; + if (Event::handle('StartCommandGetGroup', array($this, $arg, &$group))) { + $group = User_group::getForNickname($arg, $this->user->getProfile()); + } + Event::handle('EndCommandGetGroup', array($this, $arg, &$group)); + if (!$group) { + throw new CommandException(_('No such group.')); + } + return $group; + } +} + +class CommandException extends Exception +{ } class UnimplementedCommand extends Command { - function execute($channel) + function handle($channel) { $channel->error($this->user, _("Sorry, this command is not yet implemented.")); } @@ -81,24 +217,22 @@ class NudgeCommand extends Command parent::__construct($user); $this->other = $other; } - function execute($channel) + + function handle($channel) { - $recipient = User::staticGet('nickname', $this->other); - if(! $recipient){ - $channel->error($this->user, sprintf(_('Could not find a user with nickname %s'), - $this->other)); - }else{ - if ($recipient->id == $this->user->id) { - $channel->error($this->user, _('It does not make a lot of sense to nudge yourself!')); - }else{ - if ($recipient->email && $recipient->emailnotifynudge) { - mail_notify_nudge($this->user, $recipient); - } - // XXX: notify by IM - // XXX: notify by SMS - $channel->output($this->user, sprintf(_('Nudge sent to %s'), - $recipient->nickname)); + $recipient = $this->getUser($this->other); + if ($recipient->id == $this->user->id) { + throw new CommandException(_('It does not make a lot of sense to nudge yourself!')); + } else { + if ($recipient->email && $recipient->emailnotifynudge) { + mail_notify_nudge($this->user, $recipient); } + // 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)); } } } @@ -115,7 +249,7 @@ class InviteCommand extends UnimplementedCommand class StatsCommand extends Command { - function execute($channel) + function handle($channel) { $profile = $this->user->getProfile(); @@ -142,42 +276,20 @@ class FavCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - if(substr($this->other,0,1)=='#'){ - //favoriting a specific notice_id - - $notice = Notice::staticGet(substr($this->other,1)); - if (!$notice) { - $channel->error($this->user, _('Notice with that id does not exist')); - return; - } - $recipient = $notice->getProfile(); - }else{ - //favoriting a given user's last notice - - $recipient = - common_relative_profile($this->user, common_canonical_nickname($this->other)); - - if (!$recipient) { - $channel->error($this->user, _('No such user.')); - return; - } - $notice = $recipient->getCurrentNotice(); - if (!$notice) { - $channel->error($this->user, _('User has no last notice')); - return; - } - } - - $fave = Fave::addNew($this->user, $notice); + $notice = $this->getNotice($this->other); + $fave = Fave::addNew($this->user->getProfile(), $notice); if (!$fave) { $channel->error($this->user, _('Could not create favorite.')); return; } - $other = User::staticGet('id', $recipient->id); + // @fixme favorite notification should be triggered + // at a lower level + + $other = User::staticGet('id', $notice->profile_id); if ($other && $other->id != $user->id) { if ($other->email && $other->emailnotifyfav) { @@ -191,6 +303,7 @@ class FavCommand extends Command } } + class JoinCommand extends Command { var $other = null; @@ -201,17 +314,10 @@ class JoinCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - - $nickname = common_canonical_nickname($this->other); - $group = User_group::staticGet('nickname', $nickname); - $cur = $this->user; - - if (!$group) { - $channel->error($cur, _('No such group.')); - return; - } + $group = $this->getGroup($this->other); + $cur = $this->user; if ($cur->isMember($group)) { $channel->error($cur, _('You are already a member of that group')); @@ -228,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)); } @@ -249,12 +359,10 @@ class DropCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - - $nickname = common_canonical_nickname($this->other); - $group = User_group::staticGet('nickname', $nickname); - $cur = $this->user; + $group = $this->getGroup($this->other); + $cur = $this->user; if (!$group) { $channel->error($cur, _('No such group.')); @@ -272,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)); } @@ -293,28 +405,28 @@ class WhoisCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - $recipient = - common_relative_profile($this->user, common_canonical_nickname($this->other)); - - if (!$recipient) { - $channel->error($this->user, _('No such user.')); - return; - } + $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); @@ -332,9 +444,18 @@ class MessageCommand extends Command $this->text = $text; } - function execute($channel) + function handle($channel) { - $other = User::staticGet('nickname', common_canonical_nickname($this->other)); + try { + $other = $this->getUser($this->other); + } catch (CommandException $e) { + try { + $profile = $this->getProfile($this->other); + } catch (CommandException $f) { + throw $e; + } + throw new CommandException(sprintf(_('%s is a remote profile; you can only send direct messages to users on the same server.'), $this->other)); + } $len = mb_strlen($this->text); @@ -346,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; } @@ -364,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.')); @@ -380,33 +505,9 @@ class RepeatCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - if(substr($this->other,0,1)=='#'){ - //repeating a specific notice_id - - $notice = Notice::staticGet(substr($this->other,1)); - if (!$notice) { - $channel->error($this->user, _('Notice with that id does not exist')); - return; - } - $recipient = $notice->getProfile(); - }else{ - //repeating a given user's last notice - - $recipient = - common_relative_profile($this->user, common_canonical_nickname($this->other)); - - if (!$recipient) { - $channel->error($this->user, _('No such user.')); - return; - } - $notice = $recipient->getCurrentNotice(); - if (!$notice) { - $channel->error($this->user, _('User has no last notice')); - return; - } - } + $notice = $this->getNotice($this->other); if($this->user->id == $notice->profile_id) { @@ -414,7 +515,7 @@ class RepeatCommand extends Command return; } - if ($recipient->hasRepeated($notice->id)) { + if ($this->user->getProfile()->hasRepeated($notice->id)) { $channel->error($this->user, _('Already repeated that notice')); return; } @@ -423,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.')); @@ -441,33 +544,10 @@ class ReplyCommand extends Command $this->text = $text; } - function execute($channel) + function handle($channel) { - if(substr($this->other,0,1)=='#'){ - //replying to a specific notice_id - - $notice = Notice::staticGet(substr($this->other,1)); - if (!$notice) { - $channel->error($this->user, _('Notice with that id does not exist')); - return; - } - $recipient = $notice->getProfile(); - }else{ - //replying to a given user's last notice - - $recipient = - common_relative_profile($this->user, common_canonical_nickname($this->other)); - - if (!$recipient) { - $channel->error($this->user, _('No such user.')); - return; - } - $notice = $recipient->getCurrentNotice(); - if (!$notice) { - $channel->error($this->user, _('User has no last notice')); - return; - } - } + $notice = $this->getNotice($this->other); + $recipient = $notice->getProfile(); $len = mb_strlen($this->text); @@ -507,17 +587,10 @@ class GetCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { - $target_nickname = common_canonical_nickname($this->other); - - $target = - common_relative_profile($this->user, $target_nickname); + $target = $this->getProfile($this->other); - if (!$target) { - $channel->error($this->user, _('No such user.')); - return; - } $notice = $target->getCurrentNotice(); if (!$notice) { $channel->error($this->user, _('User has no last notice')); @@ -525,7 +598,7 @@ class GetCommand extends Command } $notice_content = $notice->content; - $channel->output($this->user, $target_nickname . ": " . $notice_content); + $channel->output($this->user, $target->nickname . ": " . $notice_content); } } @@ -540,7 +613,7 @@ class SubCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { if (!$this->other) { @@ -548,16 +621,16 @@ class SubCommand extends Command return; } - $otherUser = User::staticGet('nickname', $this->other); + $target = $this->getProfile($this->other); - if (empty($otherUser)) { - $channel->error($this->user, _('No such user')); - return; + $remote = Remote_profile::staticGet('id', $target->id); + if ($remote) { + throw new CommandException(_("Can't subscribe to OMB profiles by command.")); } try { Subscription::start($this->user->getProfile(), - $otherUser->getProfile()); + $target); $channel->output($this->user, sprintf(_('Subscribed to %s'), $this->other)); } catch (Exception $e) { $channel->error($this->user, $e->getMessage()); @@ -576,22 +649,18 @@ class UnsubCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { if(!$this->other) { $channel->error($this->user, _('Specify the name of the user to unsubscribe from')); return; } - $otherUser = User::staticGet('nickname', $this->other); - - if (empty($otherUser)) { - $channel->error($this->user, _('No such user')); - } + $target = $this->getProfile($this->other); try { Subscription::cancel($this->user->getProfile(), - $otherUser->getProfile()); + $target); $channel->output($this->user, sprintf(_('Unsubscribed from %s'), $this->other)); } catch (Exception $e) { $channel->error($this->user, $e->getMessage()); @@ -607,7 +676,7 @@ class OffCommand extends Command parent::__construct($user); $this->other = $other; } - function execute($channel) + function handle($channel) { if ($other) { $channel->error($this->user, _("Command not yet implemented.")); @@ -630,7 +699,7 @@ class OnCommand extends Command $this->other = $other; } - function execute($channel) + function handle($channel) { if ($other) { $channel->error($this->user, _("Command not yet implemented.")); @@ -646,7 +715,7 @@ class OnCommand extends Command class LoginCommand extends Command { - function execute($channel) + function handle($channel) { $disabled = common_config('logincommand','disabled'); $disabled = isset($disabled) && $disabled; @@ -686,7 +755,7 @@ class LoseCommand extends Command return; } - $result=subs_unsubscribe_from($this->user, $this->other); + $result = Subscription::cancel($this->getProfile($this->other), $this->user->getProfile()); if ($result) { $channel->output($this->user, sprintf(_('Unsubscribed %s'), $this->other)); @@ -698,7 +767,7 @@ class LoseCommand extends Command class SubscriptionsCommand extends Command { - function execute($channel) + function handle($channel) { $profile = $this->user->getSubscriptions(0); $nicknames=array(); @@ -720,7 +789,7 @@ class SubscriptionsCommand extends Command class SubscribersCommand extends Command { - function execute($channel) + function handle($channel) { $profile = $this->user->getSubscribers(); $nicknames=array(); @@ -742,7 +811,7 @@ class SubscribersCommand extends Command class GroupsCommand extends Command { - function execute($channel) + function handle($channel) { $group = $this->user->getGroups(); $groups=array(); @@ -763,7 +832,7 @@ class GroupsCommand extends Command class HelpCommand extends Command { - function execute($channel) + function handle($channel) { $channel->output($this->user, _("Commands:\n". diff --git a/lib/common.php b/lib/common.php index 6c01c7db4..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.0beta6+bugfix1'); +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); @@ -71,7 +71,6 @@ if (!function_exists('dl')) { # global configuration object require_once('PEAR.php'); -require_once('PEAR/Exception.php'); require_once('DB/DataObject.php'); require_once('DB/DataObject/Cast.php'); # for dates @@ -124,22 +123,10 @@ require_once INSTALLDIR.'/lib/util.php'; require_once INSTALLDIR.'/lib/action.php'; require_once INSTALLDIR.'/lib/mail.php'; require_once INSTALLDIR.'/lib/subs.php'; -require_once INSTALLDIR.'/lib/activity.php'; require_once INSTALLDIR.'/lib/clientexception.php'; require_once INSTALLDIR.'/lib/serverexception.php'; - -//set PEAR error handling to use regular PHP exceptions -function PEAR_ErrorToPEAR_Exception($err) -{ - if ($err->getCode()) { - throw new PEAR_Exception($err->getMessage(), $err->getCode()); - } - throw new PEAR_Exception($err->getMessage()); -} -PEAR::setErrorHandling(PEAR_ERROR_CALLBACK, 'PEAR_ErrorToPEAR_Exception'); - try { StatusNet::init(@$server, @$path, @$conffile); } catch (NoConfigException $e) { 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 7b97c7dda..dcf225d1f 100644 --- a/lib/default.php +++ b/lib/default.php @@ -40,7 +40,8 @@ $default = 'logdebug' => false, 'fancy' => false, 'locale_path' => INSTALLDIR.'/locale', - 'language' => 'en_US', + 'language' => 'en', + 'langdetect' => true, 'languages' => get_all_languages(), 'email' => array_key_exists('SERVER_ADMIN', $_SERVER) ? $_SERVER['SERVER_ADMIN'] : null, @@ -53,10 +54,11 @@ $default = 'ssl' => 'never', 'sslserver' => null, 'shorturllength' => 30, - 'dupelimit' => 60, # default for same person saying the same thing + 'dupelimit' => 60, // default for same person saying the same thing 'textlimit' => 140, 'indent' => true, - 'use_x_sendfile' => false + 'use_x_sendfile' => false, + 'notice' => null // site wide notice text ), 'db' => array('database' => 'YOU HAVE TO SET THIS IN config.php', @@ -70,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' => @@ -85,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 @@ -136,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 @@ -183,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' => @@ -254,6 +267,9 @@ $default = 'linkcolor' => null, 'backgroundimage' => null, 'disposition' => null), + 'custom_css' => + array('enabled' => true, + 'css' => ''), 'notice' => array('contentlimit' => null), 'message' => @@ -278,12 +294,13 @@ $default = 'TightUrl' => array('shortenerName' => '2tu.us', 'freeService' => true,'serviceUrl'=>'http://2tu.us/?save=y&url=%1$s'), 'Geonames' => null, 'Mapstraction' => null, + 'OStatus' => null, 'WikiHashtags' => null, 'RSSCloud' => null, 'OpenID' => null), ), 'admin' => - array('panels' => array('design', 'site', 'user', 'paths', 'access', 'sessions')), + array('panels' => array('design', 'site', 'user', 'paths', 'access', 'sessions', 'sitenotice')), 'singleuser' => array('enabled' => false, 'nickname' => null), @@ -291,4 +308,13 @@ $default = array('crawldelay' => 0, 'disallow' => array('main', 'settings', 'admin', 'search', 'message') ), + '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') + ), ); diff --git a/lib/deluserqueuehandler.php b/lib/deluserqueuehandler.php new file mode 100644 index 000000000..710303938 --- /dev/null +++ b/lib/deluserqueuehandler.php @@ -0,0 +1,99 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 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/>. + */ + +/** + * Background job to delete prolific users without disrupting front-end too much. + * + * Up to 50 messages are deleted on each run through; when all messages are gone, + * the actual account is deleted. + * + * @package QueueHandler + * @maintainer Brion Vibber <brion@status.net> + */ + +class DelUserQueueHandler extends QueueHandler +{ + const DELETION_WINDOW = 50; + + public function transport() + { + return 'deluser'; + } + + public function handle($user) + { + if (!($user instanceof User)) { + common_log(LOG_ERR, "Got a bogus user, not deleting"); + return true; + } + + $user = User::staticGet('id', $user->id); + if (!$user) { + common_log(LOG_INFO, "User {$user->nickname} was deleted before we got here."); + 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); + if ($notice->N) { + common_log(LOG_INFO, "Deleting next {$notice->N} notices by {$user->nickname}"); + while ($notice->fetch()) { + $del = clone($notice); + $del->delete(); + } + + // @todo improve reliability in case we died during the above deletions + // with a fatal error. If the job is lost, we should perform some kind + // of garbage collection later. + + // Queue up the next batch. + $qm = QueueManager::get(); + $qm->enqueue($user, 'deluser'); + } else { + // Out of notices? Let's finish deleting this guy! + $user->delete(); + common_log(LOG_INFO, "User $user->id $user->nickname deleted."); + return true; + } + + return true; + } + + /** + * Fetch the next self::DELETION_WINDOW messages for this user. + * @return Notice + */ + protected function getNextBatch(User $user) + { + $notice = new Notice(); + $notice->profile_id = $user->id; + $notice->limit(self::DELETION_WINDOW); + $notice->find(); + return $notice; + } + +} 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/grantroleform.php b/lib/grantroleform.php new file mode 100644 index 000000000..b5f952746 --- /dev/null +++ b/lib/grantroleform.php @@ -0,0 +1,93 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Form for granting a role + * + * 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 Form + * @package StatusNet + * @author Evan Prodromou <evan@status.net>, Brion Vibber <brion@status.net> + * @copyright 2009-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')) { + exit(1); +} + +/** + * Form for sandboxing a user + * + * @category Form + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + * + * @see UnSandboxForm + */ + +class GrantRoleForm extends ProfileActionForm +{ + function __construct($role, $label, $writer, $profile, $r2args) + { + parent::__construct($writer, $profile, $r2args); + $this->role = $role; + $this->label = $label; + } + + /** + * Action this form provides + * + * @return string Name of the action, lowercased. + */ + + function target() + { + return 'grantrole'; + } + + /** + * Title of the form + * + * @return string Title of the form, internationalized + */ + + function title() + { + return $this->label; + } + + function formData() + { + parent::formData(); + $this->out->hidden('role', $this->role); + } + + /** + * Description of the form + * + * @return string description of the form, internationalized + */ + + function description() + { + return sprintf(_('Grant this user the "%s" role'), $this->label); + } +} 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 4c3af8d7d..b69f718e5 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) { @@ -120,6 +123,28 @@ class HTTPClient extends HTTP_Request2 { $this->config['max_redirs'] = 10; $this->config['follow_redirects'] = true; + + // We've had some issues with keepalive breaking with + // HEAD requests, such as to youtube which seems to be + // emitting chunked encoding info for an empty body + // instead of not emitting anything. This may be a + // bug on YouTube's end, but the upstream libray + // 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; + } + parent::__construct($url, $method, $config); $this->setHeader('User-Agent', $this->userAgent()); } @@ -136,7 +161,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()) @@ -147,7 +172,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()) @@ -161,7 +186,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()) @@ -173,7 +198,7 @@ class HTTPClient extends HTTP_Request2 } /** - * @return HTTPResponse + * @return StatusNet_HTTPResponse * @throws HTTP_Request2_Exception */ protected function doRequest($url, $method, $headers) @@ -207,12 +232,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() @@ -255,6 +280,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/imagefile.php b/lib/imagefile.php index 7b0479455..e47287741 100644 --- a/lib/imagefile.php +++ b/lib/imagefile.php @@ -60,6 +60,19 @@ class ImageFile $this->filepath = $filepath; $info = @getimagesize($this->filepath); + + if (!( + ($info[2] == IMAGETYPE_GIF && function_exists('imagecreatefromgif')) || + ($info[2] == IMAGETYPE_JPEG && function_exists('imagecreatefromjpeg')) || + $info[2] == IMAGETYPE_BMP || + ($info[2] == IMAGETYPE_WBMP && function_exists('imagecreatefromwbmp')) || + ($info[2] == IMAGETYPE_XBM && function_exists('imagecreatefromxbm')) || + ($info[2] == IMAGETYPE_PNG && function_exists('imagecreatefrompng')))) { + + throw new Exception(_('Unsupported image file format.')); + return; + } + $this->type = ($info) ? $info[2]:$type; $this->width = ($info) ? $info[0]:$width; $this->height = ($info) ? $info[1]:$height; @@ -97,19 +110,6 @@ class ImageFile return; } - if ($info[2] !== IMAGETYPE_GIF && - $info[2] !== IMAGETYPE_JPEG && - $info[2] !== IMAGETYPE_BMP && - $info[2] !== IMAGETYPE_WBMP && - $info[2] !== IMAGETYPE_XBM && - $info[2] !== IMAGETYPE_XPM && - $info[2] !== IMAGETYPE_PNG) { - - @unlink($_FILES[$param]['tmp_name']); - throw new Exception(_('Unsupported image file format.')); - return; - } - return new ImageFile(null, $_FILES[$param]['tmp_name']); } @@ -159,9 +159,6 @@ class ImageFile case IMAGETYPE_XBM: $image_src = imagecreatefromxbm($this->filepath); break; - case IMAGETYPE_XPM: - $image_src = imagecreatefromxpm($this->filepath); - break; default: throw new Exception(_('Unknown file type')); return; @@ -204,10 +201,6 @@ class ImageFile //we don't want to save XBM... it's a rare format that we can't guarantee clients will support //save png instead $this->type = IMAGETYPE_PNG; - } else if($this->type == IMAGETYPE_XPM) { - //we don't want to save XPM... it's a rare format that we can't guarantee clients will support - //save png instead - $this->type = IMAGETYPE_PNG; } $outname = Avatar::filename($this->id, diff --git a/lib/installer.php b/lib/installer.php new file mode 100644 index 000000000..bd9d69cd4 --- /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', '<')) { + $errors[] = '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 e1bf06ba6..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 @@ -88,22 +247,30 @@ class Sharing_XMPP extends XMPPHP_XMPP /** * Build an XMPP proxy connection that'll save outgoing messages * to the 'xmppout' queue to be picked up by xmppdaemon later. + * + * If queueing is disabled, we'll grab a live connection. + * + * @return XMPPHP */ function jabber_proxy() { - $proxy = new Queued_XMPP(common_config('xmpp', 'host') ? - common_config('xmpp', 'host') : - common_config('xmpp', 'server'), - common_config('xmpp', 'port'), - common_config('xmpp', 'user'), - common_config('xmpp', 'password'), - common_config('xmpp', 'resource') . 'daemon', - common_config('xmpp', 'server'), - common_config('xmpp', 'debug') ? - true : false, - common_config('xmpp', 'debug') ? - XMPPHP_Log::LEVEL_VERBOSE : null); - return $proxy; + if (common_config('queue', 'enabled')) { + $proxy = new Queued_XMPP(common_config('xmpp', 'host') ? + common_config('xmpp', 'host') : + common_config('xmpp', 'server'), + common_config('xmpp', 'port'), + common_config('xmpp', 'user'), + common_config('xmpp', 'password'), + common_config('xmpp', 'resource') . 'daemon', + common_config('xmpp', 'server'), + common_config('xmpp', 'debug') ? + true : false, + common_config('xmpp', 'debug') ? + XMPPHP_Log::LEVEL_VERBOSE : null); + return $proxy; + } else { + return jabber_connect(); + } } /** diff --git a/lib/language.php b/lib/language.php index f5ee7fac5..6840148d2 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,9 +300,11 @@ 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'), 'de' => array('q' => 0.8, 'lang' => 'de', 'name' => 'German', 'direction' => 'ltr'), @@ -300,7 +316,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 c724764cc..ab5742e33 100644 --- a/lib/mail.php +++ b/lib/mail.php @@ -133,12 +133,13 @@ function mail_notify_from() * @param User &$user user to send email to * @param string $subject subject of the email * @param string $body body of the email + * @param array $headers optional list of email headers * @param string $address optional specification of email address * * @return boolean success flag */ -function mail_to_user(&$user, $subject, $body, $address=null) +function mail_to_user(&$user, $subject, $body, $headers=array(), $address=null) { if (!$address) { $address = $user->email; @@ -169,8 +170,10 @@ function mail_to_user(&$user, $subject, $body, $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, ". @@ -180,7 +183,9 @@ function mail_confirm_address($user, $code, $nickname, $address) $nickname, common_config('site', 'name'), common_local_url('confirmaddress', array('code' => $code)), common_config('site', 'name')); - return mail_to_user($user, $subject, $body, $address); + $headers = array(); + + return mail_to_user($user, $subject, $body, $headers, $address); } /** @@ -219,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(); @@ -231,13 +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'. @@ -251,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); } } @@ -283,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". @@ -410,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()); @@ -436,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"; @@ -459,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". @@ -475,8 +497,11 @@ function mail_notify_nudge($from, $to) $from->nickname, common_local_url('all', array('nickname' => $to->nickname)), common_config('site', 'name')); - common_init_locale(); - return mail_to_user($to, $subject, $body); + common_switch_locale(); + + $headers = _mail_prepare_headers('nudge', $to->nickname, $from->nickname); + + return mail_to_user($to, $subject, $body, $headers); } /** @@ -506,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". @@ -526,8 +553,10 @@ function mail_notify_message($message, $from=null, $to=null) common_local_url('newmessage', array('to' => $from->id)), common_config('site', 'name')); - common_init_locale(); - return mail_to_user($to, $subject, $body); + $headers = _mail_prepare_headers('message', $to->nickname, $from->nickname); + + common_switch_locale(); + return mail_to_user($to, $subject, $body, $headers); } /** @@ -554,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" . @@ -578,8 +609,10 @@ function mail_notify_fave($other, $user, $notice) common_config('site', 'name'), $user->nickname); - common_init_locale(); - mail_to_user($other, $subject, $body); + $headers = _mail_prepare_headers('fave', $other->nickname, $user->nickname); + + common_switch_locale(); + mail_to_user($other, $subject, $body, $headers); } /** @@ -609,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); - $body = sprintf(_("%1\$s (@%9\$s) just sent a notice to your attention (an '@-reply') on %2\$s.\n\n". + // 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" . @@ -641,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', @@ -649,6 +683,30 @@ function mail_notify_attn($user, $notice) common_local_url('emailsettings'), //%8 $sender->nickname); //%9 - common_init_locale(); - mail_to_user($user, $subject, $body); + $headers = _mail_prepare_headers('mention', $user->nickname, $sender->nickname); + + common_switch_locale(); + mail_to_user($user, $subject, $body, $headers); } + +/** + * Prepare the common mail headers used in notification emails + * + * @param string $msg_type type of message being sent to the user + * @param string $to nickname of the receipient + * @param string $from nickname of the user triggering the notification + * + * @return array list of mail headers to include in the message + */ +function _mail_prepare_headers($msg_type, $to, $from) +{ + $headers = array( + 'X-StatusNet-MessageType' => $msg_type, + 'X-StatusNet-TargetUser' => $to, + 'X-StatusNet-SourceUser' => $from, + 'X-StatusNet-Domain' => common_config('site', 'server') + ); + + return $headers; +} + 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 485096ac4..455695366 100644 --- a/lib/mysqlschema.php +++ b/lib/mysqlschema.php @@ -90,15 +90,24 @@ class MysqlSchema extends Schema * @param string $name Name of the table to get * * @return TableDef tabledef for that table. + * @throws SchemaTableMissingException */ public function getTableDef($name) { - $res = $this->conn->query('DESCRIBE ' . $name); + $query = "SELECT * FROM INFORMATION_SCHEMA.COLUMNS " . + "WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='%s'"; + $schema = $this->conn->dsn['database']; + $sql = sprintf($query, $schema, $name); + $res = $this->conn->query($sql); if (PEAR::isError($res)) { throw new Exception($res->getMessage()); } + if ($res->numRows() == 0) { + $res->free(); + throw new SchemaTableMissingException("No such table: $name"); + } $td = new TableDef(); @@ -111,9 +120,9 @@ class MysqlSchema extends Schema $cd = new ColumnDef(); - $cd->name = $row['Field']; + $cd->name = $row['COLUMN_NAME']; - $packed = $row['Type']; + $packed = $row['COLUMN_TYPE']; if (preg_match('/^(\w+)\((\d+)\)$/', $packed, $match)) { $cd->type = $match[1]; @@ -122,18 +131,58 @@ class MysqlSchema extends Schema $cd->type = $packed; } - $cd->nullable = ($row['Null'] == 'YES') ? true : false; - $cd->key = $row['Key']; - $cd->default = $row['Default']; - $cd->extra = $row['Extra']; + $cd->nullable = ($row['IS_NULLABLE'] == 'YES') ? true : false; + $cd->key = $row['COLUMN_KEY']; + $cd->default = $row['COLUMN_DEFAULT']; + $cd->extra = $row['EXTRA']; + + // Autoincrement is stuck into the extra column. + // Pull it out so we don't accidentally mod it every time... + $extra = preg_replace('/(^|\s)auto_increment(\s|$)/i', '$1$2', $cd->extra); + if ($extra != $cd->extra) { + $cd->extra = trim($extra); + $cd->auto_increment = true; + } + + // mysql extensions -- not (yet) used by base class + $cd->charset = $row['CHARACTER_SET_NAME']; + $cd->collate = $row['COLLATION_NAME']; $td->columns[] = $cd; } + $res->free(); return $td; } /** + * Pull the given table properties from INFORMATION_SCHEMA. + * Most of the good stuff is MySQL extensions. + * + * @return array + * @throws Exception if table info can't be looked up + */ + + function getTableProperties($table, $props) + { + $query = "SELECT %s FROM INFORMATION_SCHEMA.TABLES " . + "WHERE TABLE_SCHEMA='%s' AND TABLE_NAME='%s'"; + $schema = $this->conn->dsn['database']; + $sql = sprintf($query, implode(',', $props), $schema, $table); + $res = $this->conn->query($sql); + + $row = array(); + $ok = $res->fetchInto($row, DB_FETCHMODE_ASSOC); + $res->free(); + + if ($ok) { + return $row; + } else { + throw new SchemaTableMissingException("No such table: $table"); + } + } + + /** * Gets a ColumnDef object for a single column. * * Throws an exception if the table is not found. @@ -185,35 +234,26 @@ class MysqlSchema extends Schema } $sql .= $this->_columnSql($cd); - - switch ($cd->key) { - case 'UNI': - $uniques[] = $cd->name; - break; - case 'PRI': - $primary[] = $cd->name; - break; - case 'MUL': - $indices[] = $cd->name; - break; - } } - if (count($primary) > 0) { // it really should be... - $sql .= ",\nconstraint primary key (" . implode(',', $primary) . ")"; + $idx = $this->_indexList($columns); + + if ($idx['primary']) { + $sql .= ",\nconstraint primary key (" . implode(',', $idx['primary']) . ")"; } - foreach ($uniques as $u) { - $sql .= ",\nunique index {$name}_{$u}_idx ($u)"; + foreach ($idx['uniques'] as $u) { + $key = $this->_uniqueKey($name, $u); + $sql .= ",\nunique index $key ($u)"; } - foreach ($indices as $i) { - $sql .= ",\nindex {$name}_{$i}_idx ($i)"; + foreach ($idx['indices'] as $i) { + $key = $this->_key($name, $i); + $sql .= ",\nindex $key ($i)"; } - $sql .= "); "; + $sql .= ") ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin; "; - common_log(LOG_INFO, $sql); $res = $this->conn->query($sql); if (PEAR::isError($res)) { @@ -224,6 +264,47 @@ class MysqlSchema extends Schema } /** + * Look over a list of column definitions and list up which + * indices will be present + */ + private function _indexList(array $columns) + { + $list = array('uniques' => array(), + 'primary' => array(), + 'indices' => array()); + foreach ($columns as $cd) { + switch ($cd->key) { + case 'UNI': + $list['uniques'][] = $cd->name; + break; + case 'PRI': + $list['primary'][] = $cd->name; + break; + case 'MUL': + $list['indices'][] = $cd->name; + break; + } + } + return $list; + } + + /** + * Get the unique index key name for a given column on this table + */ + function _uniqueKey($tableName, $columnName) + { + return $this->_key($tableName, $columnName); + } + + /** + * Get the index key name for a given column on this table + */ + function _key($tableName, $columnName) + { + return "{$tableName}_{$columnName}_idx"; + } + + /** * Drops a table from the schema * * Throws an exception if the table is not found. @@ -394,21 +475,20 @@ class MysqlSchema extends Schema try { $td = $this->getTableDef($tableName); - } catch (Exception $e) { - if (preg_match('/no such table/', $e->getMessage())) { - return $this->createTable($tableName, $columns); - } else { - throw $e; - } + } catch (SchemaTableMissingException $e) { + return $this->createTable($tableName, $columns); } $cur = $this->_names($td->columns); $new = $this->_names($columns); - $toadd = array_diff($new, $cur); - $todrop = array_diff($cur, $new); - $same = array_intersect($new, $cur); - $tomod = array(); + $dropIndex = array(); + $toadd = array_diff($new, $cur); + $todrop = array_diff($cur, $new); + $same = array_intersect($new, $cur); + $tomod = array(); + $addIndex = array(); + $tableProps = array(); foreach ($same as $m) { $curCol = $this->_byName($td->columns, $m); @@ -416,10 +496,64 @@ class MysqlSchema extends Schema if (!$newCol->equals($curCol)) { $tomod[] = $newCol->name; + continue; + } + + // Earlier versions may have accidentally left tables at default + // charsets which might be latin1 or other freakish things. + if ($this->_isString($curCol)) { + if ($curCol->charset != 'utf8') { + $tomod[] = $newCol->name; + continue; + } + } + } + + // Find any indices we have to change... + $curIdx = $this->_indexList($td->columns); + $newIdx = $this->_indexList($columns); + + if ($curIdx['primary'] != $newIdx['primary']) { + if ($curIdx['primary']) { + $dropIndex[] = 'drop primary key'; + } + if ($newIdx['primary']) { + $keys = implode(',', $newIdx['primary']); + $addIndex[] = "add constraint primary key ($keys)"; } } - if (count($toadd) + count($todrop) + count($tomod) == 0) { + $dropUnique = array_diff($curIdx['uniques'], $newIdx['uniques']); + $addUnique = array_diff($newIdx['uniques'], $curIdx['uniques']); + foreach ($dropUnique as $columnName) { + $dropIndex[] = 'drop key ' . $this->_uniqueKey($tableName, $columnName); + } + foreach ($addUnique as $columnName) { + $addIndex[] = 'add constraint unique key ' . $this->_uniqueKey($tableName, $columnName) . " ($columnName)";; + } + + $dropMultiple = array_diff($curIdx['indices'], $newIdx['indices']); + $addMultiple = array_diff($newIdx['indices'], $curIdx['indices']); + foreach ($dropMultiple as $columnName) { + $dropIndex[] = 'drop key ' . $this->_key($tableName, $columnName); + } + foreach ($addMultiple as $columnName) { + $addIndex[] = 'add key ' . $this->_key($tableName, $columnName) . " ($columnName)"; + } + + // Check for table properties: make sure we're using a sane + // engine type and charset/collation. + // @fixme make the default engine configurable? + $oldProps = $this->getTableProperties($tableName, array('ENGINE', 'TABLE_COLLATION')); + if (strtolower($oldProps['ENGINE']) != 'innodb') { + $tableProps['ENGINE'] = 'InnoDB'; + } + if (strtolower($oldProps['TABLE_COLLATION']) != 'utf8_bin') { + $tableProps['DEFAULT CHARSET'] = 'utf8'; + $tableProps['COLLATE'] = 'utf8_bin'; + } + + if (count($dropIndex) + count($toadd) + count($todrop) + count($tomod) + count($addIndex) + count($tableProps) == 0) { // nothing to do return true; } @@ -429,6 +563,10 @@ class MysqlSchema extends Schema $phrase = array(); + foreach ($dropIndex as $indexSql) { + $phrase[] = $indexSql; + } + foreach ($toadd as $columnName) { $cd = $this->_byName($columns, $columnName); @@ -445,8 +583,17 @@ class MysqlSchema extends Schema $phrase[] = 'MODIFY COLUMN ' . $this->_columnSql($cd); } + foreach ($addIndex as $indexSql) { + $phrase[] = $indexSql; + } + + foreach ($tableProps as $key => $val) { + $phrase[] = "$key=$val"; + } + $sql = 'ALTER TABLE ' . $tableName . ' ' . implode(', ', $phrase); + common_log(LOG_DEBUG, __METHOD__ . ': ' . $sql); $res = $this->conn->query($sql); if (PEAR::isError($res)) { @@ -519,6 +666,10 @@ class MysqlSchema extends Schema $sql .= "{$cd->type} "; } + if ($this->_isString($cd)) { + $sql .= " CHARACTER SET utf8 "; + } + if (!empty($cd->default)) { $sql .= "default {$cd->default} "; } else { @@ -535,4 +686,13 @@ class MysqlSchema extends Schema return $sql; } + + /** + * Is this column a string type? + */ + private function _isString(ColumnDef $cd) + { + $strings = array('char', 'varchar', 'text'); + return in_array(strtolower($cd->type), $strings); + } } 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 88a925241..e23cf3b6d 100644 --- a/lib/noticelist.php +++ b/lib/noticelist.php @@ -340,8 +340,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 +426,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(); @@ -442,21 +451,26 @@ class NoticeListItem extends Widget 'title' => $latlon), $name); } else { - $this->out->elementStart('a', array('href' => $url)); - $this->out->element('abbr', array('class' => 'geo', - 'title' => $latlon), - $name); - $this->out->elementEnd('a'); + $xstr = new XMLStringer(false); + $xstr->elementStart('a', array('href' => $url, + 'rel' => 'external')); + $xstr->element('abbr', array('class' => 'geo', + 'title' => $latlon), + $name); + $xstr->elementEnd('a'); + $this->out->raw($xstr->getString()); } $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); @@ -476,54 +490,46 @@ class NoticeListItem extends Widget function showNoticeSource() { - if ($this->notice->source) { + $ns = $this->notice->getSource(); + + if ($ns) { + $source_name = _($ns->code); $this->out->text(' '); $this->out->elementStart('span', 'source'); $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'); } } @@ -539,18 +545,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 91bc09667..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/ */ @@ -61,7 +62,8 @@ class PgsqlSchema extends Schema public function getTableDef($name) { - $res = $this->conn->query("select *, column_default as default, is_nullable as Null, udt_name as Type, column_name AS Field from INFORMATION_SCHEMA.COLUMNS where table_name = '$name'"); + $res = $this->conn->query("SELECT *, column_default as default, is_nullable as Null, + udt_name as Type, column_name AS Field from INFORMATION_SCHEMA.COLUMNS where table_name = '$name'"); if (PEAR::isError($res)) { throw new Exception($res->getMessage()); @@ -72,10 +74,12 @@ class PgsqlSchema extends Schema $td->name = $name; $td->columns = array(); + if ($res->numRows() == 0 ) { + throw new Exception('no such table'); //pretend to be the msyql error. yeah, this sucks. + } $row = array(); while ($res->fetchInto($row, DB_FETCHMODE_ASSOC)) { -// var_dump($row); $cd = new ColumnDef(); $cd->name = $row['field']; @@ -139,6 +143,7 @@ class PgsqlSchema extends Schema $uniques = array(); $primary = array(); $indices = array(); + $onupdate = array(); $sql = "CREATE TABLE $name (\n"; @@ -151,7 +156,6 @@ class PgsqlSchema extends Schema } $sql .= $this->_columnSql($cd); - switch ($cd->key) { case 'UNI': $uniques[] = $cd->name; @@ -166,23 +170,23 @@ class PgsqlSchema extends Schema } if (count($primary) > 0) { // it really should be... - $sql .= ",\nconstraint primary key (" . implode(',', $primary) . ")"; + $sql .= ",\n PRIMARY KEY (" . implode(',', $primary) . ")"; } + $sql .= "); "; + + foreach ($uniques as $u) { - $sql .= ",\nunique index {$name}_{$u}_idx ($u)"; + $sql .= "\n CREATE index {$name}_{$u}_idx ON {$name} ($u); "; } foreach ($indices as $i) { - $sql .= ",\nindex {$name}_{$i}_idx ($i)"; + $sql .= "CREATE index {$name}_{$i}_idx ON {$name} ($i)"; } - - $sql .= "); "; - $res = $this->conn->query($sql); if (PEAR::isError($res)) { - throw new Exception($res->getMessage()); + throw new Exception($res->getMessage(). ' SQL was '. $sql); } return true; @@ -210,6 +214,22 @@ class PgsqlSchema extends Schema } /** + * Translate the (mostly) mysql-ish column types into somethings more standard + * @param string column type + * + * @return string postgres happy column type + */ + private function _columnTypeTranslation($type) { + $map = array( + 'datetime' => 'timestamp', + ); + if(!empty($map[$type])) { + return $map[$type]; + } + return $type; + } + + /** * Adds an index to a table. * * If no name is provided, a name will be made up based @@ -302,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); @@ -359,6 +379,7 @@ class PgsqlSchema extends Schema try { $td = $this->getTableDef($tableName); + } catch (Exception $e) { if (preg_match('/no such table/', $e->getMessage())) { return $this->createTable($tableName, $columns); @@ -374,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; @@ -407,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)) { @@ -473,15 +496,25 @@ 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 .= "{$cd->type}({$cd->size}) "; + $sql .= "{$type}({$cd->size}) "; } else { - $sql .= "{$cd->type} "; + $sql .= "{$type} "; } if (!empty($cd->default)) { @@ -489,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/poco.php b/lib/poco.php new file mode 100644 index 000000000..2157062b3 --- /dev/null +++ b/lib/poco.php @@ -0,0 +1,240 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class PoCo +{ + const NS = 'http://portablecontacts.net/spec/1.0'; + + const USERNAME = 'preferredUsername'; + const DISPLAYNAME = 'displayName'; + const NOTE = 'note'; + + public $preferredUsername; + public $displayName; + public $note; + public $address; + public $urls = array(); + + function __construct($element = null) + { + if (empty($element)) { + return; + } + + $this->preferredUsername = ActivityUtils::childContent( + $element, + self::USERNAME, + self::NS + ); + + $this->displayName = ActivityUtils::childContent( + $element, + self::DISPLAYNAME, + self::NS + ); + + $this->note = ActivityUtils::childContent( + $element, + self::NOTE, + self::NS + ); + + $this->address = $this->_getAddress($element); + $this->urls = $this->_getURLs($element); + } + + private function _getURLs($element) + { + $urlEls = $element->getElementsByTagnameNS(self::NS, PoCoURL::URLS); + $urls = array(); + + foreach ($urlEls as $urlEl) { + + $type = ActivityUtils::childContent( + $urlEl, + PoCoURL::TYPE, + PoCo::NS + ); + + $value = ActivityUtils::childContent( + $urlEl, + PoCoURL::VALUE, + PoCo::NS + ); + + $primary = ActivityUtils::childContent( + $urlEl, + PoCoURL::PRIMARY, + PoCo::NS + ); + + $isPrimary = false; + + if (isset($primary) && $primary == 'true') { + $isPrimary = true; + } + + // @todo check to make sure a primary hasn't already been added + + array_push($urls, new PoCoURL($type, $value, $isPrimary)); + } + return $urls; + } + + private function _getAddress($element) + { + $addressEl = ActivityUtils::child( + $element, + PoCoAddress::ADDRESS, + PoCo::NS + ); + + if (!empty($addressEl)) { + $formatted = ActivityUtils::childContent( + $addressEl, + PoCoAddress::FORMATTED, + self::NS + ); + + if (!empty($formatted)) { + $address = new PoCoAddress(); + $address->formatted = $formatted; + return $address; + } + } + + return null; + } + + function fromProfile($profile) + { + if (empty($profile)) { + return null; + } + + $poco = new PoCo(); + + $poco->preferredUsername = $profile->nickname; + $poco->displayName = $profile->getBestName(); + + $poco->note = $profile->bio; + + $paddy = new PoCoAddress(); + $paddy->formatted = $profile->location; + $poco->address = $paddy; + + if (!empty($profile->homepage)) { + array_push( + $poco->urls, + new PoCoURL( + 'homepage', + $profile->homepage, + true + ) + ); + } + + return $poco; + } + + function fromGroup($group) + { + if (empty($group)) { + return null; + } + + $poco = new PoCo(); + + $poco->preferredUsername = $group->nickname; + $poco->displayName = $group->getBestName(); + + $poco->note = $group->description; + + $paddy = new PoCoAddress(); + $paddy->formatted = $group->location; + $poco->address = $paddy; + + if (!empty($group->homepage)) { + array_push( + $poco->urls, + new PoCoURL( + 'homepage', + $group->homepage, + true + ) + ); + } + + return $poco; + } + + function getPrimaryURL() + { + foreach ($this->urls as $url) { + if ($url->primary) { + return $url; + } + } + } + + function asString() + { + $xs = new XMLStringer(true); + $xs->element( + 'poco:preferredUsername', + null, + $this->preferredUsername + ); + + $xs->element( + 'poco:displayName', + null, + $this->displayName + ); + + if (!empty($this->note)) { + $xs->element('poco:note', null, common_xml_safe_str($this->note)); + } + + if (!empty($this->address)) { + $xs->raw($this->address->asString()); + } + + foreach ($this->urls as $url) { + $xs->raw($url->asString()); + } + + return $xs->getString(); + } +} diff --git a/lib/pocoaddress.php b/lib/pocoaddress.php new file mode 100644 index 000000000..60873bdc4 --- /dev/null +++ b/lib/pocoaddress.php @@ -0,0 +1,56 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class PoCoAddress +{ + const ADDRESS = 'address'; + const FORMATTED = 'formatted'; + + public $formatted; + + // @todo Other address fields + + function asString() + { + if (!empty($this->formatted)) { + $xs = new XMLStringer(true); + $xs->elementStart('poco:address'); + $xs->element('poco:formatted', null, common_xml_safe_str($this->formatted)); + $xs->elementEnd('poco:address'); + return $xs->getString(); + } + + return null; + } +} diff --git a/lib/pocourl.php b/lib/pocourl.php new file mode 100644 index 000000000..803484d76 --- /dev/null +++ b/lib/pocourl.php @@ -0,0 +1,65 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * 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 Feed + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class PoCoURL +{ + const URLS = 'urls'; + const TYPE = 'type'; + const VALUE = 'value'; + const PRIMARY = 'primary'; + + public $type; + public $value; + public $primary; + + function __construct($type, $value, $primary = false) + { + $this->type = $type; + $this->value = $value; + $this->primary = $primary; + } + + function asString() + { + $xs = new XMLStringer(true); + $xs->elementStart('poco:urls'); + $xs->element('poco:type', null, $this->type); + $xs->element('poco:value', null, $this->value); + if (!empty($this->primary)) { + $xs->element('poco:primary', null, 'true'); + } + $xs->elementEnd('poco:urls'); + return $xs->getString(); + } +} 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/processmanager.php b/lib/processmanager.php new file mode 100644 index 000000000..6032bfc5c --- /dev/null +++ b/lib/processmanager.php @@ -0,0 +1,84 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * i/o manager to watch for a dead parent process + * + * 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 QueueManager + * @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/ + */ + +class ProcessManager extends IoManager +{ + protected $socket; + + public static function get() + { + throw new Exception("Must pass ProcessManager per-instance"); + } + + public function __construct($socket) + { + $this->socket = $socket; + } + + /** + * Tell the i/o queue master if and how we can handle multi-site + * processes. + * + * Return one of: + * IoManager::SINGLE_ONLY + * IoManager::INSTANCE_PER_SITE + * IoManager::INSTANCE_PER_PROCESS + */ + public static function multiSite() + { + return IoManager::INSTANCE_PER_PROCESS; + } + + /** + * We won't get any input on it, but if it's broken we'll + * know something's gone horribly awry. + * + * @return array of resources + */ + function getSockets() + { + return array($this->socket); + } + + /** + * See if the parent died and request a shutdown... + * + * @param resource $socket + * @return boolean success + */ + function handleInput($socket) + { + if (feof($socket)) { + common_log(LOG_INFO, "Parent process exited; shutting down child."); + $this->master->requestShutdown(); + } + return true; + } +} + diff --git a/lib/profileaction.php b/lib/profileaction.php index 2d4d23265..504b77566 100644 --- a/lib/profileaction.php +++ b/lib/profileaction.php @@ -105,28 +105,30 @@ class ProfileAction extends OwnerDesignAction $this->elementStart('div', array('id' => 'entity_subscriptions', 'class' => 'section')); + if (Event::handle('StartShowSubscriptionsMiniList', array($this))) { + $this->element('h2', null, _('Subscriptions')); - $this->element('h2', null, _('Subscriptions')); + $cnt = 0; - $cnt = 0; + if (!empty($profile)) { + $pml = new ProfileMiniList($profile, $this); + $cnt = $pml->show(); + if ($cnt == 0) { + $this->element('p', null, _('(None)')); + } + } - if (!empty($profile)) { - $pml = new ProfileMiniList($profile, $this); - $cnt = $pml->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('subscriptions', + array('nickname' => $this->profile->nickname)), + 'class' => 'more'), + _('All subscriptions')); + $this->elementEnd('p'); } - } - if ($cnt > PROFILES_PER_MINILIST) { - $this->elementStart('p'); - $this->element('a', array('href' => common_local_url('subscriptions', - array('nickname' => $this->profile->nickname)), - 'class' => 'more'), - _('All subscriptions')); - $this->elementEnd('p'); + Event::handle('EndShowSubscriptionsMiniList', array($this)); } - $this->elementEnd('div'); } @@ -137,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'); @@ -167,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')); @@ -217,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'); } @@ -226,27 +245,49 @@ class ProfileAction extends OwnerDesignAction $this->elementStart('div', array('id' => 'entity_groups', 'class' => 'section')); + if (Event::handle('StartShowGroupsMiniList', array($this))) { + $this->element('h2', null, _('Groups')); + + if ($groups) { + $gml = new GroupMiniList($groups, $this->user, $this); + $cnt = $gml->show(); + if ($cnt == 0) { + $this->element('p', null, _('(None)')); + } + } - $this->element('h2', null, _('Groups')); - - if ($groups) { - $gml = new GroupMiniList($groups, $this->user, $this); - $cnt = $gml->show(); - if ($cnt == 0) { - $this->element('p', null, _('(None)')); + if ($cnt > GROUPS_PER_MINILIST) { + $this->elementStart('p'); + $this->element('a', array('href' => common_local_url('usergroups', + array('nickname' => $this->profile->nickname)), + 'class' => 'more'), + _('All groups')); + $this->elementEnd('p'); } - } - if ($cnt > GROUPS_PER_MINILIST) { - $this->elementStart('p'); - $this->element('a', array('href' => common_local_url('usergroups', - array('nickname' => $this->profile->nickname)), - 'class' => 'more'), - _('All groups')); - $this->elementEnd('p'); + Event::handle('EndShowGroupsMiniList', array($this)); } + $this->elementEnd('div'); + } +} - $this->elementEnd('div'); +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 4f1e84a6a..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'); } @@ -273,18 +272,12 @@ class ProfileListItem extends Widget $usf = new UnsubscribeForm($this->out, $this->profile); $usf->show(); } else { - $other = User::staticGet('id', $this->profile->id); - if (!empty($other)) { + // We can't initiate sub for a remote OMB profile. + $remote = Remote_profile::staticGet('id', $this->profile->id); + if (empty($remote)) { $sf = new SubscribeForm($this->out, $this->profile); $sf->show(); } - else { - $url = common_local_url('remotesubscribe', - array('nickname' => $this->profile->nickname)); - $this->out->element('a', array('href' => $url, - 'class' => 'entity_remote_subscribe'), - _('Subscribe')); - } } $this->out->elementEnd('li'); } @@ -305,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/queued_xmpp.php b/lib/queued_xmpp.php index fdd074db2..f6bccfd5b 100644 --- a/lib/queued_xmpp.php +++ b/lib/queued_xmpp.php @@ -49,10 +49,20 @@ class Queued_XMPP extends XMPPHP_XMPP */ public function __construct($host, $port, $user, $password, $resource, $server = null, $printlog = false, $loglevel = null) { - parent::__construct($host, $port, $user, $password, $resource, $server, $printlog, $loglevel); - // Normally the fulljid isn't filled out until resource binding time; - // we need to save it here since we're not talking to a real server. - $this->fulljid = "{$this->basejid}/{$this->resource}"; + parent::__construct($host, $port, $user, $password, $resource, $server, $printlog, $loglevel); + + // We use $host to connect, but $server to build JIDs if specified. + // This seems to fix an upstream bug where $host was used to build + // $this->basejid, never seen since it isn't actually used in the base + // classes. + if (!$server) { + $server = $this->host; + } + $this->basejid = $this->user . '@' . $server; + + // Normally the fulljid is filled out by the server at resource binding + // time, but we need to do it since we're not talking to a real server. + $this->fulljid = "{$this->basejid}/{$this->resource}"; } /** diff --git a/lib/queuemanager.php b/lib/queuemanager.php index 87bd356aa..0829c8a8b 100644 --- a/lib/queuemanager.php +++ b/lib/queuemanager.php @@ -264,6 +264,9 @@ abstract class QueueManager extends IoManager $this->connect('sms', 'SmsQueueHandler'); } + // Background user management tasks... + $this->connect('deluser', 'DelUserQueueHandler'); + // Broadcasting profile updates to OMB remote subscribers $this->connect('profile', 'ProfileQueueHandler'); 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/revokeroleform.php b/lib/revokeroleform.php new file mode 100644 index 000000000..ec24b9910 --- /dev/null +++ b/lib/revokeroleform.php @@ -0,0 +1,93 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Form for revoking a role + * + * 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 Form + * @package StatusNet + * @author Evan Prodromou <evan@status.net>, Brion Vibber <brion@status.net> + * @copyright 2009-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')) { + exit(1); +} + +/** + * Form for sandboxing a user + * + * @category Form + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + * + * @see UnSandboxForm + */ + +class RevokeRoleForm extends ProfileActionForm +{ + function __construct($role, $label, $writer, $profile, $r2args) + { + parent::__construct($writer, $profile, $r2args); + $this->role = $role; + $this->label = $label; + } + + /** + * Action this form provides + * + * @return string Name of the action, lowercased. + */ + + function target() + { + return 'revokerole'; + } + + /** + * Title of the form + * + * @return string Title of the form, internationalized + */ + + function title() + { + return $this->label; + } + + function formData() + { + parent::formData(); + $this->out->hidden('role', $this->role); + } + + /** + * Description of the form + * + * @return string description of the form, internationalized + */ + + function description() + { + return sprintf(_('Revoke the "%s" role from this user'), $this->label); + } +} diff --git a/lib/right.php b/lib/right.php index 4e9c5a918..deb451fde 100644 --- a/lib/right.php +++ b/lib/right.php @@ -58,5 +58,7 @@ class Right const EMAILONSUBSCRIBE = 'emailonsubscribe'; const EMAILONFAVE = 'emailonfave'; const MAKEGROUPADMIN = 'makegroupadmin'; + const GRANTROLE = 'grantrole'; + const REVOKEROLE = 'revokerole'; } diff --git a/lib/router.php b/lib/router.php index abbce041d..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))) { @@ -98,6 +125,7 @@ class Router 'groupblock', 'groupunblock', 'sandbox', 'unsandbox', 'silence', 'unsilence', + 'grantrole', 'revokerole', 'repeat', 'deleteuser', 'geocode', @@ -108,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]+')); @@ -230,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}')); @@ -507,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', @@ -564,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', @@ -625,12 +658,18 @@ 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( + 'api/statusnet/media/upload', + array('action' => 'ApiMediaUpload') + ); // 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')); @@ -649,6 +688,8 @@ class Router $m->connect('admin/access', array('action' => 'accessadminpanel')); $m->connect('admin/paths', array('action' => 'pathsadminpanel')); $m->connect('admin/sessions', array('action' => 'sessionsadminpanel')); + $m->connect('admin/sitenotice', array('action' => 'sitenoticeadminpanel')); + $m->connect('admin/snapshot', array('action' => 'snapshotadminpanel')); $m->connect('getfile/:filename', array('action' => 'getfile'), @@ -708,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', @@ -774,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/schema.php b/lib/schema.php index 137b814e0..1503c96d4 100644 --- a/lib/schema.php +++ b/lib/schema.php @@ -485,3 +485,9 @@ class Schema return $sql; } } + +class SchemaTableMissingException extends Exception +{ + // no-op +} + 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/spawningdaemon.php b/lib/spawningdaemon.php index fd9ae4355..2f9f6e32e 100644 --- a/lib/spawningdaemon.php +++ b/lib/spawningdaemon.php @@ -71,6 +71,8 @@ abstract class SpawningDaemon extends Daemon */ function run() { + $this->initPipes(); + $children = array(); for ($i = 1; $i <= $this->threads; $i++) { $pid = pcntl_fork(); @@ -129,6 +131,34 @@ abstract class SpawningDaemon extends Daemon } /** + * Create an IPC socket pair which child processes can use to detect + * if the parent process has been killed. + */ + function initPipes() + { + $sockets = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, 0); + if ($sockets) { + $this->parentWriter = $sockets[0]; + $this->parentReader = $sockets[1]; + } else { + $this->log(LOG_ERROR, "Couldn't create inter-process sockets"); + exit(1); + } + } + + /** + * Build an IOManager that simply ensures that we have a connection + * to the parent process open. If it breaks, the child process will + * die. + * + * @return ProcessManager + */ + public function processManager() + { + return new ProcessManager($this->parentReader); + } + + /** * Determine whether to respawn an exited subprocess based on its exit code. * Otherwise we'll respawn all exits by default. * @@ -152,6 +182,8 @@ abstract class SpawningDaemon extends Daemon */ protected function initAndRunChild($thread) { + // Close the writer end of our parent<->children pipe. + fclose($this->parentWriter); $this->set_id($this->get_id() . "." . $thread); $this->resetDb(); $exitCode = $this->runThread(); diff --git a/lib/statusnet.php b/lib/statusnet.php index 7c4df84b4..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 @@ -354,10 +369,10 @@ class StatusNet class NoConfigException extends Exception { - public $config_files; + public $configFiles; - function __construct($msg, $config_files) { + function __construct($msg, $configFiles) { parent::__construct($msg); - $this->config_files = $config_files; + $this->configFiles = $configFiles; } } 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/subs.php b/lib/subs.php index e2ce0667e..165bbaa8f 100644 --- a/lib/subs.php +++ b/lib/subs.php @@ -43,46 +43,3 @@ function subs_unsubscribe_to($user, $other) return $e->getMessage(); } } - -function subs_unsubscribe_from($user, $other){ - $local = User::staticGet("nickname",$other); - if($local){ - return subs_unsubscribe_to($local,$user); - } else { - try { - $remote = Profile::staticGet("nickname",$other); - if(is_string($remote)){ - return $remote; - } - if (Event::handle('StartUnsubscribe', array($remote,$user))) { - - $sub = DB_DataObject::factory('subscription'); - - $sub->subscriber = $remote->id; - $sub->subscribed = $user->id; - - $sub->find(true); - - // note we checked for existence above - - if (!$sub->delete()) - return _('Couldn\'t delete subscription.'); - - $cache = common_memcache(); - - if ($cache) { - $cache->delete(common_cache_key('user:notices_with_friends:' . $remote->id)); - } - - - $user->blowSubscribersCount(); - $remote->blowSubscribersCount(); - - Event::handle('EndUnsubscribe', array($remote, $user)); - } - } catch (Exception $e) { - return $e->getMessage(); - } - } -} - 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/usernoprofileexception.php b/lib/usernoprofileexception.php new file mode 100644 index 000000000..6744d2529 --- /dev/null +++ b/lib/usernoprofileexception.php @@ -0,0 +1,74 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * class for an exception when the user profile is missing + * + * 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 Exception + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +/** + * Class for an exception when the user profile is missing + * + * @category Exception + * @package StatusNet + * @author Evan Prodromou <evan@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3 + * @link http://status.net/ + */ + +class UserNoProfileException extends ServerException +{ + var $user = null; + + /** + * constructor + * + * @param User $user User that's missing a profile + */ + + public function __construct($user) + { + $this->user = $user; + + $message = sprintf(_("User %s (%d) has no profile record."), + $user->nickname, $user->id); + + parent::__construct($message); + } + + /** + * Accessor for user + * + * @return User the user that triggered this exception + */ + + public function getUser() + { + return $this->user; + } +} diff --git a/lib/userprofile.php b/lib/userprofile.php index 43dfd05be..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))) { @@ -228,6 +229,17 @@ class UserProfile extends Widget function showEntityActions() { + if ($this->profile->hasRole(Profile_role::DELETED)) { + $this->out->elementStart('div', 'entity_actions'); + $this->out->element('h2', null, _('User actions')); + $this->out->elementStart('ul'); + $this->out->elementStart('p', array('class' => 'profile_deleted')); + $this->out->text(_('User deletion in progress...')); + $this->out->elementEnd('p'); + $this->out->elementEnd('ul'); + $this->out->elementEnd('div'); + return; + } if (Event::handle('StartProfilePageActionsSection', array(&$this->out, $this->profile))) { $cur = common_current_user(); @@ -346,6 +358,16 @@ class UserProfile extends Widget $this->out->elementEnd('ul'); $this->out->elementEnd('li'); } + + if ($cur->hasRight(Right::GRANTROLE)) { + $this->out->elementStart('li', 'entity_role'); + $this->out->element('p', null, _('User role')); + $this->out->elementStart('ul'); + $this->roleButton('administrator', _m('role', 'Administrator')); + $this->roleButton('moderator', _m('role', 'Moderator')); + $this->out->elementEnd('ul'); + $this->out->elementEnd('li'); + } } } @@ -359,6 +381,22 @@ class UserProfile extends Widget } } + function roleButton($role, $label) + { + list($action, $r2args) = $this->out->returnToArgs(); + $r2args['action'] = $action; + + $this->out->elementStart('li', "entity_role_$role"); + if ($this->user->hasRole($role)) { + $rf = new RevokeRoleForm($role, $label, $this->out, $this->profile, $r2args); + $rf->show(); + } else { + $rf = new GrantRoleForm($role, $label, $this->out, $this->profile, $r2args); + $rf->show(); + } + $this->out->elementEnd('li'); + } + function showRemoteSubscribeLink() { $url = common_local_url('remotesubscribe', diff --git a/lib/util.php b/lib/util.php index add1b0ae6..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,28 +49,91 @@ 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'); - // gettext seems very picky... We first need to setlocale() - // to a locale which _does_ exist on the system, and _then_ - // we can set in another locale that may not be set up - // (say, ga_ES for Galego/Galician) it seems to take it. - common_init_locale("en_US"); - // Note that this setlocale() call may "fail" but this is harmless; // gettext will still select the right language. $language = common_language(); $locale_set = common_init_locale($language); + if (!$locale_set) { + // The requested locale doesn't exist on the system. + // + // gettext seems very picky... We first need to setlocale() + // to a locale which _does_ exist on the system, and _then_ + // we can set in another locale that may not be set up + // (say, ga_ES for Galego/Galician) it seems to take it. + // + // For some reason C and POSIX which are guaranteed to work + // 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 && 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`; + foreach (explode("\n", $all) as $locale) { + if (preg_match('/\.utf[-_]?8$/i', $locale)) { + $ok = setlocale(LC_ALL, $locale); + if ($ok) { + break; + } + } + } + } + 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'); @@ -71,6 +142,7 @@ function common_init_language() textdomain("statusnet"); } + function common_timezone() { if (common_logged_in()) { @@ -105,11 +177,13 @@ function common_language() // Otherwise, find the best match for the languages requested by the // user's browser... - $httplang = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : null; - if (!empty($httplang)) { - $language = client_prefered_language($httplang); - if ($language) - return $language; + if (common_config('site', 'langdetect')) { + $httplang = isset($_SERVER['HTTP_ACCEPT_LANGUAGE']) ? $_SERVER['HTTP_ACCEPT_LANGUAGE'] : null; + if (!empty($httplang)) { + $language = client_prefered_language($httplang); + if ($language) + return $language; + } } // Finally, if none of the above worked, use the site's default... @@ -131,6 +205,11 @@ function common_munge_password($password, $id) function common_check_user($nickname, $password) { + // empty nickname always unacceptable + if (empty($nickname)) { + return false; + } + $authenticatedUser = false; if (Event::handle('StartCheckPassword', array($nickname, $password, &$authenticatedUser))) { @@ -770,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; @@ -793,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')); } @@ -829,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, @@ -853,7 +939,7 @@ function common_valid_profile_tag($str) function common_group_link($sender_id, $nickname) { $sender = Profile::staticGet($sender_id); - $group = User_group::getForNickname($nickname); + $group = User_group::getForNickname($nickname, $sender); if ($sender && $group && $sender->isMember($group)) { $attrs = array('href' => $group->permalink(), 'class' => 'url'); @@ -1010,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); @@ -1149,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'; @@ -1239,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(); @@ -1270,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() @@ -1364,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. */ @@ -1460,7 +1634,15 @@ function common_copy_args($from) $to = array(); $strip = get_magic_quotes_gpc(); foreach ($from as $k => $v) { - $to[$k] = ($strip) ? stripslashes($v) : $v; + if($strip) { + if(is_array($v)) { + $to[$k] = common_copy_args($v); + } else { + $to[$k] = stripslashes($v); + } + } else { + $to[$k] = $v; + } } return $to; } @@ -1488,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 @@ -1755,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 f37635855..829eaa36c 100644 --- a/lib/xmppmanager.php +++ b/lib/xmppmanager.php @@ -36,6 +36,7 @@ class XmppManager extends IoManager protected $site = null; protected $pingid = 0; protected $lastping = null; + protected $conn = null; static protected $singletons = array(); @@ -252,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/ */ |