Merge branch 'testing' into 0.9.x

This commit is contained in:
Brion Vibber 2010-02-17 10:14:08 -08:00
commit 5a6cbb248f
15 changed files with 840 additions and 182 deletions

View File

@ -1,4 +1,4 @@
\InitializePlugin: a chance to initialize a plugin in a complete environment InitializePlugin: a chance to initialize a plugin in a complete environment
CleanupPlugin: a chance to cleanup a plugin at the end of a program CleanupPlugin: a chance to cleanup a plugin at the end of a program
@ -722,3 +722,10 @@ StartRobotsTxt: Before outputting the robots.txt page
EndRobotsTxt: After the default robots.txt page (good place for customization) EndRobotsTxt: After the default robots.txt page (good place for customization)
- &$action: RobotstxtAction being shown - &$action: RobotstxtAction being shown
StartGetProfileUri: When determining the canonical URI for a given profile
- $profile: the current profile
- &$uri: the URI
EndGetProfileUri: After determining the canonical URI for a given profile
- $profile: the current profile
- &$uri: the URI

View File

@ -196,7 +196,8 @@ class ApiTimelineUserAction extends ApiBareAuthAction
$atom->addEntryFromNotices($this->notices); $atom->addEntryFromNotices($this->notices);
$this->raw($atom->getString()); #$this->raw($atom->getString());
print $atom->getString(); // temporary for output buffering
break; break;
case 'json': case 'json':

78
classes/Conversation.php Executable file
View File

@ -0,0 +1,78 @@
<?php
/**
* StatusNet, the distributed open-source microblogging tool
*
* Data class for Conversations
*
* 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 Data
* @package StatusNet
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0
* @link http://status.net/
*/
require_once INSTALLDIR . '/classes/Memcached_DataObject.php';
class Conversation extends Memcached_DataObject
{
###START_AUTOCODE
/* the code below is auto generated do not remove the above tag */
public $__table = 'conversation'; // table name
public $id; // int(4) primary_key not_null
public $uri; // varchar(225) unique_key
public $created; // datetime not_null
public $modified; // timestamp not_null default_CURRENT_TIMESTAMP
/* Static get */
function staticGet($k,$v=NULL) { return Memcached_DataObject::staticGet('conversation',$k,$v); }
/* the code above is auto generated do not remove the tag below */
###END_AUTOCODE
/**
* Factory method for creating a new conversation
*
* @return Conversation the new conversation DO
*/
static function create()
{
$conv = new Conversation();
$conv->created = common_sql_now();
$id = $conv->insert();
if (empty($id)) {
common_log_db_error($conv, 'INSERT', __FILE__);
return null;
}
$orig = clone($conv);
$orig->uri = common_local_url('conversation', array('id' => $id));
$result = $orig->update($conv);
if (empty($result)) {
common_log_db_error($conv, 'UPDATE', __FILE__);
return null;
}
return $conv;
}
}

View File

@ -309,7 +309,8 @@ class Notice extends Memcached_DataObject
// the beginning of a new conversation. // the beginning of a new conversation.
if (empty($notice->conversation)) { if (empty($notice->conversation)) {
$notice->conversation = $notice->id; $conv = Conversation::create();
$notice->conversation = $conv->id;
$changed = true; $changed = true;
} }
@ -331,14 +332,15 @@ class Notice extends Memcached_DataObject
return $notice; return $notice;
} }
function blowOnInsert() function blowOnInsert($conversation = false)
{ {
self::blow('profile:notice_ids:%d', $this->profile_id); self::blow('profile:notice_ids:%d', $this->profile_id);
self::blow('public'); self::blow('public');
if ($this->conversation != $this->id) { // XXX: Before we were blowing the casche only if the notice id
self::blow('notice:conversation_ids:%d', $this->conversation); // was not the root of the conversation. What to do now?
}
self::blow('notice:conversation_ids:%d', $this->conversation);
if (!empty($this->repeat_of)) { if (!empty($this->repeat_of)) {
self::blow('notice:repeats:%d', $this->repeat_of); self::blow('notice:repeats:%d', $this->repeat_of);
@ -1015,28 +1017,29 @@ class Notice extends Memcached_DataObject
} }
} }
if (!empty($this->conversation) if (!empty($this->conversation)) {
&& $this->conversation != $this->id) {
$xs->element( $conv = Conversation::staticGet('id', $this->conversation);
'link', array(
'rel' => 'ostatus:conversation', if (!empty($conv)) {
'href' => common_local_url( $xs->element(
'conversation', 'link', array(
array('id' => $this->conversation) 'rel' => 'ostatus:conversation',
) 'href' => $conv->uri
) )
); );
}
} }
$reply_ids = $this->getReplies(); $reply_ids = $this->getReplies();
foreach ($reply_ids as $id) { foreach ($reply_ids as $id) {
$profile = Profile::staticGet('id', $id); $profile = Profile::staticGet('id', $id);
if (!empty($profile)) { if (!empty($profile)) {
$xs->element( $xs->element(
'link', array( 'link', array(
'rel' => 'ostatus:attention', 'rel' => 'ostatus:attention',
'href' => $profile->getAcctUri() 'href' => $profile->getUri()
) )
); );
} }

View File

@ -769,7 +769,7 @@ class Profile extends Memcached_DataObject
$xs->elementStart('author'); $xs->elementStart('author');
$xs->element('name', null, $this->nickname); $xs->element('name', null, $this->nickname);
$xs->element('uri', null, $this->profileurl); $xs->element('uri', null, $this->getUri());
$xs->elementEnd('author'); $xs->elementEnd('author');
return $xs->getString(); return $xs->getString();
@ -810,10 +810,7 @@ class Profile extends Memcached_DataObject
$xs->element( $xs->element(
'id', 'id',
null, null,
common_local_url( $this->getUri()
'userbyid',
array('id' => $this->id)
)
); );
$xs->element('title', null, $this->getBestName()); $xs->element('title', null, $this->getBestName());
@ -822,6 +819,7 @@ class Profile extends Memcached_DataObject
$xs->element( $xs->element(
'link', array( 'link', array(
'type' => empty($avatar) ? 'image/png' : $avatar->mediatype, 'type' => empty($avatar) ? 'image/png' : $avatar->mediatype,
'rel' => 'avatar',
'href' => empty($avatar) 'href' => empty($avatar)
? Avatar::defaultImage(AVATAR_PROFILE_SIZE) ? Avatar::defaultImage(AVATAR_PROFILE_SIZE)
: $avatar->displayUrl() : $avatar->displayUrl()
@ -834,9 +832,40 @@ class Profile extends Memcached_DataObject
return $xs->getString(); return $xs->getString();
} }
function getAcctUri() /**
* Returns the best URI for a profile. Plugins may override.
*
* @return string $uri
*/
function getUri()
{ {
return $this->nickname . '@' . common_config('site', 'server'); $uri = null;
// check for a local user first
$user = User::staticGet('id', $this->id);
if (!empty($user)) {
$uri = common_local_url(
'userbyid',
array('id' => $user->id)
);
} else {
// give plugins a chance to set the URI
if (Event::handle('StartGetProfileUri', array($this, &$uri))) {
// return OMB profile if any
$remote = Remote_profile::staticGet('id', $this->id);
if (!empty($remote)) {
$uri = $remote->uri;
}
Event::handle('EndGetProfileUri', array($this, &$uri));
}
}
return $uri;
} }
} }

View File

@ -47,6 +47,16 @@ modified = 384
[consumer__keys] [consumer__keys]
consumer_key = K consumer_key = K
[conversation]
id = 129
uri = 2
created = 142
modified = 384
[conversation__keys]
id = N
uri = U
[deleted_notice] [deleted_notice]
id = 129 id = 129
profile_id = 129 profile_id = 129

View File

@ -633,3 +633,11 @@ create table inbox (
constraint primary key (user_id) constraint primary key (user_id)
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin; ) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin;
create table conversation (
id integer auto_increment primary key comment 'unique identifier',
uri varchar(225) unique comment 'URI of the conversation',
created datetime not null comment 'date this record was created',
modified timestamp comment 'date this record was modified'
) ENGINE=InnoDB CHARACTER SET utf8 COLLATE utf8_bin;

View File

@ -492,30 +492,34 @@ class NoticeListItem extends Widget
break; break;
default: default:
$name = null; $name = $source_name;
$url = null; $url = null;
$ns = Notice_source::staticGet($this->notice->source); if (Event::handle('StartNoticeSourceLink', array($this->notice, &$name, &$url, &$title))) {
$ns = Notice_source::staticGet($this->notice->source);
if ($ns) { if ($ns) {
$name = $ns->name; $name = $ns->name;
$url = $ns->url; $url = $ns->url;
} else { } else {
$app = Oauth_application::staticGet('name', $this->notice->source); $app = Oauth_application::staticGet('name', $this->notice->source);
if ($app) { if ($app) {
$name = $app->name; $name = $app->name;
$url = $app->source_url; $url = $app->source_url;
}
} }
} }
Event::handle('EndNoticeSourceLink', array($this->notice, &$name, &$url, &$title));
if (!empty($name) && !empty($url)) { if (!empty($name) && !empty($url)) {
$this->out->elementStart('span', 'device'); $this->out->elementStart('span', 'device');
$this->out->element('a', array('href' => $url, $this->out->element('a', array('href' => $url,
'rel' => 'external'), 'rel' => 'external',
'title' => $title),
$name); $name);
$this->out->elementEnd('span'); $this->out->elementEnd('span');
} else { } else {
$this->out->element('span', 'device', $source_name); $this->out->element('span', 'device', $name);
} }
break; break;
} }

View File

@ -289,4 +289,17 @@ class OStatusPlugin extends Plugin
$action->script('plugins/OStatus/js/ostatus.js'); $action->script('plugins/OStatus/js/ostatus.js');
return true; return true;
} }
function onStartNoticeSourceLink($notice, &$name, &$url, &$title)
{
if ($notice->source == 'ostatus') {
$bits = parse_url($notice->uri);
$domain = $bits['host'];
$name = $domain;
$url = $notice->uri;
$title = sprintf(_m("Sent from %s via OStatus"), $domain);
return false;
}
}
} }

View File

@ -59,6 +59,9 @@ class PushCallbackAction extends Action
} }
$post = file_get_contents('php://input'); $post = file_get_contents('php://input');
// @fixme Queue this to a background process; we should return
// as quickly as possible from a distribution POST.
$profile->postUpdates($post, $hmac); $profile->postUpdates($post, $hmac);
} }

View File

@ -44,7 +44,7 @@ class PushHubAction extends Action
// PHP converts '.'s in incoming var names to '_'s. // PHP converts '.'s in incoming var names to '_'s.
// It also merges multiple values, which'll break hub.verify and hub.topic for publishing // It also merges multiple values, which'll break hub.verify and hub.topic for publishing
// @fixme handle multiple args // @fixme handle multiple args
$arg = str_replace('.', '_', $arg); $arg = str_replace('hub.', 'hub_', $arg);
return parent::arg($arg, $def); return parent::arg($arg, $def);
} }
@ -96,7 +96,11 @@ class PushHubAction extends Action
$sub = new HubSub(); $sub = new HubSub();
$sub->topic = $feed; $sub->topic = $feed;
$sub->callback = $callback; $sub->callback = $callback;
$sub->verify_token = $this->arg('hub.verify_token', null);
$sub->secret = $this->arg('hub.secret', null); $sub->secret = $this->arg('hub.secret', null);
if (strlen($sub->secret) > 200) {
throw new ClientException("hub.secret must be no longer than 200 chars", 400);
}
$sub->setLease(intval($this->arg('hub.lease_seconds'))); $sub->setLease(intval($this->arg('hub.lease_seconds')));
// @fixme check for feeds we don't manage // @fixme check for feeds we don't manage

View File

@ -218,14 +218,10 @@ class Ostatus_profile extends Memcached_DataObject
$profile->query('BEGIN'); $profile->query('BEGIN');
// Awful hack! Awful hack!
$profile->verify = common_good_rand(16);
$profile->secret = common_good_rand(32);
try { try {
$local = $munger->profile(); $local = $munger->profile();
if ($entity->isGroup()) { if ($profile->isGroup()) {
$group = new User_group(); $group = new User_group();
$group->nickname = $local->nickname . '@remote'; // @fixme $group->nickname = $local->nickname . '@remote'; // @fixme
$group->fullname = $local->fullname; $group->fullname = $local->fullname;
@ -245,31 +241,31 @@ class Ostatus_profile extends Memcached_DataObject
$profile->profile_id = $local->id; $profile->profile_id = $local->id;
} }
$profile->created = sql_common_date(); $profile->created = common_sql_now();
$profile->lastupdate = sql_common_date(); $profile->lastupdate = common_sql_now();
$result = $profile->insert(); $result = $profile->insert();
if (empty($result)) { if (empty($result)) {
throw new FeedDBException($profile); throw new FeedDBException($profile);
} }
$entity->query('COMMIT'); $profile->query('COMMIT');
} catch (FeedDBException $e) { } catch (FeedDBException $e) {
common_log_db_error($e->obj, 'INSERT', __FILE__); common_log_db_error($e->obj, 'INSERT', __FILE__);
$entity->query('ROLLBACK'); $profile->query('ROLLBACK');
return false; return false;
} }
$avatar = $munger->getAvatar(); $avatar = $munger->getAvatar();
if ($avatar) { if ($avatar) {
try { try {
$this->updateAvatar($avatar); $profile->updateAvatar($avatar);
} catch (Exception $e) { } catch (Exception $e) {
common_log(LOG_ERR, "Exception setting OStatus avatar: " . common_log(LOG_ERR, "Exception setting OStatus avatar: " .
$e->getMessage()); $e->getMessage());
} }
} }
return $entity; return $profile;
} }
/** /**
@ -283,8 +279,10 @@ class Ostatus_profile extends Memcached_DataObject
// ripped from oauthstore.php (for old OMB client) // ripped from oauthstore.php (for old OMB client)
$temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar'); $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
copy($url, $temp_filename); copy($url, $temp_filename);
$imagefile = new ImageFile($profile->id, $temp_filename);
$filename = Avatar::filename($profile->id, // @fixme should we be using different ids?
$imagefile = new ImageFile($this->id, $temp_filename);
$filename = Avatar::filename($this->id,
image_type_to_extension($imagefile->type), image_type_to_extension($imagefile->type),
null, null,
common_timestamp()); common_timestamp());
@ -376,17 +374,59 @@ class Ostatus_profile extends Memcached_DataObject
* The hub will later send us a confirmation POST to /main/push/callback. * The hub will later send us a confirmation POST to /main/push/callback.
* *
* @return bool true on success, false on failure * @return bool true on success, false on failure
* @throws ServerException if feed state is not valid
*/ */
public function subscribe($mode='subscribe') public function subscribe($mode='subscribe')
{ {
if (common_config('feedsub', 'nohub')) { if ($this->sub_state != '') {
// Fake it! We're just testing remote feeds w/o hubs. throw new ServerException("Attempting to start PuSH subscription to feed in state $this->sub_state");
return true;
} }
// @fixme use the verification token if (empty($this->huburi)) {
#$token = md5(mt_rand() . ':' . $this->feeduri); if (common_config('feedsub', 'nohub')) {
#$this->verify_token = $token; // Fake it! We're just testing remote feeds w/o hubs.
#$this->update(); // @fixme return true;
} else {
throw new ServerException("Attempting to start PuSH subscription for feed with no hub");
}
}
return $this->doSubscribe('subscribe');
}
/**
* Send a PuSH unsubscription request to the hub for this feed.
* The hub will later send us a confirmation POST to /main/push/callback.
*
* @return bool true on success, false on failure
* @throws ServerException if feed state is not valid
*/
public function unsubscribe() {
if ($this->sub_state != 'active') {
throw new ServerException("Attempting to end PuSH subscription to feed in state $this->sub_state");
}
if (empty($this->huburi)) {
if (common_config('feedsub', 'nohub')) {
// Fake it! We're just testing remote feeds w/o hubs.
return true;
} else {
throw new ServerException("Attempting to end PuSH subscription for feed with no hub");
}
}
return $this->doSubscribe('unsubscribe');
}
protected function doSubscribe($mode)
{
$orig = clone($this);
$this->verify_token = common_good_rand(16);
if ($mode == 'subscribe') {
$this->secret = common_good_rand(32);
}
$this->sub_state = $mode;
$this->update($orig);
unset($orig);
try { try {
$callback = common_local_url('pushcallback', array('feed' => $this->id)); $callback = common_local_url('pushcallback', array('feed' => $this->id));
$headers = array('Content-Type: application/x-www-form-urlencoded'); $headers = array('Content-Type: application/x-www-form-urlencoded');
@ -416,6 +456,13 @@ class Ostatus_profile extends Memcached_DataObject
} catch (Exception $e) { } catch (Exception $e) {
// wtf! // wtf!
common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri"); common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri");
$orig = clone($this);
$this->verify_token = null;
$this->sub_state = null;
$this->update($orig);
unset($orig);
return false; return false;
} }
} }
@ -460,16 +507,6 @@ class Ostatus_profile extends Memcached_DataObject
return $this->update($original); return $this->update($original);
} }
/**
* Send a PuSH unsubscription request to the hub for this feed.
* The hub will later send us a confirmation POST to /main/push/callback.
*
* @return bool true on success, false on failure
*/
public function unsubscribe() {
return $this->subscribe('unsubscribe');
}
/** /**
* Send an Activity Streams notification to the remote Salmon endpoint, * Send an Activity Streams notification to the remote Salmon endpoint,
* if so configured. * if so configured.
@ -561,84 +598,339 @@ class Ostatus_profile extends Memcached_DataObject
* Currently assumes that all items in the feed are new, * Currently assumes that all items in the feed are new,
* coming from a PuSH hub. * coming from a PuSH hub.
* *
* @param string $xml source of Atom or RSS feed * @param string $post source of Atom or RSS feed
* @param string $hmac X-Hub-Signature header, if present * @param string $hmac X-Hub-Signature header, if present
*/ */
public function postUpdates($xml, $hmac) public function postUpdates($post, $hmac)
{ {
common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml"); common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $post");
if ($this->secret) { if ($this->sub_state != 'active') {
if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH for inactive feed $this->feeduri (in state '$this->sub_state')");
$their_hmac = strtolower($matches[1]);
$our_hmac = hash_hmac('sha1', $xml, $this->secret);
if ($their_hmac !== $our_hmac) {
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
return;
}
} else {
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
return;
}
} else if ($hmac) {
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
return; return;
} }
require_once "XML/Feed/Parser.php"; if ($post === '') {
$feed = new XML_Feed_Parser($xml, false, false, true); common_log(LOG_ERR, __METHOD__ . ": ignoring empty post");
$munger = new FeedMunger($feed); return;
$hits = 0;
foreach ($feed as $index => $entry) {
// @fixme this might sort in wrong order if we get multiple updates
$notice = $munger->notice($index);
// Double-check for oldies
// @fixme this could explode horribly for multiple feeds on a blog. sigh
$dupe = Notice::staticGet('uri', $notice->uri);
if (!empty($dupe)) {
common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}");
continue;
}
// @fixme need to ensure that groups get handled correctly
$saved = Notice::saveNew($notice->profile_id,
$notice->content,
'ostatus',
array('is_local' => Notice::REMOTE_OMB,
'uri' => $notice->uri,
'lat' => $notice->lat,
'lon' => $notice->lon,
'location_ns' => $notice->location_ns,
'location_id' => $notice->location_id));
/*
common_log(LOG_DEBUG, "going to check group delivery...");
if ($this->group_id) {
$group = User_group::staticGet($this->group_id);
if ($group) {
common_log(LOG_INFO, __METHOD__ . ": saving to local shadow group $group->id $group->nickname");
$groups = array($group);
} else {
common_log(LOG_INFO, __METHOD__ . ": lost the local shadow group?");
}
} else {
common_log(LOG_INFO, __METHOD__ . ": no local shadow groups");
$groups = array();
}
common_log(LOG_DEBUG, "going to add to inboxes...");
$notice->addToInboxes($groups, array());
common_log(LOG_DEBUG, "added to inboxes.");
*/
$hits++;
} }
if ($hits == 0) {
common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml"); if (!$this->validatePushSig($post, $hmac)) {
// Per spec we silently drop input with a bad sig,
// while reporting receipt to the server.
return;
}
$feed = new DOMDocument();
if (!$feed->loadXML($post)) {
// @fixme might help to include the err message
common_log(LOG_ERR, __METHOD__ . ": ignoring invalid XML");
return;
}
$entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
if ($entries->length == 0) {
common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
return;
}
for ($i = 0; $i < $entries->length; $i++) {
$entry = $entries->item($i);
$this->processEntry($entry, $feed);
} }
} }
/**
* Validate the given Atom chunk and HMAC signature against our
* shared secret that was set up at subscription time.
*
* If we don't have a shared secret, there should be no signature.
* If we we do, our the calculated HMAC should match theirs.
*
* @param string $post raw XML source as POSTed to us
* @param string $hmac X-Hub-Signature HTTP header value, or empty
* @return boolean true for a match
*/
protected function validatePushSig($post, $hmac)
{
if ($this->secret) {
if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) {
$their_hmac = strtolower($matches[1]);
$our_hmac = hash_hmac('sha1', $post, $this->secret);
if ($their_hmac === $our_hmac) {
return true;
}
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bad SHA-1 HMAC: got $their_hmac, expected $our_hmac");
} else {
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with bogus HMAC '$hmac'");
}
} else {
if (empty($hmac)) {
return true;
} else {
common_log(LOG_ERR, __METHOD__ . ": ignoring PuSH with unexpected HMAC '$hmac'");
}
}
return false;
}
/**
* Process a posted entry from this feed source.
*
* @param DOMElement $entry
* @param DOMElement $feed for context
*/
protected function processEntry($entry, $feed)
{
$activity = new Activity($entry, $feed);
$debug = var_export($activity, true);
common_log(LOG_DEBUG, $debug);
if ($activity->verb == ActivityVerb::POST) {
$this->processPost($activity);
} else {
common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
}
}
/**
* Process an incoming post activity from this remote feed.
* @param Activity $activity
*/
protected function processPost($activity)
{
if ($this->isGroup()) {
// @fixme validate these profiles in some way!
$oprofile = $this->ensureActorProfile($activity);
} else {
$actorUri = $this->getActorProfileURI($activity);
if ($actorUri == $this->homeuri) {
// @fixme check if profile info has changed and update it
} else {
// @fixme drop or reject the messages once we've got the canonical profile URI recorded sanely
common_log(LOG_INFO, "OStatus: Warning: non-group post with unexpected author: $actorUri expected $this->homeuri");
//return;
}
$oprofile = $this;
}
if ($activity->object->link) {
$sourceUri = $activity->object->link;
} else if (preg_match('!^https?://!', $activity->object->id)) {
$sourceUri = $activity->object->id;
} else {
common_log(LOG_INFO, "OStatus: ignoring post with no source link: id $activity->object->id");
return;
}
$dupe = Notice::staticGet('uri', $sourceUri);
if ($dupe) {
common_log(LOG_INFO, "OStatus: ignoring duplicate post: $noticeLink");
return;
}
// @fixme sanitize and save HTML content if available
$content = $activity->object->title;
$params = array('is_local' => Notice::REMOTE_OMB,
'uri' => $sourceUri);
$location = $this->getEntryLocation($activity->entry);
if ($location) {
$params['lat'] = $location->lat;
$params['lon'] = $location->lon;
if ($location->location_id) {
$params['location_ns'] = $location->location_ns;
$params['location_id'] = $location->location_id;
}
}
// @fixme save detailed ostatus source info
// @fixme ensure that groups get handled correctly
$saved = Notice::saveNew($oprofile->localProfile()->id,
$content,
'ostatus',
$params);
}
/**
* Parse location given as a GeoRSS-simple point, if provided.
* http://www.georss.org/simple
*
* @param feed item $entry
* @return mixed Location or false
*/
function getLocation($dom)
{
$points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point');
for ($i = 0; $i < $points->length; $i++) {
$point = $points->item(0)->textContent;
$point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
$point = preg_replace('/\s+/', ' ', $point);
$point = trim($point);
$coords = explode(' ', $point);
if (count($coords) == 2) {
list($lat, $lon) = $coords;
if (is_numeric($lat) && is_numeric($lon)) {
common_log(LOG_INFO, "Looking up location for $lat $lon from georss");
return Location::fromLatLon($lat, $lon);
}
}
common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
}
return false;
}
/**
* Get an appropriate avatar image source URL, if available.
*
* @param ActivityObject $actor
* @param DOMElement $feed
* @return string
*/
function getAvatar($actor, $feed)
{
$url = '';
$icon = '';
if ($actor->avatar) {
$url = trim($actor->avatar);
}
if (!$url) {
// Check <atom:logo> and <atom:icon> on the feed
$els = $feed->childNodes();
if ($els && $els->length) {
for ($i = 0; $i < $els->length; $i++) {
$el = $els->item($i);
if ($el->namespaceURI == Activity::ATOM) {
if (empty($url) && $el->localName == 'logo') {
$url = trim($el->textContent);
break;
}
if (empty($icon) && $el->localName == 'icon') {
// Use as a fallback
$icon = trim($el->textContent);
}
}
}
}
if ($icon && !$url) {
$url = $icon;
}
}
if ($url) {
$opts = array('allowed_schemes' => array('http', 'https'));
if (Validate::uri($url, $opts)) {
return $url;
}
}
return common_path('plugins/OStatus/images/96px-Feed-icon.svg.png');
}
/**
* @fixme move off of ostatus_profile or static?
*/
function ensureActorProfile($activity)
{
$profile = $this->getActorProfile($activity);
if (!$profile) {
$profile = $this->createActorProfile($activity);
}
return $profile;
}
/**
* @param Activity $activity
* @return mixed matching Ostatus_profile or false if none known
*/
function getActorProfile($activity)
{
$homeuri = $this->getActorProfileURI($activity);
return Ostatus_profile::staticGet('homeuri', $homeuri);
}
/**
* @param Activity $activity
* @return string
* @throws ServerException
*/
function getActorProfileURI($activity)
{
$opts = array('allowed_schemes' => array('http', 'https'));
$actor = $activity->actor;
if ($actor->id && Validate::uri($actor->id, $opts)) {
return $actor->id;
}
if ($actor->link && Validate::uri($actor->link, $opts)) {
return $actor->link;
}
throw new ServerException("No author ID URI found");
}
/**
*
*/
function createActorProfile($activity)
{
$actor = $activity->actor();
$homeuri = $this->getActivityProfileURI($activity);
$nickname = $this->getAuthorNick($activity);
$avatar = $this->getAvatar($actor, $feed);
$profile = new Profile();
$profile->nickname = $nickname;
$profile->fullname = $actor->displayName;
$profile->homepage = $actor->link; // @fixme
$profile->profileurl = $homeuri;
// @fixme bio
// @fixme tags/categories
// @fixme location?
// @todo tags from categories
// @todo lat/lon/location?
$ok = $profile->insert();
if ($ok) {
$this->updateAvatar($profile, $avatar);
} else {
throw new ServerException("Can't save local profile");
}
// @fixme either need to do feed discovery here
// or need to split out some of the feed stuff
// so we can leave it empty until later.
$oprofile = new Ostatus_profile();
$oprofile->homeuri = $homeuri;
$oprofile->profile_id = $profile->id;
$ok = $oprofile->insert();
if ($ok) {
return $oprofile;
} else {
throw new ServerException("Can't save OStatus profile");
}
}
/**
* @fixme move this into Activity?
* @param Activity $activity
* @return string
*/
function getAuthorNick($activity)
{
// @fixme not technically part of the actor?
foreach (array($activity->entry, $activity->feed) as $source) {
$author = ActivityUtil::child($source, 'author', Activity::ATOM);
if ($author) {
$name = ActivityUtil::child($author, 'name', Activity::ATOM);
if ($name) {
return trim($name->textContent);
}
}
}
return false;
}
} }

View File

@ -63,22 +63,82 @@ class ActivityUtils
* @return string related link, if any * @return string related link, if any
*/ */
static function getLink($element) static function getPermalink($element)
{
return self::getLink($element, 'alternate', 'text/html');
}
/**
* Get the permalink for an Activity object
*
* @param DOMElement $element A DOM element
*
* @return string related link, if any
*/
static function getLink($element, $rel, $type=null)
{ {
$links = $element->getElementsByTagnameNS(self::ATOM, self::LINK); $links = $element->getElementsByTagnameNS(self::ATOM, self::LINK);
foreach ($links as $link) { foreach ($links as $link) {
$rel = $link->getAttribute(self::REL); $linkRel = $link->getAttribute(self::REL);
$type = $link->getAttribute(self::TYPE); $linkType = $link->getAttribute(self::TYPE);
if ($rel == 'alternate' && $type == 'text/html') { if ($linkRel == $rel &&
(is_null($type) || $linkType == $type)) {
return $link->getAttribute(self::HREF); return $link->getAttribute(self::HREF);
} }
} }
return null; return null;
} }
/**
* Gets the first child element with the given tag
*
* @param DOMElement $element element to pick at
* @param string $tag tag to look for
* @param string $namespace Namespace to look under
*
* @return DOMElement found element or null
*/
static function child($element, $tag, $namespace=self::ATOM)
{
$els = $element->childNodes;
if (empty($els) || $els->length == 0) {
return null;
} else {
for ($i = 0; $i < $els->length; $i++) {
$el = $els->item($i);
if ($el->localName == $tag && $el->namespaceURI == $namespace) {
return $el;
}
}
}
}
/**
* Grab the text content of a DOM element child of the current element
*
* @param DOMElement $element Element whose children we examine
* @param string $tag Tag to look up
* @param string $namespace Namespace to use, defaults to Atom
*
* @return string content of the child
*/
static function childContent($element, $tag, $namespace=self::ATOM)
{
$el = self::child($element, $tag, $namespace);
if (empty($el)) {
return null;
} else {
return $el->textContent;
}
}
} }
/** /**
@ -130,6 +190,7 @@ class ActivityObject
const URI = 'uri'; const URI = 'uri';
const EMAIL = 'email'; const EMAIL = 'email';
public $element;
public $type; public $type;
public $id; public $id;
public $title; public $title;
@ -150,7 +211,7 @@ class ActivityObject
function __construct($element) function __construct($element)
{ {
$this->source = $element; $this->element = $element;
if ($element->tagName == 'author') { if ($element->tagName == 'author') {
@ -179,33 +240,43 @@ class ActivityObject
$this->title = $this->_childContent($element, self::TITLE); $this->title = $this->_childContent($element, self::TITLE);
$this->summary = $this->_childContent($element, self::SUMMARY); $this->summary = $this->_childContent($element, self::SUMMARY);
$this->content = $this->_childContent($element, self::CONTENT); $this->content = $this->_childContent($element, self::CONTENT);
$this->source = $this->_childContent($element, self::SOURCE);
$this->link = ActivityUtils::getLink($element); $this->source = $this->_getSource($element);
$this->link = ActivityUtils::getPermalink($element);
// XXX: grab PoCo stuff // XXX: grab PoCo stuff
} }
// Some per-type attributes...
if ($this->type == self::PERSON || $this->type == self::GROUP) {
$this->displayName = $this->title;
// @fixme we may have multiple avatars with different resolutions specified
$this->avatar = ActivityUtils::getLink($element, 'avatar');
}
} }
/** private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM)
* Grab the text content of a DOM element child of the current element
*
* @param DOMElement $element Element whose children we examine
* @param string $tag Tag to look up
* @param string $namespace Namespace to use, defaults to Atom
*
* @return string content of the child
*/
private function _childContent($element, $tag, $namespace=Activity::ATOM)
{ {
$els = $element->getElementsByTagnameNS($namespace, $tag); return ActivityUtils::childContent($element, $tag, $namespace);
}
if (empty($els) || $els->length == 0) { // Try to get a unique id for the source feed
private function _getSource($element)
{
$sourceEl = ActivityUtils::child($element, 'source');
if (empty($sourceEl)) {
return null; return null;
} else { } else {
$el = $els->item(0); $href = ActivityUtils::getLink($sourceEl, 'self');
return $el->textContent; if (!empty($href)) {
return $href;
} else {
return ActivityUtils::childContent($sourceEl, 'id');
}
} }
} }
} }
@ -306,7 +377,7 @@ class Activity
} }
} }
$this->link = ActivityUtils::getLink($entry); $this->link = ActivityUtils::getPermalink($entry);
$verbEl = $this->_child($entry, self::VERB); $verbEl = $this->_child($entry, self::VERB);
@ -370,24 +441,8 @@ class Activity
return null; return null;
} }
/**
* Gets the first child element with the given tag
*
* @param DOMElement $element element to pick at
* @param string $tag tag to look for
* @param string $namespace Namespace to look under
*
* @return DOMElement found element or null
*/
private function _child($element, $tag, $namespace=self::SPEC) private function _child($element, $tag, $namespace=self::SPEC)
{ {
$els = $element->getElementsByTagnameNS($namespace, $tag); return ActivityUtils::child($element, $tag, $namespace);
if (empty($els) || $els->length == 0) {
return null;
} else {
return $els->item(0);
}
} }
} }

View File

@ -258,11 +258,12 @@ class FeedMunger
{ {
// hack hack hack // hack hack hack
// should get profile for this entry's author... // should get profile for this entry's author...
$remote = Ostatus_profile::staticGet('feeduri', $this->getSelfLink()); $feeduri = $this->getSelfLink();
if ($feed) { $remote = Ostatus_profile::staticGet('feeduri', $feeduri);
return $feed->profile_id; if ($remote) {
return $remote->profile_id;
} else { } else {
throw new Exception("Can't find feed profile"); throw new Exception("Can't find feed profile for $feeduri");
} }
} }

View File

@ -0,0 +1,150 @@
<?php
/**
* StatusNet - the distributed open-source microblogging tool
* Copyright (C) 2010, StatusNet, Inc.
*
* Debugging helper plugin -- records detailed data on POSTs to log
*
* PHP version 5
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* @category Sample
* @package StatusNet
* @author Brion Vibber <brionv@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
class PostDebugPlugin extends Plugin
{
/**
* Set to a directory to dump individual items instead of
* sending to the debug log
*/
public $dir=false;
public function onArgsInitialize(&$args)
{
if (isset($_SERVER['REQUEST_METHOD']) &&
$_SERVER['REQUEST_METHOD'] == 'POST') {
$this->doDebug();
}
}
public function onPluginVersion(&$versions)
{
$versions[] = array('name' => 'PostDebug',
'version' => STATUSNET_VERSION,
'author' => 'Brion Vibber',
'homepage' => 'http://status.net/wiki/Plugin:PostDebug',
'rawdescription' =>
_m('Debugging tool to record request details on POST.'));
return true;
}
protected function doDebug()
{
$data = array('timestamp' => gmdate('r'),
'remote_addr' => @$_SERVER['REMOTE_ADDR'],
'url' => @$_SERVER['REQUEST_URI'],
'have_session' => common_have_session(),
'logged_in' => common_logged_in(),
'is_real_login' => common_is_real_login(),
'user' => common_logged_in() ? common_current_user()->nickname : null,
'headers' => $this->getHttpHeaders(),
'post_data' => $this->sanitizePostData($_POST));
$this->saveDebug($data);
}
protected function saveDebug($data)
{
$output = var_export($data, true);
if ($this->dir) {
$file = $this->dir . DIRECTORY_SEPARATOR . $this->logFileName();
file_put_contents($file, $output);
} else {
common_log(LOG_DEBUG, "PostDebug: $output");
}
}
protected function logFileName()
{
$base = common_request_id();
$base = preg_replace('/^(.+?) .*$/', '$1', $base);
$base = str_replace(':', '-', $base);
$base = rawurlencode($base);
return $base;
}
protected function getHttpHeaders()
{
if (function_exists('getallheaders')) {
$headers = getallheaders();
} else {
$headers = array();
$prefix = 'HTTP_';
$prefixLen = strlen($prefix);
foreach ($_SERVER as $key => $val) {
if (substr($key, 0, $prefixLen) == $prefix) {
$header = $this->normalizeHeader(substr($key, $prefixLen));
$headers[$header] = $val;
}
}
}
foreach ($headers as $header => $val) {
if (strtolower($header) == 'cookie') {
$headers[$header] = $this->sanitizeCookies($val);
}
}
return $headers;
}
protected function normalizeHeader($key)
{
return implode('-',
array_map('ucfirst',
explode("_",
strtolower($key))));
}
function sanitizeCookies($val)
{
$blacklist = array(session_name(), 'rememberme');
foreach ($blacklist as $name) {
$val = preg_replace("/(^|;\s*)({$name}=)(.*?)(;|$)/",
"$1$2########$4",
$val);
}
return $val;
}
function sanitizePostData($data)
{
$blacklist = array('password', 'confirm', 'token');
foreach ($data as $key => $val) {
if (in_array($key, $blacklist)) {
$data[$key] = '########';
}
}
return $data;
}
}