diff options
Diffstat (limited to 'lib')
37 files changed, 1844 insertions, 303 deletions
diff --git a/lib/action.php b/lib/action.php index 01bb0f7e9..0e5d7ae36 100644 --- a/lib/action.php +++ b/lib/action.php @@ -274,15 +274,16 @@ class Action extends HTMLOutputter // lawsuit if (Event::handle('StartShowScripts', array($this))) { if (Event::handle('StartShowJQueryScripts', array($this))) { $this->script('jquery.min.js'); - $this->script('jquery.form.js'); - $this->script('jquery.cookie.js'); - $this->inlineScript('if (typeof window.JSON !== "object") { $.getScript("'.common_path('js/json2.js').'"); }'); + $this->script('jquery.form.min.js'); + $this->script('jquery.cookie.min.js'); + $this->inlineScript('if (typeof window.JSON !== "object") { $.getScript("'.common_path('js/json2.min.js').'"); }'); $this->script('jquery.joverlay.min.js'); Event::handle('EndShowJQueryScripts', array($this)); } if (Event::handle('StartShowStatusNetScripts', array($this)) && Event::handle('StartShowLaconicaScripts', array($this))) { - $this->script('util.js'); + $this->script('util.min.js'); + $this->showScriptMessages(); // Frame-busting code to avoid clickjacking attacks. $this->inlineScript('if (window.top !== window.self) { window.top.location.href = window.self.location.href; }'); Event::handle('EndShowStatusNetScripts', array($this)); @@ -293,6 +294,59 @@ class Action extends HTMLOutputter // lawsuit } /** + * Exports a map of localized text strings to JavaScript code. + * + * Plugins can add to what's exported by hooking the StartScriptMessages or EndScriptMessages + * events and appending to the array. Try to avoid adding strings that won't be used, as + * they'll be added to HTML output. + */ + + function showScriptMessages() + { + $messages = array(); + + if (Event::handle('StartScriptMessages', array($this, &$messages))) { + // Common messages needed for timeline views etc... + + // TRANS: Localized tooltip for '...' expansion button on overlong remote messages. + $messages['showmore_tooltip'] = _m('TOOLTIP', 'Show more'); + + $messages = array_merge($messages, $this->getScriptMessages()); + + Event::handle('EndScriptMessages', array($this, &$messages)); + } + + if (!empty($messages)) { + $this->inlineScript('SN.messages=' . json_encode($messages)); + } + + return $messages; + } + + /** + * If the action will need localizable text strings, export them here like so: + * + * return array('pool_deepend' => _('Deep end'), + * 'pool_shallow' => _('Shallow end')); + * + * The exported map will be available via SN.msg() to JS code: + * + * $('#pool').html('<div class="deepend"></div><div class="shallow"></div>'); + * $('#pool .deepend').text(SN.msg('pool_deepend')); + * $('#pool .shallow').text(SN.msg('pool_shallow')); + * + * Exports a map of localized text strings to JavaScript code. + * + * Plugins can add to what's exported on any action by hooking the StartScriptMessages or + * EndScriptMessages events and appending to the array. Try to avoid adding strings that won't + * be used, as they'll be added to HTML output. + */ + function getScriptMessages() + { + return array(); + } + + /** * Show OpenSearch headers * * @return nothing @@ -824,16 +878,17 @@ class Action extends HTMLOutputter // lawsuit // TRANS: Secondary navigation menu option leading to privacy policy. _('Privacy')); $this->menuItem(common_local_url('doc', array('title' => 'source')), - // TRANS: Secondary navigation menu option. + // TRANS: Secondary navigation menu option. Leads to information about StatusNet and its license. _('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. + // TRANS: Secondary navigation menu option leading to e-mail contact information on the + // TRANS: StatusNet site, where to report bugs, ... _('Contact')); $this->menuItem(common_local_url('doc', array('title' => 'badge')), - // TRANS: Secondary navigation menu option. + // TRANS: Secondary navigation menu option. Leads to information about embedding a timeline widget. _('Badge')); Event::handle('EndSecondaryNav', array($this)); } @@ -1354,4 +1409,15 @@ class Action extends HTMLOutputter // lawsuit $this->clientError(_('There was a problem with your session token.')); } } + + /** + * Check if the current request is a POST + * + * @return boolean true if POST; otherwise false. + */ + + function isPost() + { + return ($_SERVER['REQUEST_METHOD'] == 'POST'); + } } diff --git a/lib/activity.php b/lib/activity.php index e974ca991..e29bc1a25 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -101,6 +101,11 @@ class Activity public $categories = array(); // list of AtomCategory objects public $enclosures = array(); // list of enclosure URL references + public $extra = array(); // extra elements as array(tag, attrs, content) + public $source; // ActivitySource object representing 'home feed' + public $selfLink; // <link rel='self' type='application/atom+xml'> + public $editLink; // <link rel='edit' type='application/atom+xml'> + /** * Turns a regular old Atom <entry> into a magical activity * @@ -235,6 +240,11 @@ class Activity foreach (ActivityUtils::getLinks($entry, 'enclosure') as $link) { $this->enclosures[] = $link->getAttribute('href'); } + + // From APP. Might be useful. + + $this->selfLink = ActivityUtils::getLink($entry, 'self', 'application/atom+xml'); + $this->editLink = ActivityUtils::getLink($entry, 'edit', 'application/atom+xml'); } function _fromRssItem($item, $channel) @@ -323,34 +333,74 @@ class Activity if ($namespace) { $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom', + 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0', 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', 'xmlns:georss' => 'http://www.georss.org/georss', 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0', 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0', - 'xmlns:media' => 'http://purl.org/syndication/atommedia'); + 'xmlns:media' => 'http://purl.org/syndication/atommedia', + 'xmlns:statusnet' => 'http://status.net/schema/api/1/'); } else { $attrs = array(); } $xs->elementStart('entry', $attrs); - $xs->element('id', null, $this->id); - $xs->element('title', null, $this->title); - $xs->element('published', null, self::iso8601Date($this->time)); - $xs->element('content', array('type' => 'html'), $this->content); + if ($this->verb == ActivityVerb::POST && count($this->objects) == 1) { - if (!empty($this->summary)) { - $xs->element('summary', null, $this->summary); - } + $obj = $this->objects[0]; + + $xs->element('id', null, $obj->id); + $xs->element('activity:object-type', null, $obj->type); + + if (!empty($obj->title)) { + $xs->element('title', null, $obj->title); + } else { + // XXX need a better default title + $xs->element('title', null, _('Post')); + } + + if (!empty($obj->content)) { + $xs->element('content', array('type' => 'html'), $obj->content); + } + + if (!empty($obj->summary)) { + $xs->element('summary', null, $obj->summary); + } + + if (!empty($obj->link)) { + $xs->element('link', array('rel' => 'alternate', + 'type' => 'text/html'), + $obj->link); + } + + // XXX: some object types might have other values here. + + } else { + $xs->element('id', null, $this->id); + $xs->element('title', null, $this->title); + + $xs->element('content', array('type' => 'html'), $this->content); + + if (!empty($this->summary)) { + $xs->element('summary', null, $this->summary); + } + + if (!empty($this->link)) { + $xs->element('link', array('rel' => 'alternate', + 'type' => 'text/html'), + $this->link); + } - if (!empty($this->link)) { - $xs->element('link', array('rel' => 'alternate', - 'type' => 'text/html'), - $this->link); } - // XXX: add context + $xs->element('activity:verb', null, $this->verb); + $published = self::iso8601Date($this->time); + + $xs->element('published', null, $published); + $xs->element('updated', null, $published); + if ($author) { $xs->elementStart('author'); $xs->element('uri', array(), $this->actor->id); @@ -361,14 +411,61 @@ class Activity $xs->raw($this->actor->asString('activity:actor')); } - $xs->element('activity:verb', null, $this->verb); - - if (!empty($this->objects)) { + if ($this->verb != ActivityVerb::POST || count($this->objects) != 1) { foreach($this->objects as $object) { $xs->raw($object->asString()); } } + if (!empty($this->context)) { + + if (!empty($this->context->replyToID)) { + if (!empty($this->context->replyToUrl)) { + $xs->element('thr:in-reply-to', + array('ref' => $this->context->replyToID, + 'href' => $this->context->replyToUrl)); + } else { + $xs->element('thr:in-reply-to', + array('ref' => $this->context->replyToID)); + } + } + + if (!empty($this->context->replyToUrl)) { + $xs->element('link', array('rel' => 'related', + 'href' => $this->context->replyToUrl)); + } + + if (!empty($this->context->conversation)) { + $xs->element('link', array('rel' => 'ostatus:conversation', + 'href' => $this->context->conversation)); + } + + foreach ($this->context->attention as $attnURI) { + $xs->element('link', array('rel' => 'ostatus:attention', + 'href' => $attnURI)); + $xs->element('link', array('rel' => 'mentioned', + 'href' => $attnURI)); + } + + // XXX: shoulda used ActivityVerb::SHARE + + if (!empty($this->context->forwardID)) { + if (!empty($this->context->forwardUrl)) { + $xs->element('ostatus:forward', + array('ref' => $this->context->forwardID, + 'href' => $this->context->forwardUrl)); + } else { + $xs->element('ostatus:forward', + array('ref' => $this->context->forwardID)); + } + } + + if (!empty($this->context->location)) { + $loc = $this->context->location; + $xs->element('georss:point', null, $loc->lat . ' ' . $loc->lon); + } + } + if ($this->target) { $xs->raw($this->target->asString('activity:target')); } @@ -377,6 +474,79 @@ class Activity $xs->raw($cat->asString()); } + // can be either URLs or enclosure objects + + foreach ($this->enclosures as $enclosure) { + if (is_string($enclosure)) { + $xs->element('link', array('rel' => 'enclosure', + 'href' => $enclosure)); + } else { + $attributes = array('rel' => 'enclosure', + 'href' => $enclosure->url, + 'type' => $enclosure->mimetype, + 'length' => $enclosure->size); + if ($enclosure->title) { + $attributes['title'] = $enclosure->title; + } + $xs->element('link', $attributes); + } + } + + // Info on the source feed + + if (!empty($this->source)) { + $xs->elementStart('source'); + + $xs->element('id', null, $this->source->id); + $xs->element('title', null, $this->source->title); + + if (array_key_exists('alternate', $this->source->links)) { + $xs->element('link', array('rel' => 'alternate', + 'type' => 'text/html', + 'href' => $this->source->links['alternate'])); + } + + if (array_key_exists('self', $this->source->links)) { + $xs->element('link', array('rel' => 'self', + 'type' => 'application/atom+xml', + 'href' => $this->source->links['self'])); + } + + if (array_key_exists('license', $this->source->links)) { + $xs->element('link', array('rel' => 'license', + 'href' => $this->source->links['license'])); + } + + if (!empty($this->source->icon)) { + $xs->element('icon', null, $this->source->icon); + } + + if (!empty($this->source->updated)) { + $xs->element('updated', null, $this->source->updated); + } + + $xs->elementEnd('source'); + } + + if (!empty($this->selfLink)) { + $xs->element('link', array('rel' => 'self', + 'type' => 'application/atom+xml', + 'href' => $this->selfLink)); + } + + if (!empty($this->editLink)) { + $xs->element('link', array('rel' => 'edit', + 'type' => 'application/atom+xml', + 'href' => $this->editLink)); + } + + // For throwing in extra elements; used for statusnet:notice_info + + foreach ($this->extra as $el) { + list($tag, $attrs, $content) = $el; + $xs->element($tag, $attrs, $content); + } + $xs->elementEnd('entry'); return $xs->getString(); diff --git a/lib/activitycontext.php b/lib/activitycontext.php index ff3bc9411..fd0dfe06c 100644 --- a/lib/activitycontext.php +++ b/lib/activitycontext.php @@ -39,6 +39,8 @@ class ActivityContext public $location; public $attention = array(); public $conversation; + public $forwardID; // deprecated, use ActivityVerb::SHARE instead + public $forwardUrl; // deprecated, use ActivityVerb::SHARE instead const THR = 'http://purl.org/syndication/thread/1.0'; const GEORSS = 'http://www.georss.org/georss'; diff --git a/lib/activitysource.php b/lib/activitysource.php new file mode 100644 index 000000000..56266057f --- /dev/null +++ b/lib/activitysource.php @@ -0,0 +1,56 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Activity source, to save in <atom:source> + * + * 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); +} + +/** + * Feed data to store in the <atom:source> element + * + * I wanted to use Atom10Feed but it seems more heavyweight than what's + * needed here. + * + * @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 ActivitySource +{ + public $id; + public $title; + public $icon; + public $updated; + public $links; +} diff --git a/lib/apiaction.php b/lib/apiaction.php index d5023f7c5..2de513cbb 100644 --- a/lib/apiaction.php +++ b/lib/apiaction.php @@ -726,6 +726,12 @@ class ApiAction extends Action $this->endDocument('xml'); } + function showSingleAtomStatus($notice) + { + header('Content-Type: application/atom+xml; charset=utf-8'); + print $notice->asAtomEntry(true, true, true, $this->auth_user); + } + function show_single_json_status($notice) { $this->initDocument('json'); diff --git a/lib/atomcategory.php b/lib/atomcategory.php index 4cc3b4f4d..9763023f7 100644 --- a/lib/atomcategory.php +++ b/lib/atomcategory.php @@ -72,6 +72,6 @@ class AtomCategory } $xs = new XMLStringer(); $xs->element('category', $attribs); - return $xs->asString(); + return $xs->getString(); } } diff --git a/lib/attachmentlist.php b/lib/attachmentlist.php index f6b09fb49..7e536925b 100644 --- a/lib/attachmentlist.php +++ b/lib/attachmentlist.php @@ -79,23 +79,33 @@ class AttachmentList extends Widget $atts = new File; $att = $atts->getAttachments($this->notice->id); if (empty($att)) return 0; + $this->showListStart(); + + foreach ($att as $n=>$attachment) { + $item = $this->newListItem($attachment); + $item->show(); + } + + $this->showListEnd(); + + return count($att); + } + + function showListStart() + { $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')); + } - foreach ($att as $n=>$attachment) { - $item = $this->newListItem($attachment); - $item->show(); - } - + function showListEnd() + { $this->out->elementEnd('dd'); $this->out->elementEnd('ol'); $this->out->elementEnd('dl'); - - return count($att); } /** @@ -187,7 +197,10 @@ class AttachmentListItem extends Widget } function linkAttr() { - return array('class' => 'attachment', 'href' => $this->attachment->url, 'id' => 'attachment-' . $this->attachment->id); + return array('class' => 'attachment', + 'href' => $this->attachment->url, + 'id' => 'attachment-' . $this->attachment->id, + 'title' => $this->title()); } function showLink() { @@ -203,10 +216,32 @@ class AttachmentListItem extends Widget } function showRepresentation() { + $thumb = $this->getThumbInfo(); + if ($thumb) { + $this->out->element('img', array('alt' => '', 'src' => $thumb->url, 'width' => $thumb->width, 'height' => $thumb->height)); + } + } + + /** + * Pull a thumbnail image reference for the given file, and if necessary + * resize it to match currently thumbnail size settings. + * + * @return File_Thumbnail or false/null + */ + function getThumbInfo() + { $thumbnail = File_thumbnail::staticGet('file_id', $this->attachment->id); - if (!empty($thumbnail)) { - $this->out->element('img', array('alt' => '', 'src' => $thumbnail->url, 'width' => $thumbnail->width, 'height' => $thumbnail->height)); + if ($thumbnail) { + $maxWidth = common_config('attachments', 'thumb_width'); + $maxHeight = common_config('attachments', 'thumb_height'); + if ($thumbnail->width > $maxWidth) { + $thumb = clone($thumbnail); + $thumb->width = $maxWidth; + $thumb->height = intval($thumbnail->height * $maxWidth / $thumbnail->width); + return $thumb; + } } + return $thumbnail; } /** @@ -234,6 +269,9 @@ class AttachmentListItem extends Widget } } +/** + * used for one-off attachment action + */ class Attachment extends AttachmentListItem { function showLink() { @@ -417,15 +455,6 @@ class Attachment extends AttachmentListItem 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>'); + // still needed: should show a link? } } diff --git a/lib/command.php b/lib/command.php index 658262a09..3fb4d76c7 100644 --- a/lib/command.php +++ b/lib/command.php @@ -139,7 +139,7 @@ class Command { $user = null; if (Event::handle('StartCommandGetUser', array($this, $arg, &$user))) { - $user = User::staticGet('nickname', $arg); + $user = User::staticGet('nickname', Nickname::normalize($arg)); } Event::handle('EndCommandGetUser', array($this, $arg, &$user)); if (!$user){ @@ -423,7 +423,7 @@ class WhoisCommand extends Command // 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, + $whois = sprintf(_m('WHOIS',"%1\$s (%2\$s)"), $recipient->nickname, $recipient->profileurl); if ($recipient->fullname) { // TRANS: Whois output. %s is the full name of the queried user. @@ -479,13 +479,15 @@ class MessageCommand extends Command return; } - $this->text = common_shorten_links($this->text); + $this->text = $this->user->shortenLinks($this->text); if (Message::contentTooLong($this->text)) { // XXX: i18n. Needs plural support. - // TRANS: Message given if content is too long. + // TRANS: Message given if content is too long. %1$sd is used for plural. // 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.'), + $channel->error($this->user, sprintf(_m('Message too long - maximum is %1$d character, you sent %2$d.', + 'Message too long - maximum is %1$d characters, you sent %2$d.', + Message::maxContent()), Message::maxContent(), mb_strlen($this->text))); return; } @@ -580,13 +582,15 @@ class ReplyCommand extends Command return; } - $this->text = common_shorten_links($this->text); + $this->text = $this->user->shortenLinks($this->text); if (Notice::contentTooLong($this->text)) { // XXX: i18n. Needs plural support. - // TRANS: Message given if content of a notice for a reply is too long. + // TRANS: Message given if content of a notice for a reply is too long. %1$d is used for plural. // TRANS: %1$d is the maximum number of characters, %2$d is the number of submitted characters. - $channel->error($this->user, sprintf(_('Notice too long - maximum is %1$d characters, you sent %2$d.'), + $channel->error($this->user, sprintf(_m('Notice too long - maximum is %1$d character, you sent %2$d.', + 'Notice too long - maximum is %1$d characters, you sent %2$d.', + Notice::maxContent()), Notice::maxContent(), mb_strlen($this->text))); return; } diff --git a/lib/common.php b/lib/common.php index cd4fbfb15..cf4d6e1e7 100644 --- a/lib/common.php +++ b/lib/common.php @@ -117,6 +117,17 @@ require_once 'markdown.php'; // XXX: other formats here +/** + * Avoid the NICKNAME_FMT constant; use the Nickname class instead. + * + * Nickname::DISPLAY_FMT is more suitable for inserting into regexes; + * note that it includes the [] and repeating bits, so should be wrapped + * directly in a capture paren usually. + * + * For validation, use Nickname::normalize(), Nickname::isValid() etc. + * + * @deprecated + */ define('NICKNAME_FMT', VALIDATE_NUM.VALIDATE_ALPHA_LOWER); require_once INSTALLDIR.'/lib/util.php'; diff --git a/lib/currentuserdesignaction.php b/lib/currentuserdesignaction.php index 490f87d13..e84c77768 100644 --- a/lib/currentuserdesignaction.php +++ b/lib/currentuserdesignaction.php @@ -22,7 +22,7 @@ * @category Action * @package StatusNet * @author Evan Prodromou <evan@status.net> - * @copyright 2009 StatusNet, Inc. + * @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/ */ @@ -40,12 +40,33 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * @category Action * @package StatusNet * @author Evan Prodromou <evan@status.net> + * @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 CurrentUserDesignAction extends Action { + + protected $cur = null; // The current user + + /** + * For initializing members of the class. Set a the + * current user here. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + function prepare($argarray) + { + parent::prepare($argarray); + + $this->cur = common_current_user(); + + return true; + } + /** * A design for this action * @@ -55,11 +76,9 @@ class CurrentUserDesignAction extends Action */ function getDesign() { - $cur = common_current_user(); - - if (!empty($cur)) { + if (!empty($this->cur)) { - $design = $cur->getDesign(); + $design = $this->cur->getDesign(); if (!empty($design)) { return $design; @@ -68,4 +87,10 @@ class CurrentUserDesignAction extends Action return parent::getDesign(); } + + function getCurrentUser() + { + return $this->cur; + } } + diff --git a/lib/default.php b/lib/default.php index 85d27f522..029dbb390 100644 --- a/lib/default.php +++ b/lib/default.php @@ -251,6 +251,10 @@ $default = 'monthly_quota' => 15000000, 'uploads' => true, 'filecommand' => '/usr/bin/file', + 'show_thumbs' => true, // show thumbnails in notice lists for uploaded images, and photos and videos linked remotely that provide oEmbed info + 'thumb_width' => 100, + 'thumb_height' => 75, + 'process_links' => true, // check linked resources for embeddable photos and videos; this will hit referenced external web sites when processing new messages. ), 'application' => array('desclimit' => null), diff --git a/lib/designsettings.php b/lib/designsettings.php index 4955e9219..90296a64d 100644 --- a/lib/designsettings.php +++ b/lib/designsettings.php @@ -48,10 +48,8 @@ require_once INSTALLDIR . '/lib/webcolor.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 DesignSettingsAction extends AccountSettingsAction { - var $submitaction = null; /** @@ -59,9 +57,9 @@ class DesignSettingsAction extends AccountSettingsAction * * @return string Title of the page */ - function title() { + // TRANS: Page title for profile design page. return _('Profile design'); } @@ -70,9 +68,9 @@ class DesignSettingsAction extends AccountSettingsAction * * @return instructions for use */ - function getInstructions() { + // TRANS: Instructions for profile design page. return _('Customize the way your profile looks ' . 'with a background image and a colour palette of your choice.'); } @@ -84,10 +82,8 @@ class DesignSettingsAction extends AccountSettingsAction * * @return nothing */ - function showDesignForm($design) { - $this->elementStart('form', array('method' => 'post', 'enctype' => 'multipart/form-data', 'id' => 'form_settings_design', @@ -98,14 +94,18 @@ class DesignSettingsAction extends AccountSettingsAction $this->elementStart('fieldset', array('id' => 'settings_design_background-image')); + // TRANS: Fieldset legend on profile design page. $this->element('legend', null, _('Change background image')); $this->elementStart('ul', 'form_data'); $this->elementStart('li'); $this->element('label', array('for' => 'design_background-image_file'), + // TRANS: Label in form on profile design page. + // TRANS: Field contains file name on user's computer that could be that user's custom profile background image. _('Upload file')); $this->element('input', array('name' => 'design_background-image_file', 'type' => 'file', 'id' => 'design_background-image_file')); + // TRANS: Instructions for form on profile design page. $this->element('p', 'form_guide', _('You can upload your personal ' . 'background image. The maximum file size is 2MB.')); $this->element('input', array('name' => 'MAX_FILE_SIZE', @@ -115,7 +115,6 @@ class DesignSettingsAction extends AccountSettingsAction $this->elementEnd('li'); if (!empty($design->backgroundimage)) { - $this->elementStart('li', array('id' => 'design_background-image_onoff')); @@ -136,7 +135,8 @@ class DesignSettingsAction extends AccountSettingsAction $this->element('label', array('for' => 'design_background-image_on', 'class' => 'radio'), - _('On')); + // TRANS: Radio button on profile design page that will enable use of the uploaded profile image. + _m('RADIO','On')); $attrs = array('name' => 'design_background-image_onoff', 'type' => 'radio', @@ -152,12 +152,16 @@ class DesignSettingsAction extends AccountSettingsAction $this->element('label', array('for' => 'design_background-image_off', 'class' => 'radio'), - _('Off')); + // TRANS: Radio button on profile design page that will disable use of the uploaded profile image. + _m('RADIO','Off')); + // TRANS: Form guide for a set of radio buttons on the profile design page that will enable or disable + // TRANS: use of the uploaded profile image. $this->element('p', 'form_guide', _('Turn background image on or off.')); $this->elementEnd('li'); $this->elementStart('li'); $this->checkbox('design_background-image_repeat', + // TRANS: Checkbox label on profile design page that will cause the profile image to be tiled. _('Tile background image'), ($design->disposition & BACKGROUND_TILE) ? true : false); $this->elementEnd('li'); @@ -167,14 +171,15 @@ class DesignSettingsAction extends AccountSettingsAction $this->elementEnd('fieldset'); $this->elementStart('fieldset', array('id' => 'settings_design_color')); + // TRANS: Fieldset legend on profile design page to change profile page colours. $this->element('legend', null, _('Change colours')); $this->elementStart('ul', 'form_data'); try { - $bgcolor = new WebColor($design->backgroundcolor); $this->elementStart('li'); + // TRANS: Label on profile design page for setting a profile page background colour. $this->element('label', array('for' => 'swatch-1'), _('Background')); $this->element('input', array('name' => 'design_background', 'type' => 'text', @@ -188,6 +193,7 @@ class DesignSettingsAction extends AccountSettingsAction $ccolor = new WebColor($design->contentcolor); $this->elementStart('li'); + // TRANS: Label on profile design page for setting a profile page content colour. $this->element('label', array('for' => 'swatch-2'), _('Content')); $this->element('input', array('name' => 'design_content', 'type' => 'text', @@ -201,6 +207,7 @@ class DesignSettingsAction extends AccountSettingsAction $sbcolor = new WebColor($design->sidebarcolor); $this->elementStart('li'); + // TRANS: Label on profile design page for setting a profile page sidebar colour. $this->element('label', array('for' => 'swatch-3'), _('Sidebar')); $this->element('input', array('name' => 'design_sidebar', 'type' => 'text', @@ -214,6 +221,7 @@ class DesignSettingsAction extends AccountSettingsAction $tcolor = new WebColor($design->textcolor); $this->elementStart('li'); + // TRANS: Label on profile design page for setting a profile page text colour. $this->element('label', array('for' => 'swatch-4'), _('Text')); $this->element('input', array('name' => 'design_text', 'type' => 'text', @@ -227,6 +235,7 @@ class DesignSettingsAction extends AccountSettingsAction $lcolor = new WebColor($design->linkcolor); $this->elementStart('li'); + // TRANS: Label on profile design page for setting a profile page links colour. $this->element('label', array('for' => 'swatch-5'), _('Links')); $this->element('input', array('name' => 'design_links', 'type' => 'text', @@ -244,16 +253,22 @@ class DesignSettingsAction extends AccountSettingsAction $this->elementEnd('ul'); $this->elementEnd('fieldset'); + // TRANS: Button text on profile design page to immediately reset all colour settings to default. $this->submit('defaults', _('Use defaults'), 'submit form_action-default', + // TRANS: Title for button on profile design page to reset all colour settings to default. 'defaults', _('Restore default designs')); $this->element('input', array('id' => 'settings_design_reset', 'type' => 'reset', - 'value' => 'Reset', + // TRANS: Button text on profile design page to reset all colour settings to default without saving. + 'value' => _m('BUTTON','Reset'), 'class' => 'submit form_action-primary', + // TRANS: Title for button on profile design page to reset all colour settings to default without saving. 'title' => _('Reset back to default'))); - $this->submit('save', _('Save'), 'submit form_action-secondary', + // TRANS: Button text on profile design page to save settings. + $this->submit('save', _m('BUTTON','Save'), 'submit form_action-secondary', + // TRANS: Title for button on profile design page to save settings. 'save', _('Save design')); $this->elementEnd('fieldset'); @@ -268,7 +283,6 @@ class DesignSettingsAction extends AccountSettingsAction * * @return void */ - function handlePost() { if ($_SERVER['REQUEST_METHOD'] == 'POST') { @@ -280,8 +294,10 @@ class DesignSettingsAction extends AccountSettingsAction && empty($_POST) && ($_SERVER['CONTENT_LENGTH'] > 0) ) { - $msg = _('The server was unable to handle that much POST ' . - 'data (%s bytes) due to its current configuration.'); + // TRANS: Form validation error in design settings form. POST should remain untranslated. + $msg = _m('The server was unable to handle that much POST data (%s byte) due to its current configuration.', + 'The server was unable to handle that much POST data (%s bytes) due to its current configuration.', + intval($_SERVER['CONTENT_LENGTH'])); $this->showForm(sprintf($msg, $_SERVER['CONTENT_LENGTH'])); return; @@ -301,6 +317,7 @@ class DesignSettingsAction extends AccountSettingsAction } else if ($this->arg('defaults')) { $this->restoreDefaults(); } else { + // TRANS: Unknown form validation error in design settings form. $this->showForm(_('Unexpected form submission.')); } } @@ -310,7 +327,6 @@ class DesignSettingsAction extends AccountSettingsAction * * @return void */ - function showStylesheets() { parent::showStylesheets(); @@ -322,7 +338,6 @@ class DesignSettingsAction extends AccountSettingsAction * * @return void */ - function showScripts() { parent::showScripts(); @@ -340,10 +355,8 @@ class DesignSettingsAction extends AccountSettingsAction * * @return nothing */ - function saveBackgroundImage($design) { - // Now that we have a Design ID we can add a file to the design. // XXX: This is an additional DB hit, but figured having the image // associated with the Design rather than the User was worth @@ -388,6 +401,7 @@ class DesignSettingsAction extends AccountSettingsAction if ($result === false) { common_log_db_error($design, 'UPDATE', __FILE__); + // TRANS: Error message displayed if design settings could not be saved. $this->showForm(_('Couldn\'t update your design.')); return; } @@ -399,7 +413,6 @@ class DesignSettingsAction extends AccountSettingsAction * * @return nothing */ - function restoreDefaults() { $design = $this->getWorkingDesign(); @@ -410,12 +423,13 @@ class DesignSettingsAction extends AccountSettingsAction if ($result === false) { common_log_db_error($design, 'DELETE', __FILE__); + // TRANS: Error message displayed if design settings could not be saved after clicking "Use defaults". $this->showForm(_('Couldn\'t update your design.')); return; } } + // TRANS: Success message displayed if design settings were saved after clicking "Use defaults". $this->showForm(_('Design defaults restored.'), true); } - } diff --git a/lib/feedlist.php b/lib/feedlist.php index 076576028..bbe66b2e7 100644 --- a/lib/feedlist.php +++ b/lib/feedlist.php @@ -62,6 +62,7 @@ class FeedList extends Widget if (!empty($feeds)) { $this->out->elementStart('div', array('id' => 'export_data', 'class' => 'section')); + // TRANS: Header for feed links (h2). $this->out->element('h2', null, _('Feeds')); $this->out->elementStart('ul', array('class' => 'xoxo')); diff --git a/lib/groupeditform.php b/lib/groupeditform.php index 433f6a138..cc25f0688 100644 --- a/lib/groupeditform.php +++ b/lib/groupeditform.php @@ -160,14 +160,17 @@ class GroupEditForm extends Form $this->out->elementStart('li'); $this->out->input('homepage', _('Homepage'), ($this->out->arg('homepage')) ? $this->out->arg('homepage') : $homepage, - _('URL of the homepage or blog of the group or topic')); + _('URL of the homepage or blog of the group or topic.')); $this->out->elementEnd('li'); $this->out->elementStart('li'); $desclimit = User_group::maxDescription(); if ($desclimit == 0) { $descinstr = _('Describe the group or topic'); } else { - $descinstr = sprintf(_('Describe the group or topic in %d characters'), $desclimit); + $descinstr = sprintf(_m('Describe the group or topic in %d character or less', + 'Describe the group or topic in %d characters or less', + $desclimit), + $desclimit); } $this->out->textarea('description', _('Description'), ($this->out->arg('description')) ? $this->out->arg('description') : $description, @@ -176,7 +179,7 @@ class GroupEditForm extends Form $this->out->elementStart('li'); $this->out->input('location', _('Location'), ($this->out->arg('location')) ? $this->out->arg('location') : $location, - _('Location for the group, if any, like "City, State (or Region), Country"')); + _('Location for the group, if any, like "City, State (or Region), Country".')); $this->out->elementEnd('li'); if (common_config('group', 'maxaliases') > 0) { $aliases = (empty($this->group)) ? array() : $this->group->getAliases(); @@ -184,7 +187,9 @@ class GroupEditForm extends Form $this->out->input('aliases', _('Aliases'), ($this->out->arg('aliases')) ? $this->out->arg('aliases') : (!empty($aliases)) ? implode(' ', $aliases) : '', - sprintf(_('Extra nicknames for the group, comma- or space- separated, max %d'), + sprintf(_m('Extra nicknames for the group, separated with commas or spaces. Maximum %d alias allowed.', + 'Extra nicknames for the group, separated with commas or spaces. Maximum %d aliases allowed.', + common_config('group', 'maxaliases')), common_config('group', 'maxaliases')));; $this->out->elementEnd('li'); } @@ -199,6 +204,6 @@ class GroupEditForm extends Form function formActions() { - $this->out->submit('submit', _('Save')); + $this->out->submit('submit', _m('BUTTON','Save')); } } diff --git a/lib/htmloutputter.php b/lib/htmloutputter.php index 42bff4490..b341d1495 100644 --- a/lib/htmloutputter.php +++ b/lib/htmloutputter.php @@ -119,9 +119,16 @@ class HTMLOutputter extends XMLOutputter $language = $this->getLanguage(); - $this->elementStart('html', array('xmlns' => 'http://www.w3.org/1999/xhtml', - 'xml:lang' => $language, - 'lang' => $language)); + $attrs = array( + 'xmlns' => 'http://www.w3.org/1999/xhtml', + 'xml:lang' => $language, + 'lang' => $language + ); + + if (Event::handle('StartHtmlElement', array($this, &$attrs))) { + $this->elementStart('html', $attrs); + Event::handle('EndHtmlElement', array($this, &$attrs)); + } } function getLanguage() diff --git a/lib/imagefile.php b/lib/imagefile.php index e47287741..159deead6 100644 --- a/lib/imagefile.php +++ b/lib/imagefile.php @@ -85,6 +85,8 @@ class ImageFile break; case UPLOAD_ERR_INI_SIZE: case UPLOAD_ERR_FORM_SIZE: + // TRANS: Exception thrown when too large a file is uploaded. + // TRANS: %s is the maximum file size, for example "500b", "10kB" or "2MB". throw new Exception(sprintf(_('That file is too big. The maximum file size is %s.'), ImageFile::maxFileSize())); return; @@ -113,10 +115,46 @@ class ImageFile return new ImageFile(null, $_FILES[$param]['tmp_name']); } + /** + * Compat interface for old code generating avatar thumbnails... + * Saves the scaled file directly into the avatar area. + * + * @param int $size target width & height -- must be square + * @param int $x (default 0) upper-left corner to crop from + * @param int $y (default 0) upper-left corner to crop from + * @param int $w (default full) width of image area to crop + * @param int $h (default full) height of image area to crop + * @return string filename + */ function resize($size, $x = 0, $y = 0, $w = null, $h = null) { + $targetType = $this->preferredType($this->type); + $outname = Avatar::filename($this->id, + image_type_to_extension($targetType), + $size, + common_timestamp()); + $outpath = Avatar::path($outname); + $this->resizeTo($outpath, $size, $size, $x, $y, $w, $h); + return $outname; + } + + /** + * Create and save a thumbnail image. + * + * @param string $outpath + * @param int $width target width + * @param int $height target height + * @param int $x (default 0) upper-left corner to crop from + * @param int $y (default 0) upper-left corner to crop from + * @param int $w (default full) width of image area to crop + * @param int $h (default full) height of image area to crop + * @return string full local filesystem filename + */ + function resizeTo($outpath, $width, $height, $x=0, $y=0, $w=null, $h=null) + { $w = ($w === null) ? $this->width:$w; $h = ($h === null) ? $this->height:$h; + $targetType = $this->preferredType($this->type); if (!file_exists($this->filepath)) { throw new Exception(_('Lost our file.')); @@ -124,20 +162,16 @@ class ImageFile } // Don't crop/scale if it isn't necessary - if ($size === $this->width - && $size === $this->height + if ($width === $this->width + && $height === $this->height && $x === 0 && $y === 0 && $w === $this->width - && $h === $this->height) { + && $h === $this->height + && $this->type == $targetType) { - $outname = Avatar::filename($this->id, - image_type_to_extension($this->type), - $size, - common_timestamp()); - $outpath = Avatar::path($outname); @copy($this->filepath, $outpath); - return $outname; + return $outpath; } switch ($this->type) { @@ -164,7 +198,7 @@ class ImageFile return; } - $image_dest = imagecreatetruecolor($size, $size); + $image_dest = imagecreatetruecolor($width, $height); if ($this->type == IMAGETYPE_GIF || $this->type == IMAGETYPE_PNG || $this->type == IMAGETYPE_BMP) { @@ -187,30 +221,9 @@ class ImageFile } } - imagecopyresampled($image_dest, $image_src, 0, 0, $x, $y, $size, $size, $w, $h); - - if($this->type == IMAGETYPE_BMP) { - //we don't want to save BMP... it's an inefficient, rare, antiquated format - //save png instead - $this->type = IMAGETYPE_PNG; - } else if($this->type == IMAGETYPE_WBMP) { - //we don't want to save WBMP... 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_XBM) { - //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; - } - - $outname = Avatar::filename($this->id, - image_type_to_extension($this->type), - $size, - common_timestamp()); - - $outpath = Avatar::path($outname); + imagecopyresampled($image_dest, $image_src, 0, 0, $x, $y, $width, $height, $w, $h); - switch ($this->type) { + switch ($targetType) { case IMAGETYPE_GIF: imagegif($image_dest, $outpath); break; @@ -228,7 +241,31 @@ class ImageFile imagedestroy($image_src); imagedestroy($image_dest); - return $outname; + return $outpath; + } + + /** + * Several obscure file types should be normalized to PNG on resize. + * + * @param int $type + * @return int + */ + function preferredType($type) + { + if($type == IMAGETYPE_BMP) { + //we don't want to save BMP... it's an inefficient, rare, antiquated format + //save png instead + return IMAGETYPE_PNG; + } else if($type == IMAGETYPE_WBMP) { + //we don't want to save WBMP... it's a rare format that we can't guarantee clients will support + //save png instead + return IMAGETYPE_PNG; + } else if($type == IMAGETYPE_XBM) { + //we don't want to save XBM... it's a rare format that we can't guarantee clients will support + //save png instead + return IMAGETYPE_PNG; + } + return $type; } function unlink() @@ -241,11 +278,16 @@ class ImageFile $value = ImageFile::maxFileSizeInt(); if ($value > 1024 * 1024) { - return ($value/(1024*1024)) . _('MB'); + $value = $value/(1024*1024); + // TRANS: Number of megabytes. %d is the number. + return sprintf(_m('%dMB','%dMB',$value),$value); } else if ($value > 1024) { - return ($value/(1024)) . _('kB'); + $value = $value/1024; + // TRANS: Number of kilobytes. %d is the number. + return sprintf(_m('%dkB','%dkB',$value),$value); } else { - return $value; + // TRANS: Number of bytes. %d is the number. + return sprintf(_m('%dB','%dB',$value),$value); } } diff --git a/lib/inlineattachmentlist.php b/lib/inlineattachmentlist.php new file mode 100644 index 000000000..de5008e87 --- /dev/null +++ b/lib/inlineattachmentlist.php @@ -0,0 +1,108 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * widget for displaying notice attachments thumbnails + * + * 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 UI + * @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')) { + exit(1); +} + +class InlineAttachmentList extends AttachmentList +{ + function showListStart() + { + $this->out->elementStart('div', array('class' => 'entry-content thumbnails')); + } + + function showListEnd() + { + $this->out->elementEnd('div'); + } + + /** + * returns a new list item for the current attachment + * + * @param File $notice the current attachment + * + * @return ListItem a list item for displaying the attachment + */ + function newListItem($attachment) + { + return new InlineAttachmentListItem($attachment, $this->out); + } +} + +class InlineAttachmentListItem extends AttachmentListItem +{ + function show() + { + if ($this->attachment->isEnclosure()) { + parent::show(); + } + } + + function showLink() { + $this->out->elementStart('a', $this->linkAttr()); + $this->showRepresentation(); + $this->out->elementEnd('a'); + } + + /** + * Build HTML attributes for the link + * @return array + */ + function linkAttr() + { + $attr = parent::linkAttr(); + $attr['class'] = 'attachment-thumbnail'; + return $attr; + } + + /** + * start a single notice. + * + * @return void + */ + function showStart() + { + // XXX: RDFa + // TODO: add notice_type class e.g., notice_video, notice_image + $this->out->elementStart('span', array('class' => 'inline-attachment')); + } + + /** + * finish the notice + * + * Close the last elements in the notice list item + * + * @return void + */ + function showEnd() + { + $this->out->elementEnd('span'); + } +} diff --git a/lib/mail.php b/lib/mail.php index 30d743848..dd6a1a366 100644 --- a/lib/mail.php +++ b/lib/mail.php @@ -593,6 +593,10 @@ function mail_notify_fave($other, $user, $notice) } $profile = $user->getProfile(); + if ($other->hasBlocked($profile)) { + // If the author has blocked us, don't spam them with a notification. + return; + } $bestname = $profile->getBestName(); diff --git a/lib/mailhandler.php b/lib/mailhandler.php index e9ba41839..459657ffe 100644 --- a/lib/mailhandler.php +++ b/lib/mailhandler.php @@ -55,10 +55,11 @@ class MailHandler return true; } $msg = $this->cleanup_msg($msg); - $msg = common_shorten_links($msg); + $msg = $user->shortenLinks($msg); if (Notice::contentTooLong($msg)) { - $this->error($from, sprintf(_('That\'s too long. '. - 'Max notice size is %d chars.'), + $this->error($from, sprintf(_('That\'s too long. Maximum notice size is %d character.', + 'That\'s too long. Maximum notice size is %d characters.', + Notice::maxContent()), Notice::maxContent())); } diff --git a/lib/mediafile.php b/lib/mediafile.php index 23338cc0e..a41d7c76b 100644 --- a/lib/mediafile.php +++ b/lib/mediafile.php @@ -48,11 +48,14 @@ class MediaFile { if ($user == null) { $this->user = common_current_user(); + } else { + $this->user = $user; } $this->filename = $filename; $this->mimetype = $mimetype; $this->fileRecord = $this->storeFile(); + $this->thumbnailRecord = $this->storeThumbnail(); $this->fileurl = common_local_url('attachment', array('attachment' => $this->fileRecord->id)); @@ -102,6 +105,52 @@ class MediaFile return $file; } + /** + * Generate and store a thumbnail image for the uploaded file, if applicable. + * + * @return File_thumbnail or null + */ + function storeThumbnail() + { + if (substr($this->mimetype, 0, strlen('image/')) != 'image/') { + // @fixme video thumbs would be nice! + return null; + } + try { + $image = new ImageFile($this->fileRecord->id, + File::path($this->filename)); + } catch (Exception $e) { + // Unsupported image type. + return null; + } + + $outname = File::filename($this->user->getProfile(), 'thumb-' . $this->filename, $this->mimetype); + $outpath = File::path($outname); + + $maxWidth = common_config('attachments', 'thumb_width'); + $maxHeight = common_config('attachments', 'thumb_height'); + list($width, $height) = $this->scaleToFit($image->width, $image->height, $maxWidth, $maxHeight); + + $image->resizeTo($outpath, $width, $height); + File_thumbnail::saveThumbnail($this->fileRecord->id, + File::url($outname), + $width, + $height); + } + + function scaleToFit($width, $height, $maxWidth, $maxHeight) + { + $aspect = $maxWidth / $maxHeight; + $w1 = $maxWidth; + $h1 = intval($height * $maxWidth / $width); + if ($h1 > $maxHeight) { + $w2 = intval($width * $maxHeight / $height); + $h2 = $maxHeight; + return array($w2, $h2); + } + return array($w1, $h1); + } + function rememberFile($file, $short) { $this->maybeAddRedir($file->id, $short); @@ -278,6 +327,9 @@ class MediaFile static function getUploadedFileType($f, $originalFilename=false) { require_once 'MIME/Type.php'; require_once 'MIME/Type/Extension.php'; + + // We have to disable auto handling of PEAR errors + PEAR::staticPushErrorHandling(PEAR_ERROR_RETURN); $mte = new MIME_Type_Extension(); $cmd = &PEAR::getStaticProperty('MIME_Type', 'fileCmd'); @@ -330,6 +382,8 @@ class MediaFile } } if ($supported === true || in_array($filetype, $supported)) { + // Restore PEAR error handlers for our DB code... + PEAR::staticPopErrorHandling(); return $filetype; } $media = MIME_Type::getMedia($filetype); @@ -344,6 +398,8 @@ class MediaFile // TRANS: %s is the file type that was denied. $hint = sprintf(_('"%s" is not a supported file type on this server.'), $filetype); } + // Restore PEAR error handlers for our DB code... + PEAR::staticPopErrorHandling(); throw new ClientException($hint); } diff --git a/lib/nickname.php b/lib/nickname.php new file mode 100644 index 000000000..a0c9378cd --- /dev/null +++ b/lib/nickname.php @@ -0,0 +1,176 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2008, 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/>. + */ + +class Nickname +{ + /** + * Regex fragment for pulling an arbitrarily-formated nickname. + * + * Not guaranteed to be valid after normalization; run the string through + * Nickname::normalize() to get the canonical form, or Nickname::isValid() + * if you just need to check if it's properly formatted. + * + * This and CANONICAL_FMT replace the old NICKNAME_FMT, but be aware + * that these should not be enclosed in []s. + */ + const DISPLAY_FMT = '[0-9a-zA-Z_]+'; + + /** + * Regex fragment for checking a canonical nickname. + * + * Any non-matching string is not a valid canonical/normalized nickname. + * Matching strings are valid and canonical form, but may still be + * unavailable for registration due to blacklisting et. + * + * Only the canonical forms should be stored as keys in the database; + * there are multiple possible denormalized forms for each valid + * canonical-form name. + * + * This and DISPLAY_FMT replace the old NICKNAME_FMT, but be aware + * that these should not be enclosed in []s. + */ + const CANONICAL_FMT = '[0-9a-z]{1,64}'; + + /** + * Maximum number of characters in a canonical-form nickname. + */ + const MAX_LEN = 64; + + /** + * Nice simple check of whether the given string is a valid input nickname, + * which can be normalized into an internally canonical form. + * + * Note that valid nicknames may be in use or reserved. + * + * @param string $str + * @return boolean + */ + public static function isValid($str) + { + try { + self::normalize($str); + return true; + } catch (NicknameException $e) { + return false; + } + } + + /** + * Validate an input nickname string, and normalize it to its canonical form. + * The canonical form will be returned, or an exception thrown if invalid. + * + * @param string $str + * @return string Normalized canonical form of $str + * + * @throws NicknameException (base class) + * @throws NicknameInvalidException + * @throws NicknameEmptyException + * @throws NicknameTooLongException + */ + public static function normalize($str) + { + $str = trim($str); + $str = str_replace('_', '', $str); + $str = mb_strtolower($str); + + $len = mb_strlen($str); + if ($len < 1) { + throw new NicknameEmptyException(); + } else if ($len > self::MAX_LEN) { + throw new NicknameTooLongException(); + } + if (!self::isCanonical($str)) { + throw new NicknameInvalidException(); + } + + return $str; + } + + /** + * Is the given string a valid canonical nickname form? + * + * @param string $str + * @return boolean + */ + public static function isCanonical($str) + { + return preg_match('/^(?:' . self::CANONICAL_FMT . ')$/', $str); + } +} + +class NicknameException extends ClientException +{ + function __construct($msg=null, $code=400) + { + if ($msg === null) { + $msg = $this->defaultMessage(); + } + parent::__construct($msg, $code); + } + + /** + * Default localized message for this type of exception. + * @return string + */ + protected function defaultMessage() + { + return null; + } +} + +class NicknameInvalidException extends NicknameException { + /** + * Default localized message for this type of exception. + * @return string + */ + protected function defaultMessage() + { + // TRANS: Validation error in form for registration, profile and group settings, etc. + return _('Nickname must have only lowercase letters and numbers and no spaces.'); + } +} + +class NicknameEmptyException extends NicknameException +{ + /** + * Default localized message for this type of exception. + * @return string + */ + protected function defaultMessage() + { + // TRANS: Validation error in form for registration, profile and group settings, etc. + return _('Nickname cannot be empty.'); + } +} + +class NicknameTooLongException extends NicknameInvalidException +{ + /** + * Default localized message for this type of exception. + * @return string + */ + protected function defaultMessage() + { + // TRANS: Validation error in form for registration, profile and group settings, etc. + return sprintf(_m('Nickname cannot be more than %d character long.', + 'Nickname cannot be more than %d characters long.', + Nickname::MAX_LEN), + Nickname::MAX_LEN); + } +} diff --git a/lib/noticelist.php b/lib/noticelist.php index df1533980..c6f964662 100644 --- a/lib/noticelist.php +++ b/lib/noticelist.php @@ -208,6 +208,7 @@ class NoticeListItem extends Widget $this->showStart(); if (Event::handle('StartShowNoticeItem', array($this))) { $this->showNotice(); + $this->showNoticeAttachments(); $this->showNoticeInfo(); $this->showNoticeOptions(); Event::handle('EndShowNoticeItem', array($this)); @@ -306,7 +307,7 @@ class NoticeListItem extends Widget $attrs = array('href' => $this->profile->profileurl, 'class' => 'url'); if (!empty($this->profile->fullname)) { - $attrs['title'] = $this->profile->fullname . ' (' . $this->profile->nickname . ')'; + $attrs['title'] = $this->profile->getFancyName(); } $this->out->elementStart('a', $attrs); $this->showAvatar(); @@ -327,11 +328,8 @@ class NoticeListItem extends Widget function showAvatar() { - if ('shownotice' === $this->out->trimmed('action')) { - $avatar_size = AVATAR_PROFILE_SIZE; - } else { - $avatar_size = AVATAR_STREAM_SIZE; - } + $avatar_size = AVATAR_STREAM_SIZE; + $avatar = $this->profile->getAvatar($avatar_size); $this->out->element('img', array('src' => ($avatar) ? @@ -386,6 +384,13 @@ class NoticeListItem extends Widget $this->out->elementEnd('p'); } + function showNoticeAttachments() { + if (common_config('attachments', 'show_thumbs')) { + $al = new InlineAttachmentList($this->notice, $this->out); + $al->show(); + } + } + /** * show the link to the main page for the notice * diff --git a/lib/oembedhelper.php b/lib/oembedhelper.php new file mode 100644 index 000000000..84cf10586 --- /dev/null +++ b/lib/oembedhelper.php @@ -0,0 +1,318 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2008-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/>. + */ + +if (!defined('STATUSNET')) { + exit(1); +} + + +/** + * Utility class to wrap basic oEmbed lookups. + * + * Blacklisted hosts will use an alternate lookup method: + * - Twitpic + * + * Whitelisted hosts will use known oEmbed API endpoints: + * - Flickr, YFrog + * + * Sites that provide discovery links will use them directly; a bug + * in use of discovery links with query strings is worked around. + * + * Others will fall back to oohembed (unless disabled). + * The API endpoint can be configured or disabled through config + * as 'oohembed'/'endpoint'. + */ +class oEmbedHelper +{ + protected static $apiMap = array( + 'flickr.com' => 'http://www.flickr.com/services/oembed/', + 'yfrog.com' => 'http://www.yfrog.com/api/oembed', + ); + protected static $functionMap = array( + 'twitpic.com' => 'oEmbedHelper::twitPic', + ); + + /** + * Perform or fake an oEmbed lookup for the given resource. + * + * Some known hosts are whitelisted with API endpoints where we + * know they exist but autodiscovery data isn't available. + * If autodiscovery links are missing and we don't recognize the + * host, we'll pass it to oohembed.com's public service which + * will either proxy or fake info on a lot of sites. + * + * A few hosts are blacklisted due to known problems with oohembed, + * in which case we'll look up the info another way and return + * equivalent data. + * + * Throws exceptions on failure. + * + * @param string $url + * @param array $params + * @return object + */ + public static function getObject($url, $params=array()) + { + $host = parse_url($url, PHP_URL_HOST); + if (substr($host, 0, 4) == 'www.') { + $host = substr($host, 4); + } + + // Blacklist: systems with no oEmbed API of their own, which are + // either missing from or broken on oohembed.com's proxy. + // we know how to look data up in another way... + if (array_key_exists($host, self::$functionMap)) { + $func = self::$functionMap[$host]; + return call_user_func($func, $url, $params); + } + + // Whitelist: known API endpoints for sites that don't provide discovery... + if (array_key_exists($host, self::$apiMap)) { + $api = self::$apiMap[$host]; + } else { + try { + $api = self::discover($url); + } catch (Exception $e) { + // Discovery failed... fall back to oohembed if enabled. + $oohembed = common_config('oohembed', 'endpoint'); + if ($oohembed) { + $api = $oohembed; + } else { + throw $e; + } + } + } + return self::getObjectFrom($api, $url, $params); + } + + /** + * Perform basic discovery. + * @return string + */ + static function discover($url) + { + // @fixme ideally skip this for non-HTML stuff! + $body = self::http($url); + return self::discoverFromHTML($url, $body); + } + + /** + * Partially ripped from OStatus' FeedDiscovery class. + * + * @param string $url source URL, used to resolve relative links + * @param string $body HTML body text + * @return mixed string with URL or false if no target found + */ + static function discoverFromHTML($url, $body) + { + // DOMDocument::loadHTML may throw warnings on unrecognized elements, + // and notices on unrecognized namespaces. + $old = error_reporting(error_reporting() & ~(E_WARNING | E_NOTICE)); + $dom = new DOMDocument(); + $ok = $dom->loadHTML($body); + error_reporting($old); + + if (!$ok) { + throw new oEmbedHelper_BadHtmlException(); + } + + // Ok... now on to the links! + $feeds = array( + 'application/json+oembed' => false, + ); + + $nodes = $dom->getElementsByTagName('link'); + for ($i = 0; $i < $nodes->length; $i++) { + $node = $nodes->item($i); + if ($node->hasAttributes()) { + $rel = $node->attributes->getNamedItem('rel'); + $type = $node->attributes->getNamedItem('type'); + $href = $node->attributes->getNamedItem('href'); + if ($rel && $type && $href) { + $rel = array_filter(explode(" ", $rel->value)); + $type = trim($type->value); + $href = trim($href->value); + + if (in_array('alternate', $rel) && array_key_exists($type, $feeds) && empty($feeds[$type])) { + // Save the first feed found of each type... + $feeds[$type] = $href; + } + } + } + } + + // Return the highest-priority feed found + foreach ($feeds as $type => $url) { + if ($url) { + return $url; + } + } + + throw new oEmbedHelper_DiscoveryException(); + } + + /** + * Actually do an oEmbed lookup to a particular API endpoint. + * + * @param string $api oEmbed API endpoint URL + * @param string $url target URL to look up info about + * @param array $params + * @return object + */ + static function getObjectFrom($api, $url, $params=array()) + { + $params['url'] = $url; + $params['format'] = 'json'; + $data = self::json($api, $params); + return self::normalize($data); + } + + /** + * Normalize oEmbed format. + * + * @param object $orig + * @return object + */ + static function normalize($orig) + { + $data = clone($orig); + + if (empty($data->type)) { + throw new Exception('Invalid oEmbed data: no type field.'); + } + + if ($data->type == 'image') { + // YFrog does this. + $data->type = 'photo'; + } + + if (isset($data->thumbnail_url)) { + if (!isset($data->thumbnail_width)) { + // !?!?! + $data->thumbnail_width = common_config('attachments', 'thumb_width'); + $data->thumbnail_height = common_config('attachments', 'thumb_height'); + } + } + + return $data; + } + + /** + * Using a local function for twitpic lookups, as oohembed's adapter + * doesn't return a valid result: + * http://code.google.com/p/oohembed/issues/detail?id=19 + * + * This code fetches metadata from Twitpic's own API, and attempts + * to guess proper thumbnail size from the original's size. + * + * @todo respect maxwidth and maxheight params + * + * @param string $url + * @param array $params + * @return object + */ + static function twitPic($url, $params=array()) + { + $matches = array(); + if (preg_match('!twitpic\.com/(\w+)!', $url, $matches)) { + $id = $matches[1]; + } else { + throw new Exception("Invalid twitpic URL"); + } + + // Grab metadata from twitpic's API... + // http://dev.twitpic.com/docs/2/media_show + $data = self::json('http://api.twitpic.com/2/media/show.json', + array('id' => $id)); + $oembed = (object)array('type' => 'photo', + 'url' => 'http://twitpic.com/show/full/' . $data->short_id, + 'width' => $data->width, + 'height' => $data->height); + if (!empty($data->message)) { + $oembed->title = $data->message; + } + + // Thumbnail is cropped and scaled to 150x150 box: + // http://dev.twitpic.com/docs/thumbnails/ + $thumbSize = 150; + $oembed->thumbnail_url = 'http://twitpic.com/show/thumb/' . $data->short_id; + $oembed->thumbnail_width = $thumbSize; + $oembed->thumbnail_height = $thumbSize; + + return $oembed; + } + + /** + * Fetch some URL and return JSON data. + * + * @param string $url + * @param array $params query-string params + * @return object + */ + static protected function json($url, $params=array()) + { + $data = self::http($url, $params); + return json_decode($data); + } + + /** + * Hit some web API and return data on success. + * @param string $url + * @param array $params + * @return string + */ + static protected function http($url, $params=array()) + { + $client = HTTPClient::start(); + if ($params) { + $query = http_build_query($params, null, '&'); + if (strpos($url, '?') === false) { + $url .= '?' . $query; + } else { + $url .= '&' . $query; + } + } + $response = $client->get($url); + if ($response->isOk()) { + return $response->getBody(); + } else { + throw new Exception('Bad HTTP response code: ' . $response->getStatus()); + } + } +} + +class oEmbedHelper_Exception extends Exception +{ +} + +class oEmbedHelper_BadHtmlException extends oEmbedHelper_Exception +{ + function __construct($previous=null) + { + return parent::__construct('Bad HTML in discovery data.', 0, $previous); + } +} + +class oEmbedHelper_DiscoveryException extends oEmbedHelper_Exception +{ + function __construct($previous=null) + { + return parent::__construct('No oEmbed discovery data.', 0, $previous); + } +} diff --git a/lib/personalgroupnav.php b/lib/personalgroupnav.php index 25db5baa9..1f543b897 100644 --- a/lib/personalgroupnav.php +++ b/lib/personalgroupnav.php @@ -87,8 +87,11 @@ class PersonalGroupNav extends Widget if ($nickname) { $user = User::staticGet('nickname', $nickname); $user_profile = $user->getProfile(); + $name = $user_profile->getBestName(); } else { + // @fixme can this happen? is this valid? $user_profile = false; + $name = $nickname; } $this->out->elementStart('ul', array('class' => 'nav')); @@ -97,22 +100,22 @@ class PersonalGroupNav extends Widget $this->out->menuItem(common_local_url('all', array('nickname' => $nickname)), _('Personal'), - sprintf(_('%s and friends'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), + sprintf(_('%s and friends'), $name), $action == 'all', 'nav_timeline_personal'); $this->out->menuItem(common_local_url('replies', array('nickname' => $nickname)), _('Replies'), - sprintf(_('Replies to %s'), (($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname)), + sprintf(_('Replies to %s'), $name), $action == 'replies', 'nav_timeline_replies'); $this->out->menuItem(common_local_url('showstream', array('nickname' => $nickname)), _('Profile'), - ($user_profile && $user_profile->fullname) ? $user_profile->fullname : $nickname, + $name, $action == 'showstream', 'nav_profile'); $this->out->menuItem(common_local_url('showfavorites', array('nickname' => $nickname)), _('Favorites'), - sprintf(_('%s\'s favorite notices'), ($user_profile) ? $user_profile->getBestName() : _('User')), + sprintf(_('%s\'s favorite notices'), ($user_profile) ? $name : _('User')), $action == 'showfavorites', 'nav_timeline_favorites'); $cur = common_current_user(); diff --git a/lib/ping.php b/lib/ping.php index abf1c4048..e1c7c748e 100644 --- a/lib/ping.php +++ b/lib/ping.php @@ -20,13 +20,12 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { exit(1); } function ping_broadcast_notice($notice) { + if ($notice->is_local != Notice::LOCAL_PUBLIC && $notice->is_local != Notice::LOCAL_NONPUBLIC) { + return true; + } - if ($notice->is_local != Notice::LOCAL_PUBLIC && $notice->is_local != Notice::LOCAL_NONPUBLIC) { - return true; - } - - # Array of servers, URL => type - $notify = common_config('ping', 'notify'); + # Array of servers, URL => type + $notify = common_config('ping', 'notify'); try { $profile = $notice->getProfile(); } catch (Exception $e) { @@ -35,21 +34,21 @@ function ping_broadcast_notice($notice) { common_log(LOG_ERR, "Exception getting notice profile: " . $e->getMessage()); return true; } - $tags = ping_notice_tags($notice); + $tags = ping_notice_tags($notice); - foreach ($notify as $notify_url => $type) { - switch ($type) { - case 'xmlrpc': - case 'extended': - $req = xmlrpc_encode_request('weblogUpdates.ping', - array($profile->nickname, # site name - common_local_url('showstream', - array('nickname' => $profile->nickname)), - common_local_url('shownotice', - array('notice' => $notice->id)), - common_local_url('userrss', - array('nickname' => $profile->nickname)), - $tags)); + foreach ($notify as $notify_url => $type) { + switch ($type) { + case 'xmlrpc': + case 'extended': + $req = xmlrpc_encode_request('weblogUpdates.ping', + array($profile->nickname, # site name + common_local_url('showstream', + array('nickname' => $profile->nickname)), + common_local_url('shownotice', + array('notice' => $notice->id)), + common_local_url('userrss', + array('nickname' => $profile->nickname)), + $tags)); $request = HTTPClient::start(); $request->setConfig('connect_timeout', common_config('ping', 'timeout')); @@ -79,9 +78,8 @@ function ping_broadcast_notice($notice) { "Ping success for $notify_url $notice->id"); } break; - - case 'get': - case 'post': + case 'get': + case 'post': $args = array('name' => $profile->nickname, 'url' => common_local_url('showstream', array('nickname' => $profile->nickname)), @@ -108,26 +106,25 @@ function ping_broadcast_notice($notice) { "'$result->body'"); } break; - - default: - common_log(LOG_WARNING, 'Unknown notify type for ' . $notify_url . ': ' . $type); + default: + common_log(LOG_WARNING, 'Unknown notify type for ' . $notify_url . ': ' . $type); } - } + } return true; } function ping_notice_tags($notice) { - $tag = new Notice_tag(); - $tag->notice_id = $notice->id; - $tags = array(); - if ($tag->find()) { - while ($tag->fetch()) { - $tags[] = $tag->tag; - } - $tag->free(); - unset($tag); - return implode('|', $tags); - } - return NULL; + $tag = new Notice_tag(); + $tag->notice_id = $notice->id; + $tags = array(); + if ($tag->find()) { + while ($tag->fetch()) { + $tags[] = $tag->tag; + } + $tag->free(); + unset($tag); + return implode('|', $tags); + } + return NULL; } diff --git a/lib/router.php b/lib/router.php index e4d5e5286..eff88bb46 100644 --- a/lib/router.php +++ b/lib/router.php @@ -220,6 +220,10 @@ class Router $m->connect('main/xrds', array('action' => 'publicxrds')); + $m->connect('.well-known/host-meta', + array('action' => 'hostmeta')); + $m->connect('main/xrd', + array('action' => 'userxrd')); // these take a code @@ -290,10 +294,10 @@ class Router $m->connect('notice/new', array('action' => 'newnotice')); $m->connect('notice/new?replyto=:replyto', array('action' => 'newnotice'), - array('replyto' => '[A-Za-z0-9_-]+')); + array('replyto' => Nickname::DISPLAY_FMT)); $m->connect('notice/new?replyto=:replyto&inreplyto=:inreplyto', array('action' => 'newnotice'), - array('replyto' => '[A-Za-z0-9_-]+'), + array('replyto' => Nickname::DISPLAY_FMT), array('inreplyto' => '[0-9]+')); $m->connect('notice/:notice/file', @@ -317,7 +321,7 @@ class Router array('id' => '[0-9]+')); $m->connect('message/new', array('action' => 'newmessage')); - $m->connect('message/new?to=:to', array('action' => 'newmessage'), array('to' => '[A-Za-z0-9_-]+')); + $m->connect('message/new?to=:to', array('action' => 'newmessage'), array('to' => Nickname::DISPLAY_FMT)); $m->connect('message/:message', array('action' => 'showmessage'), array('message' => '[0-9]+')); @@ -348,7 +352,7 @@ class Router foreach (array('edit', 'join', 'leave', 'delete') as $v) { $m->connect('group/:nickname/'.$v, array('action' => $v.'group'), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect('group/:id/id/'.$v, array('action' => $v.'group'), array('id' => '[0-9]+')); @@ -357,20 +361,20 @@ class Router foreach (array('members', 'logo', 'rss', 'designsettings') as $n) { $m->connect('group/:nickname/'.$n, array('action' => 'group'.$n), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); } $m->connect('group/:nickname/foaf', array('action' => 'foafgroup'), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect('group/:nickname/blocked', array('action' => 'blockedfromgroup'), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect('group/:nickname/makeadmin', array('action' => 'makeadmin'), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect('group/:id/id', array('action' => 'groupbyid'), @@ -378,7 +382,7 @@ class Router $m->connect('group/:nickname', array('action' => 'showgroup'), - array('nickname' => '[a-zA-Z0-9]+')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect('group/', array('action' => 'groups')); $m->connect('group', array('action' => 'groups')); @@ -399,7 +403,7 @@ class Router $m->connect('api/statuses/friends_timeline/:id.:format', array('action' => 'ApiTimelineFriends', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statuses/home_timeline.:format', @@ -408,7 +412,7 @@ class Router $m->connect('api/statuses/home_timeline/:id.:format', array('action' => 'ApiTimelineHome', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statuses/user_timeline.:format', @@ -417,7 +421,7 @@ class Router $m->connect('api/statuses/user_timeline/:id.:format', array('action' => 'ApiTimelineUser', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statuses/mentions.:format', @@ -426,7 +430,7 @@ class Router $m->connect('api/statuses/mentions/:id.:format', array('action' => 'ApiTimelineMentions', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statuses/replies.:format', @@ -435,7 +439,7 @@ class Router $m->connect('api/statuses/replies/:id.:format', array('action' => 'ApiTimelineMentions', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statuses/retweeted_by_me.:format', @@ -456,7 +460,7 @@ class Router $m->connect('api/statuses/friends/:id.:format', array('action' => 'ApiUserFriends', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statuses/followers.:format', @@ -465,17 +469,17 @@ class Router $m->connect('api/statuses/followers/:id.:format', array('action' => 'ApiUserFollowers', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statuses/show.:format', array('action' => 'ApiStatusesShow', - 'format' => '(xml|json)')); + 'format' => '(xml|json|atom)')); $m->connect('api/statuses/show/:id.:format', array('action' => 'ApiStatusesShow', 'id' => '[0-9]+', - 'format' => '(xml|json)')); + 'format' => '(xml|json|atom)')); $m->connect('api/statuses/update.:format', array('action' => 'ApiStatusesUpdate', @@ -508,7 +512,7 @@ class Router $m->connect('api/users/show/:id.:format', array('action' => 'ApiUserShow', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); // direct messages @@ -546,12 +550,12 @@ class Router $m->connect('api/friendships/create/:id.:format', array('action' => 'ApiFriendshipsCreate', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/friendships/destroy/:id.:format', array('action' => 'ApiFriendshipsDestroy', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); // Social graph @@ -608,17 +612,17 @@ class Router $m->connect('api/favorites/:id.:format', array('action' => 'ApiTimelineFavorites', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/favorites/create/:id.:format', array('action' => 'ApiFavoriteCreate', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/favorites/destroy/:id.:format', array('action' => 'ApiFavoriteDestroy', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); // blocks @@ -628,7 +632,7 @@ class Router $m->connect('api/blocks/create/:id.:format', array('action' => 'ApiBlockCreate', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/blocks/destroy.:format', @@ -637,7 +641,7 @@ class Router $m->connect('api/blocks/destroy/:id.:format', array('action' => 'ApiBlockDestroy', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); // help @@ -673,7 +677,7 @@ class Router $m->connect('api/statusnet/groups/timeline/:id.:format', array('action' => 'ApiTimelineGroup', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statusnet/groups/show.:format', @@ -682,12 +686,12 @@ class Router $m->connect('api/statusnet/groups/show/:id.:format', array('action' => 'ApiGroupShow', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statusnet/groups/join.:format', array('action' => 'ApiGroupJoin', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statusnet/groups/join/:id.:format', @@ -696,7 +700,7 @@ class Router $m->connect('api/statusnet/groups/leave.:format', array('action' => 'ApiGroupLeave', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statusnet/groups/leave/:id.:format', @@ -713,7 +717,7 @@ class Router $m->connect('api/statusnet/groups/list/:id.:format', array('action' => 'ApiGroupList', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json|rss|atom)')); $m->connect('api/statusnet/groups/list_all.:format', @@ -726,7 +730,7 @@ class Router $m->connect('api/statusnet/groups/membership/:id.:format', array('action' => 'ApiGroupMembership', - 'id' => '[a-zA-Z0-9]+', + 'id' => Nickname::DISPLAY_FMT, 'format' => '(xml|json)')); $m->connect('api/statusnet/groups/create.:format', @@ -757,6 +761,13 @@ class Router $m->connect('api/oauth/authorize', array('action' => 'ApiOauthAuthorize')); + $m->connect('api/statusnet/app/service/:id.xml', + array('action' => 'ApiAtomService', + 'id' => Nickname::DISPLAY_FMT)); + + $m->connect('api/statusnet/app/service.xml', + array('action' => 'ApiAtomService')); + // Admin $m->connect('admin/site', array('action' => 'siteadminpanel')); @@ -848,54 +859,54 @@ class Router 'replies', 'inbox', 'outbox', 'microsummary', 'hcard') as $a) { $m->connect(':nickname/'.$a, array('action' => $a), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); } foreach (array('subscriptions', 'subscribers') as $a) { $m->connect(':nickname/'.$a.'/:tag', array('action' => $a), array('tag' => '[a-zA-Z0-9]+', - 'nickname' => '[a-zA-Z0-9]{1,64}')); + 'nickname' => Nickname::DISPLAY_FMT)); } foreach (array('rss', 'groups') as $a) { $m->connect(':nickname/'.$a, array('action' => 'user'.$a), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); } foreach (array('all', 'replies', 'favorites') as $a) { $m->connect(':nickname/'.$a.'/rss', array('action' => $a.'rss'), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); } $m->connect(':nickname/favorites', array('action' => 'showfavorites'), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect(':nickname/avatar/:size', array('action' => 'avatarbynickname'), array('size' => '(original|96|48|24)', - 'nickname' => '[a-zA-Z0-9]{1,64}')); + 'nickname' => Nickname::DISPLAY_FMT)); $m->connect(':nickname/tag/:tag/rss', array('action' => 'userrss'), - array('nickname' => '[a-zA-Z0-9]{1,64}'), + array('nickname' => Nickname::DISPLAY_FMT), array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect(':nickname/tag/:tag', array('action' => 'showstream'), - array('nickname' => '[a-zA-Z0-9]{1,64}'), + array('nickname' => Nickname::DISPLAY_FMT), array('tag' => '[\pL\pN_\-\.]{1,64}')); $m->connect(':nickname/rsd.xml', array('action' => 'rsd'), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); $m->connect(':nickname', array('action' => 'showstream'), - array('nickname' => '[a-zA-Z0-9]{1,64}')); + array('nickname' => Nickname::DISPLAY_FMT)); } // user stuff diff --git a/lib/search_engines.php b/lib/search_engines.php index 332db3f89..19703e03f 100644 --- a/lib/search_engines.php +++ b/lib/search_engines.php @@ -52,10 +52,10 @@ class MySQLSearch extends SearchEngine { if ('profile' === $this->table) { $this->target->whereAdd('MATCH(nickname, fullname, location, bio, homepage) ' . - 'AGAINST (\''.addslashes($q).'\' IN BOOLEAN MODE)'); + 'AGAINST (\''.$this->target->escape($q).'\' IN BOOLEAN MODE)'); if (strtolower($q) != $q) { $this->target->whereAdd('MATCH(nickname, fullname, location, bio, homepage) ' . - 'AGAINST (\''.addslashes(strtolower($q)).'\' IN BOOLEAN MODE)', 'OR'); + 'AGAINST (\''.$this->target->escape(strtolower($q)).'\' IN BOOLEAN MODE)', 'OR'); } return true; } else if ('notice' === $this->table) { @@ -64,13 +64,13 @@ class MySQLSearch extends SearchEngine $this->target->whereAdd('notice.is_local != ' . Notice::GATEWAY); if (strtolower($q) != $q) { - $this->target->whereAdd("( MATCH(content) AGAINST ('" . addslashes($q) . + $this->target->whereAdd("( MATCH(content) AGAINST ('" . $this->target->escape($q) . "' IN BOOLEAN MODE)) OR ( MATCH(content) " . - "AGAINST ('" . addslashes(strtolower($q)) . + "AGAINST ('" . $this->target->escape(strtolower($q)) . "' IN BOOLEAN MODE))"); } else { $this->target->whereAdd('MATCH(content) ' . - 'AGAINST (\''.addslashes($q).'\' IN BOOLEAN MODE)'); + 'AGAINST (\''.$this->target->escape($q).'\' IN BOOLEAN MODE)'); } return true; @@ -89,9 +89,9 @@ class MySQLLikeSearch extends SearchEngine ' fullname LIKE "%%%1$s%%" OR '. ' location LIKE "%%%1$s%%" OR '. ' bio LIKE "%%%1$s%%" OR '. - ' homepage LIKE "%%%1$s%%")', addslashes($q)); + ' homepage LIKE "%%%1$s%%")', $this->target->escape($q, true)); } else if ('notice' === $this->table) { - $qry = sprintf('content LIKE "%%%1$s%%"', addslashes($q)); + $qry = sprintf('content LIKE "%%%1$s%%"', $this->target->escape($q, true)); } else { throw new ServerException('Unknown table: ' . $this->table); } @@ -107,12 +107,12 @@ class PGSearch extends SearchEngine function query($q) { if ('profile' === $this->table) { - return $this->target->whereAdd('textsearch @@ plainto_tsquery(\''.addslashes($q).'\')'); + return $this->target->whereAdd('textsearch @@ plainto_tsquery(\''.$this->target->escape($q).'\')'); } else if ('notice' === $this->table) { // XXX: We need to filter out gateway notices (notice.is_local = -2) --Zach - return $this->target->whereAdd('to_tsvector(\'english\', content) @@ plainto_tsquery(\''.addslashes($q).'\')'); + return $this->target->whereAdd('to_tsvector(\'english\', content) @@ plainto_tsquery(\''.$this->target->escape($q).'\')'); } else { throw new ServerException('Unknown table: ' . $this->table); } diff --git a/lib/searchaction.php b/lib/searchaction.php index 14c3ed016..6d7f46cd6 100644 --- a/lib/searchaction.php +++ b/lib/searchaction.php @@ -70,7 +70,6 @@ class SearchAction extends Action * @return void * @see SearchGroupNav */ - function showLocalNav() { $nav = new SearchGroupNav($this, $this->trimmed('q')); @@ -127,6 +126,7 @@ class SearchAction extends Action // TRANS: Used as a field label for the field where one or more keywords // TRANS: for searching can be entered. $this->input('q', _('Keyword(s)'), $q); + // TRANS: Button text for searching site. $this->submit('search', _m('BUTTON','Search')); $this->elementEnd('li'); $this->elementEnd('ul'); @@ -138,7 +138,7 @@ class SearchAction extends Action } function searchSuggestions($q) { - // @todo FIXME: This formatting does not make this string get picked up by gettext. + // @todo FIXME: i18n issue: This formatting does not make this string get picked up by gettext. // TRANS: Standard search suggestions shown when a search does not give any results. $message = _(<<<E_O_T * Make sure all words are spelled correctly. @@ -150,7 +150,7 @@ E_O_T ); if (!common_config('site', 'private')) { $qe = urlencode($q); - // @todo FIXME: This formatting does not make this string get picked up by gettext. + // @todo FIXME: i18n issue: This formatting does not make this string get picked up by gettext. // TRANS: Standard search suggestions shown when a search does not give any results. $message .= sprintf(_(<<<E_O_T diff --git a/lib/statusnet.php b/lib/statusnet.php index 33bf32b10..85b46bbb3 100644 --- a/lib/statusnet.php +++ b/lib/statusnet.php @@ -377,7 +377,11 @@ class StatusNet static function isHTTPS() { // There are some exceptions to this; add them here! - return !empty($_SERVER['HTTPS']); + if(empty($_SERVER['HTTPS'])) { + return false; + } else { + return $_SERVER['HTTPS'] !== 'off'; + } } } diff --git a/lib/theme.php b/lib/theme.php index 95b7c1de4..5caa046c2 100644 --- a/lib/theme.php +++ b/lib/theme.php @@ -51,7 +51,6 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ - class Theme { var $name = null; @@ -65,14 +64,14 @@ class Theme * * @param string $name Name of the theme; defaults to config value */ - function __construct($name=null) { if (empty($name)) { $name = common_config('site', 'theme'); } if (!self::validName($name)) { - throw new ServerException("Invalid theme name."); + // TRANS: Server exception displayed if a theme name was invalid. + throw new ServerException(_('Invalid theme name.')); } $this->name = $name; @@ -95,7 +94,6 @@ class Theme $fulldir = $instroot.'/'.$name; if (file_exists($fulldir) && is_dir($fulldir)) { - $this->dir = $fulldir; $this->path = $this->relativeThemePath('theme', 'theme', $name); } @@ -113,11 +111,9 @@ class Theme * * @todo consolidate code with that for other customizable paths */ - protected function relativeThemePath($group, $fallbackSubdir, $name) { if (StatusNet::isHTTPS()) { - $sslserver = common_config($group, 'sslserver'); if (empty($sslserver)) { @@ -140,9 +136,7 @@ class Theme } $protocol = 'https'; - } else { - $path = common_config($group, 'path'); if (empty($path)) { @@ -179,7 +173,6 @@ class Theme * * @return string full pathname, like /var/www/mublog/theme/default/logo.png */ - function getFile($relative) { return $this->dir.'/'.$relative; @@ -192,7 +185,6 @@ class Theme * * @return string full URL, like 'http://example.com/theme/default/logo.png' */ - function getPath($relative) { return $this->path.'/'.$relative; @@ -258,7 +250,6 @@ class Theme * * @return string File path to the theme file */ - static function file($relative, $name=null) { $theme = new Theme($name); @@ -273,7 +264,6 @@ class Theme * * @return string URL of the file */ - static function path($relative, $name=null) { $theme = new Theme($name); @@ -285,7 +275,6 @@ class Theme * * @return array list of available theme names */ - static function listAvailable() { $local = self::subdirsOf(self::localRoot()); @@ -305,7 +294,6 @@ class Theme * * @return array relative filenames of subdirs, or empty array */ - protected static function subdirsOf($dir) { $subdirs = array(); @@ -330,7 +318,6 @@ class Theme * * @return string local root dir for themes */ - protected static function localRoot() { $basedir = common_config('local', 'dir'); @@ -347,7 +334,6 @@ class Theme * * @return string root dir for StatusNet themes */ - protected static function installRoot() { $instroot = common_config('theme', 'dir'); diff --git a/lib/themeuploader.php b/lib/themeuploader.php index 5a48e884e..b7b14d7b9 100644 --- a/lib/themeuploader.php +++ b/lib/themeuploader.php @@ -163,9 +163,10 @@ class ThemeUploader $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); + $msg = sprintf(_m('Uploaded theme is too large; must be less than %d byte uncompressed.', + 'Uploaded theme is too large; must be less than %d bytes uncompressed.', + $sizeLimit), + $sizeLimit); throw new ClientException($msg); } diff --git a/lib/uapplugin.php b/lib/uapplugin.php index 277781768..26d6a72d8 100644 --- a/lib/uapplugin.php +++ b/lib/uapplugin.php @@ -51,7 +51,6 @@ if (!defined('STATUSNET') && !defined('LACONICA')) { * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 * @link http://status.net/ */ - abstract class UAPPlugin extends Plugin { public $mediumRectangle = null; @@ -66,7 +65,6 @@ abstract class UAPPlugin extends Plugin * * @return boolean hook flag */ - function onEndShowStatusNetStyles($action) { // XXX: allow override by theme @@ -81,7 +79,6 @@ abstract class UAPPlugin extends Plugin * * @return boolean hook flag */ - function onStartShowAside($action) { if (!is_null($this->mediumRectangle)) { @@ -144,7 +141,6 @@ abstract class UAPPlugin extends Plugin * * @return boolean hook flag */ - function onStartShowSections($action) { if (!is_null($this->rectangle)) { @@ -165,7 +161,6 @@ abstract class UAPPlugin extends Plugin * * @return boolean hook flag */ - function onEndShowAside($action) { if (!is_null($this->wideSkyscraper)) { @@ -187,7 +182,6 @@ abstract class UAPPlugin extends Plugin * * @return void */ - abstract protected function showMediumRectangle($action); /** @@ -197,7 +191,6 @@ abstract class UAPPlugin extends Plugin * * @return void */ - abstract protected function showRectangle($action); /** @@ -207,7 +200,6 @@ abstract class UAPPlugin extends Plugin * * @return void */ - abstract protected function showWideSkyscraper($action); /** @@ -217,6 +209,5 @@ abstract class UAPPlugin extends Plugin * * @return void */ - abstract protected function showLeaderboard($action); } diff --git a/lib/unblockform.php b/lib/unblockform.php index b89d7ff78..8daad3c92 100644 --- a/lib/unblockform.php +++ b/lib/unblockform.php @@ -44,7 +44,6 @@ if (!defined('STATUSNET')) { * * @see BlockForm */ - class UnblockForm extends ProfileActionForm { /** @@ -52,7 +51,6 @@ class UnblockForm extends ProfileActionForm * * @return string Name of the action, lowercased. */ - function target() { return 'unblock'; @@ -63,11 +61,10 @@ class UnblockForm extends ProfileActionForm * * @return string Title of the form, internationalized */ - function title() { // TRANS: Title for the form to unblock a user. - return _('Unblock'); + return _m('TITLE','Unblock'); } /** @@ -75,7 +72,6 @@ class UnblockForm extends ProfileActionForm * * @return string description of the form, internationalized */ - function description() { // TRANS: Description of the form to unblock a user. diff --git a/lib/util.php b/lib/util.php index a1ea1f24d..da36121ff 100644 --- a/lib/util.php +++ b/lib/util.php @@ -517,14 +517,29 @@ function common_user_cache_hash($user=false) } } -// get canonical version of nickname for comparison +/** + * get canonical version of nickname for comparison + * + * @param string $nickname + * @return string + * + * @throws NicknameException on invalid input + * @deprecated call Nickname::normalize() directly. + */ function common_canonical_nickname($nickname) { - // XXX: UTF-8 canonicalization (like combining chars) - return strtolower($nickname); + return Nickname::normalize($nickname); } -// get canonical version of email for comparison +/** + * get canonical version of email for comparison + * + * @fixme actually normalize + * @fixme reject invalid input + * + * @param string $email + * @return string + */ function common_canonical_email($email) { // XXX: canonicalize UTF-8 @@ -532,15 +547,33 @@ function common_canonical_email($email) return $email; } +/** + * Partial notice markup rendering step: build links to !group references. + * + * @param string $text partially rendered HTML + * @param Notice $notice in whose context we're working + * @return string partially rendered HTML + */ function common_render_content($text, $notice) { $r = common_render_text($text); $id = $notice->profile_id; $r = common_linkify_mentions($r, $notice); - $r = preg_replace('/(^|[\s\.\,\:\;]+)!([A-Za-z0-9]{1,64})/e', "'\\1!'.common_group_link($id, '\\2')", $r); + $r = preg_replace('/(^|[\s\.\,\:\;]+)!(' . Nickname::DISPLAY_FMT . ')/e', + "'\\1!'.common_group_link($id, '\\2')", $r); return $r; } +/** + * Finds @-mentions within the partially-rendered text section and + * turns them into live links. + * + * Should generally not be called except from common_render_content(). + * + * @param string $text partially-rendered HTML + * @param Notice $notice in-progress or complete Notice object for context + * @return string partially-rendered HTML + */ function common_linkify_mentions($text, $notice) { $mentions = common_find_mentions($text, $notice); @@ -597,6 +630,21 @@ function common_linkify_mention($mention) return $output; } +/** + * Find @-mentions in the given text, using the given notice object as context. + * References will be resolved with common_relative_profile() against the user + * who posted the notice. + * + * Note the return data format is internal, to be used for building links and + * such. Should not be used directly; rather, call common_linkify_mentions(). + * + * @param string $text + * @param Notice $notice notice in whose context we're building links + * + * @return array + * + * @access private + */ function common_find_mentions($text, $notice) { $mentions = array(); @@ -631,20 +679,15 @@ function common_find_mentions($text, $notice) } } - preg_match_all('/^T ([A-Z0-9]{1,64}) /', - $text, - $tmatches, - PREG_OFFSET_CAPTURE); - - preg_match_all('/(?:^|\s+)@(['.NICKNAME_FMT.']{1,64})/', - $text, - $atmatches, - PREG_OFFSET_CAPTURE); - - $matches = array_merge($tmatches[1], $atmatches[1]); + $matches = common_find_mentions_raw($text); foreach ($matches as $match) { - $nickname = common_canonical_nickname($match[0]); + try { + $nickname = Nickname::normalize($match[0]); + } catch (NicknameException $e) { + // Bogus match? Drop it. + continue; + } // Try to get a profile for this nickname. // Start with conversation context, then go to @@ -710,6 +753,31 @@ function common_find_mentions($text, $notice) return $mentions; } +/** + * Does the actual regex pulls to find @-mentions in text. + * Should generally not be called directly; for use in common_find_mentions. + * + * @param string $text + * @return array of PCRE match arrays + */ +function common_find_mentions_raw($text) +{ + $tmatches = array(); + preg_match_all('/^T (' . Nickname::DISPLAY_FMT . ') /', + $text, + $tmatches, + PREG_OFFSET_CAPTURE); + + $atmatches = array(); + preg_match_all('/(?:^|\s+)@(' . Nickname::DISPLAY_FMT . ')\b/', + $text, + $atmatches, + PREG_OFFSET_CAPTURE); + + $matches = array_merge($tmatches[1], $atmatches[1]); + return $matches; +} + function common_render_text($text) { $r = htmlspecialchars($text); @@ -721,7 +789,14 @@ function common_render_text($text) return $r; } -function common_replace_urls_callback($text, $callback, $notice_id = null) { +/** + * Find links in the given text and pass them to the given callback function. + * + * @param string $text + * @param function($text, $arg) $callback: return replacement text + * @param mixed $arg: optional argument will be passed on to the callback + */ +function common_replace_urls_callback($text, $callback, $arg = null) { // Start off with a regex $regex = '#'. '(?:^|[\s\<\>\(\)\[\]\{\}\\\'\\\";]+)(?![\@\!\#])'. @@ -762,10 +837,21 @@ function common_replace_urls_callback($text, $callback, $notice_id = null) { '#ixu'; //preg_match_all($regex,$text,$matches); //print_r($matches); - return preg_replace_callback($regex, curry('callback_helper',$callback,$notice_id) ,$text); + return preg_replace_callback($regex, curry('callback_helper',$callback,$arg) ,$text); } -function callback_helper($matches, $callback, $notice_id) { +/** + * Intermediate callback for common_replace_links(), helps resolve some + * ambiguous link forms before passing on to the final callback. + * + * @param array $matches + * @param callable $callback + * @param mixed $arg optional argument to pass on as second param to callback + * @return string + * + * @access private + */ +function callback_helper($matches, $callback, $arg=null) { $url=$matches[1]; $left = strpos($matches[0],$url); $right = $left+strlen($url); @@ -808,11 +894,7 @@ function callback_helper($matches, $callback, $notice_id) { } }while($original_url!=$url); - if(empty($notice_id)){ - $result = call_user_func_array($callback, array($url)); - }else{ - $result = call_user_func_array($callback, array(array($url,$notice_id)) ); - } + $result = call_user_func_array($callback, array($url, $arg)); return substr($matches[0],0,$left) . $result . substr($matches[0],$right); } @@ -848,7 +930,7 @@ function common_linkify($url) { $canon = File_redirection::_canonUrl($url); - $longurl_data = File_redirection::where($canon); + $longurl_data = File_redirection::where($canon, common_config('attachments', 'process_links')); if (is_array($longurl_data)) { $longurl = $longurl_data['url']; } elseif (is_string($longurl_data)) { @@ -872,12 +954,14 @@ function common_linkify($url) { $f = File::staticGet('url', $longurl); if (empty($f)) { - // XXX: this writes to the database. :< - $f = File::processNew($longurl); + if (common_config('attachments', 'process_links')) { + // XXX: this writes to the database. :< + $f = File::processNew($longurl); + } } if (!empty($f)) { - if ($f->getEnclosure() || File_oembed::staticGet('file_id',$f->id)) { + if ($f->getEnclosure()) { $is_attachment = true; $attachment_id = $f->id; @@ -910,11 +994,27 @@ function common_linkify($url) { return XMLStringer::estring('a', $attrs, $url); } -function common_shorten_links($text, $always = false) +/** + * Find and shorten links in a given chunk of text if it's longer than the + * configured notice content limit (or unconditionally). + * + * Side effects: may save file and file_redirection records for referenced URLs. + * + * Pass the $user option or call $user->shortenLinks($text) to ensure the proper + * user's options are used; otherwise the current web session user's setitngs + * will be used or ur1.ca if there is no active web login. + * + * @param string $text + * @param boolean $always (optional) + * @param User $user (optional) + * + * @return string + */ +function common_shorten_links($text, $always = false, User $user=null) { $maxLength = Notice::maxContent(); if (!$always && ($maxLength == 0 || mb_strlen($text) <= $maxLength)) return $text; - return common_replace_urls_callback($text, array('File_redirection', 'makeShort')); + return common_replace_urls_callback($text, array('File_redirection', 'makeShort'), $user); } /** @@ -1002,6 +1102,13 @@ function common_valid_profile_tag($str) return preg_match('/^[A-Za-z0-9_\-\.]{1,64}$/', $str); } +/** + * + * @param <type> $sender_id + * @param <type> $nickname + * @return <type> + * @access private + */ function common_group_link($sender_id, $nickname) { $sender = Profile::staticGet($sender_id); @@ -1010,7 +1117,7 @@ function common_group_link($sender_id, $nickname) $attrs = array('href' => $group->permalink(), 'class' => 'url'); if (!empty($group->fullname)) { - $attrs['title'] = $group->fullname . ' (' . $group->nickname . ')'; + $attrs['title'] = $group->getFancyName(); } $xs = new XMLStringer(); $xs->elementStart('span', 'vcard'); @@ -1024,13 +1131,37 @@ function common_group_link($sender_id, $nickname) } } +/** + * Resolve an ambiguous profile nickname reference, checking in following order: + * - profiles that $sender subscribes to + * - profiles that subscribe to $sender + * - local user profiles + * + * WARNING: does not validate or normalize $nickname -- MUST BE PRE-VALIDATED + * OR THERE MAY BE A RISK OF SQL INJECTION ATTACKS. THIS FUNCTION DOES NOT + * ESCAPE SQL. + * + * @fixme validate input + * @fixme escape SQL + * @fixme fix or remove mystery third parameter + * @fixme is $sender a User or Profile? + * + * @param <type> $sender the user or profile in whose context we're looking + * @param string $nickname validated nickname of + * @param <type> $dt unused mystery parameter; in Notice reply-to handling a timestamp is passed. + * + * @return Profile or null + */ function common_relative_profile($sender, $nickname, $dt=null) { + // Will throw exception on invalid input. + $nickname = Nickname::normalize($nickname); + // Try to find profiles this profile is subscribed to that have this nickname $recipient = new Profile(); // XXX: use a join instead of a subquery - $recipient->whereAdd('EXISTS (SELECT subscribed from subscription where subscriber = '.$sender->id.' and subscribed = id)', 'AND'); - $recipient->whereAdd("nickname = '" . trim($nickname) . "'", 'AND'); + $recipient->whereAdd('EXISTS (SELECT subscribed from subscription where subscriber = '.intval($sender->id).' and subscribed = id)', 'AND'); + $recipient->whereAdd("nickname = '" . $recipient->escape($nickname) . "'", 'AND'); if ($recipient->find(true)) { // XXX: should probably differentiate between profiles with // the same name by date of most recent update @@ -1039,8 +1170,8 @@ function common_relative_profile($sender, $nickname, $dt=null) // Try to find profiles that listen to this profile and that have this nickname $recipient = new Profile(); // XXX: use a join instead of a subquery - $recipient->whereAdd('EXISTS (SELECT subscriber from subscription where subscribed = '.$sender->id.' and subscriber = id)', 'AND'); - $recipient->whereAdd("nickname = '" . trim($nickname) . "'", 'AND'); + $recipient->whereAdd('EXISTS (SELECT subscriber from subscription where subscribed = '.intval($sender->id).' and subscriber = id)', 'AND'); + $recipient->whereAdd("nickname = '" . $recipient->escape($nickname) . "'", 'AND'); if ($recipient->find(true)) { // XXX: should probably differentiate between profiles with // the same name by date of most recent update @@ -1499,6 +1630,7 @@ function common_request_id() function common_log($priority, $msg, $filename=null) { if(Event::handle('StartLog', array(&$priority, &$msg, &$filename))){ + $msg = (empty($filename)) ? $msg : basename($filename) . ' - ' . $msg; $msg = '[' . common_request_id() . '] ' . $msg; $logfile = common_config('site', 'logfile'); if ($logfile) { @@ -1926,15 +2058,18 @@ function common_database_tablename($tablename) * Length is not considered. * * @param string $long_url + * @param User $user to specify a particular user's options * @return string may return the original URL if shortening failed * * @fixme provide a way to specify a particular shortener - * @fixme provide a way to specify to use a given user's shortening preferences */ -function common_shorten_url($long_url) +function common_shorten_url($long_url, User $user=null) { $long_url = trim($long_url); - $user = common_current_user(); + if (empty($user)) { + // Current web session + $user = common_current_user(); + } if (empty($user)) { // common current user does not find a user when called from the XMPP daemon // therefore we'll set one here fix, so that XMPP given URLs may be shortened diff --git a/lib/xmppmanager.php b/lib/xmppmanager.php index 238696664..585d044c7 100644 --- a/lib/xmppmanager.php +++ b/lib/xmppmanager.php @@ -398,7 +398,7 @@ class XmppManager extends IoManager function add_notice(&$user, &$pl) { $body = trim($pl['body']); - $content_shortened = common_shorten_links($body); + $content_shortened = $user->shortenLinks($body); if (Notice::contentTooLong($content_shortened)) { $from = jabber_normalize_jid($pl['from']); // TRANS: Response to XMPP source when it sent too long a message. diff --git a/lib/xrd.php b/lib/xrd.php new file mode 100644 index 000000000..c8cffed9c --- /dev/null +++ b/lib/xrd.php @@ -0,0 +1,172 @@ +<?php +/** + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2010, StatusNet, Inc. + * + * A sample module to show best practices for StatusNet plugins + * + * PHP version 5 + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @package StatusNet + * @author James Walker <james@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class XRD +{ + const XML_NS = 'http://www.w3.org/2000/xmlns/'; + + const XRD_NS = 'http://docs.oasis-open.org/ns/xri/xrd-1.0'; + + const HOST_META_NS = 'http://host-meta.net/xrd/1.0'; + + public $expires; + + public $subject; + + public $host; + + public $alias = array(); + + public $types = array(); + + public $links = array(); + + public static function parse($xml) + { + $xrd = new XRD(); + + $dom = new DOMDocument(); + + // Don't spew XML warnings to output + $old = error_reporting(); + error_reporting($old & ~E_WARNING); + $ok = $dom->loadXML($xml); + error_reporting($old); + + if (!$ok) { + // TRANS: Exception. + throw new Exception(_m('Invalid XML.')); + } + $xrd_element = $dom->getElementsByTagName('XRD')->item(0); + if (!$xrd_element) { + // TRANS: Exception. + throw new Exception(_m('Invalid XML, missing XRD root.')); + } + + // Check for host-meta host + $host = $xrd_element->getElementsByTagName('Host')->item(0); + if ($host) { + $xrd->host = $host->nodeValue; + } + + // Loop through other elements + foreach ($xrd_element->childNodes as $node) { + if (!($node instanceof DOMElement)) { + continue; + } + switch ($node->tagName) { + case 'Expires': + $xrd->expires = $node->nodeValue; + break; + case 'Subject': + $xrd->subject = $node->nodeValue; + break; + + case 'Alias': + $xrd->alias[] = $node->nodeValue; + break; + + case 'Link': + $xrd->links[] = $xrd->parseLink($node); + break; + + case 'Type': + $xrd->types[] = $xrd->parseType($node); + break; + + } + } + return $xrd; + } + + public function toXML() + { + $xs = new XMLStringer(); + + $xs->startXML(); + $xs->elementStart('XRD', array('xmlns' => XRD::XRD_NS)); + + if ($this->host) { + $xs->element('hm:Host', array('xmlns:hm' => XRD::HOST_META_NS), $this->host); + } + + if ($this->expires) { + $xs->element('Expires', null, $this->expires); + } + + if ($this->subject) { + $xs->element('Subject', null, $this->subject); + } + + foreach ($this->alias as $alias) { + $xs->element('Alias', null, $alias); + } + + foreach ($this->links as $link) { + $titles = array(); + if (isset($link['title'])) { + $titles = $link['title']; + unset($link['title']); + } + $xs->elementStart('Link', $link); + foreach ($titles as $title) { + $xs->element('Title', null, $title); + } + $xs->elementEnd('Link'); + } + + $xs->elementEnd('XRD'); + + return $xs->getString(); + } + + function parseType($element) + { + return array(); + } + + function parseLink($element) + { + $link = array(); + $link['rel'] = $element->getAttribute('rel'); + $link['type'] = $element->getAttribute('type'); + $link['href'] = $element->getAttribute('href'); + $link['template'] = $element->getAttribute('template'); + foreach ($element->childNodes as $node) { + if ($node instanceof DOMElement) { + switch($node->tagName) { + case 'Title': + $link['title'][] = $node->nodeValue; + } + } + } + + return $link; + } +} diff --git a/lib/xrdaction.php b/lib/xrdaction.php new file mode 100644 index 000000000..43826b32b --- /dev/null +++ b/lib/xrdaction.php @@ -0,0 +1,135 @@ +<?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/>. + */ + +/** + * @package OStatusPlugin + * @maintainer James Walker <james@status.net> + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class XrdAction extends Action +{ + const PROFILEPAGE = 'http://webfinger.net/rel/profile-page'; + const UPDATESFROM = 'http://schemas.google.com/g/2010#updates-from'; + const HCARD = 'http://microformats.org/profile/hcard'; + + public $uri; + + public $user; + + public $xrd; + + function handle() + { + $nick = $this->user->nickname; + $profile = $this->user->getProfile(); + + if (empty($this->xrd)) { + $xrd = new XRD(); + } else { + $xrd = $this->xrd; + } + + if (empty($xrd->subject)) { + $xrd->subject = self::normalize($this->uri); + } + + if (Event::handle('StartXrdActionAliases', array(&$xrd, $this->user))) { + + // Possible aliases for the user + + $uris = array($this->user->uri, $profile->profileurl); + + // FIXME: Webfinger generation code should live somewhere on its own + + $path = common_config('site', 'path'); + + if (empty($path)) { + $uris[] = sprintf('acct:%s@%s', $nick, common_config('site', 'server')); + } + + foreach ($uris as $uri) { + if ($uri != $xrd->subject) { + $xrd->alias[] = $uri; + } + } + + Event::handle('EndXrdActionAliases', array(&$xrd, $this->user)); + } + + if (Event::handle('StartXrdActionLinks', array(&$xrd, $this->user))) { + + $xrd->links[] = array('rel' => self::PROFILEPAGE, + 'type' => 'text/html', + 'href' => $profile->profileurl); + + // hCard + $xrd->links[] = array('rel' => self::HCARD, + 'type' => 'text/html', + 'href' => common_local_url('hcard', array('nickname' => $nick))); + + // XFN + $xrd->links[] = array('rel' => 'http://gmpg.org/xfn/11', + 'type' => 'text/html', + 'href' => $profile->profileurl); + // FOAF + $xrd->links[] = array('rel' => 'describedby', + 'type' => 'application/rdf+xml', + 'href' => common_local_url('foaf', + array('nickname' => $nick))); + + + Event::handle('EndXrdActionLinks', array(&$xrd, $this->user)); + } + + + header('Content-type: application/xrd+xml'); + print $xrd->toXML(); + } + + /** + * Given a "user id" make sure it's normalized to either a webfinger + * acct: uri or a profile HTTP URL. + */ + + public static function normalize($user_id) + { + if (substr($user_id, 0, 5) == 'http:' || + substr($user_id, 0, 6) == 'https:' || + substr($user_id, 0, 5) == 'acct:') { + return $user_id; + } + + if (strpos($user_id, '@') !== FALSE) { + return 'acct:' . $user_id; + } + + return 'http://' . $user_id; + } + + public static function isWebfinger($user_id) + { + $uri = self::normalize($user_id); + + return (substr($uri, 0, 5) == 'acct:'); + } +} |