diff options
45 files changed, 3002 insertions, 576 deletions
diff --git a/actions/apitimelinefavorites.php b/actions/apitimelinefavorites.php index 1027d97d4..f7f900ddf 100644 --- a/actions/apitimelinefavorites.php +++ b/actions/apitimelinefavorites.php @@ -100,11 +100,11 @@ class ApiTimelineFavoritesAction extends ApiBareAuthAction function showTimeline() { - $profile = $this->user->getProfile(); - $avatar = $profile->getAvatar(AVATAR_PROFILE_SIZE); + $profile = $this->user->getProfile(); + $avatar = $profile->getAvatar(AVATAR_PROFILE_SIZE); - $sitename = common_config('site', 'name'); - $title = sprintf( + $sitename = common_config('site', 'name'); + $title = sprintf( _('%1$s / Favorites from %2$s'), $sitename, $this->user->nickname @@ -112,32 +112,69 @@ class ApiTimelineFavoritesAction extends ApiBareAuthAction $taguribase = common_config('integration', 'taguri'); $id = "tag:$taguribase:Favorites:" . $this->user->id; - $link = common_local_url( - 'favorites', - array('nickname' => $this->user->nickname) - ); - $subtitle = sprintf( + + $subtitle = sprintf( _('%1$s updates favorited by %2$s / %2$s.'), $sitename, $profile->getBestName(), $this->user->nickname ); - $logo = ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_PROFILE_SIZE); + $logo = !empty($avatar) + ? $avatar->displayUrl() + : Avatar::defaultImage(AVATAR_PROFILE_SIZE); switch($this->format) { case 'xml': $this->showXmlTimeline($this->notices); break; case 'rss': - $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $logo); + $link = common_local_url( + 'showfavorites', + array('nickname' => $this->user->nickname) + ); + $this->showRssTimeline( + $this->notices, + $title, + $link, + $subtitle, + null, + $logo + ); break; case 'atom': - $selfuri = common_root_url() . - ltrim($_SERVER['QUERY_STRING'], 'p='); - $this->showAtomTimeline( - $this->notices, $title, $id, $link, $subtitle, - null, $selfuri, $logo + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'showfavorites', + array('nickname' => $this->user->nickname) + ) + ); + + $id = $this->arg('id'); + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; + } + + $atom->addLink( + $this->getSelfUri('ApiTimelineFavorites', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') ); + + $atom->addEntryFromNotices($this->notices); + + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); diff --git a/actions/apitimelinefriends.php b/actions/apitimelinefriends.php index 4e3827bae..0af04fe4f 100644 --- a/actions/apitimelinefriends.php +++ b/actions/apitimelinefriends.php @@ -114,39 +114,71 @@ class ApiTimelineFriendsAction extends ApiBareAuthAction $title = sprintf(_("%s and friends"), $this->user->nickname); $taguribase = common_config('integration', 'taguri'); $id = "tag:$taguribase:FriendsTimeline:" . $this->user->id; - $link = common_local_url( - 'all', array('nickname' => $this->user->nickname) - ); - $subtitle = sprintf( - _('Updates from %1$s and friends on %2$s!'), - $this->user->nickname, $sitename - ); - $logo = ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_PROFILE_SIZE); + + $subtitle = sprintf( + _('Updates from %1$s and friends on %2$s!'), + $this->user->nickname, $sitename + ); + + $logo = (!empty($avatar)) + ? $avatar->displayUrl() + : Avatar::defaultImage(AVATAR_PROFILE_SIZE); switch($this->format) { case 'xml': $this->showXmlTimeline($this->notices); break; case 'rss': - $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $logo); + + $link = common_local_url( + 'all', array( + 'nickname' => $this->user->nickname + ) + ); + + $this->showRssTimeline( + $this->notices, + $title, + $link, + $subtitle, + null, + $logo + ); break; case 'atom': - $target_id = $this->arg('id'); + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); - if (isset($target_id)) { - $selfuri = common_root_url() . - 'api/statuses/friends_timeline/' . - $target_id . '.atom'; - } else { - $selfuri = common_root_url() . - 'api/statuses/friends_timeline.atom'; + $atom->addLink( + common_local_url( + 'all', + array('nickname' => $this->user->nickname) + ) + ); + + $id = $this->arg('id'); + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; } - $this->showAtomTimeline( - $this->notices, $title, $id, $link, - $subtitle, null, $selfuri, $logo - ); + $atom->addLink( + $this->getSelfUri('ApiTimelineFriends', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') + ); + + $atom->addEntryFromNotices($this->notices); + + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); diff --git a/actions/apitimelinegroup.php b/actions/apitimelinegroup.php index af414c680..fd2ed9ff9 100644 --- a/actions/apitimelinegroup.php +++ b/actions/apitimelinegroup.php @@ -130,7 +130,7 @@ class ApiTimelineGroupAction extends ApiPrivateAuthAction case 'atom': $selfuri = common_root_url() . 'api/statusnet/groups/timeline/' . - $this->group->nickname . '.atom'; + $this->group->id . '.atom'; $this->showAtomTimeline( $this->notices, $title, diff --git a/actions/apitimelinehome.php b/actions/apitimelinehome.php index 828eae6cf..ae4168070 100644 --- a/actions/apitimelinehome.php +++ b/actions/apitimelinehome.php @@ -115,39 +115,67 @@ class ApiTimelineHomeAction extends ApiBareAuthAction $title = sprintf(_("%s and friends"), $this->user->nickname); $taguribase = common_config('integration', 'taguri'); $id = "tag:$taguribase:HomeTimeline:" . $this->user->id; - $link = common_local_url( - 'all', array('nickname' => $this->user->nickname) - ); + $subtitle = sprintf( _('Updates from %1$s and friends on %2$s!'), $this->user->nickname, $sitename ); - $logo = ($avatar) ? $avatar->displayUrl() : Avatar::defaultImage(AVATAR_PROFILE_SIZE); + + $logo = (!empty($avatar)) + ? $avatar->displayUrl() + : Avatar::defaultImage(AVATAR_PROFILE_SIZE); switch($this->format) { case 'xml': $this->showXmlTimeline($this->notices); break; case 'rss': - $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $logo); + $link = common_local_url( + 'all', + array('nickname' => $this->user->nickname) + ); + $this->showRssTimeline( + $this->notices, + $title, + $link, + $subtitle, + null, + $logo + ); break; case 'atom': - $target_id = $this->arg('id'); + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); - if (isset($target_id)) { - $selfuri = common_root_url() . - 'api/statuses/home_timeline/' . - $target_id . '.atom'; - } else { - $selfuri = common_root_url() . - 'api/statuses/home_timeline.atom'; + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'all', + array('nickname' => $this->user->nickname) + ) + ); + + $id = $this->arg('id'); + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; } - $this->showAtomTimeline( - $this->notices, $title, $id, $link, - $subtitle, null, $selfuri, $logo + $atom->addLink( + $this->getSelfUri('ApiTimelineHome', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') ); + + $atom->addEntryFromNotices($this->notices); + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); diff --git a/actions/apitimelinementions.php b/actions/apitimelinementions.php index 9dc2162cc..d2e31d0bd 100644 --- a/actions/apitimelinementions.php +++ b/actions/apitimelinementions.php @@ -137,12 +137,36 @@ class ApiTimelineMentionsAction extends ApiBareAuthAction $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $logo); break; case 'atom': - $selfuri = common_root_url() . - ltrim($_SERVER['QUERY_STRING'], 'p='); - $this->showAtomTimeline( - $this->notices, $title, $id, $link, $subtitle, - null, $selfuri, $logo + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'replies', + array('nickname' => $this->user->nickname) + ) + ); + + $id = $this->arg('id'); + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; + } + + $atom->addLink( + $this->getSelfUri('ApiTimelineMentions', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') ); + + $atom->addEntryFromNotices($this->notices); + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); diff --git a/actions/apitimelinepublic.php b/actions/apitimelinepublic.php index 3f4a46c0f..c1fa72a3e 100644 --- a/actions/apitimelinepublic.php +++ b/actions/apitimelinepublic.php @@ -75,6 +75,10 @@ class ApiTimelinePublicAction extends ApiPrivateAuthAction $this->notices = $this->getNotices(); + if ($this->since) { + throw new ServerException("since parameter is disabled for performance; use since_id", 403); + } + return true; } @@ -118,11 +122,28 @@ class ApiTimelinePublicAction extends ApiPrivateAuthAction $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $sitelogo); break; case 'atom': - $selfuri = common_root_url() . 'api/statuses/public_timeline.atom'; - $this->showAtomTimeline( - $this->notices, $title, $id, $link, - $subtitle, null, $selfuri, $sitelogo + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($sitelogo); + $atom->setUpdated('now'); + + $atom->addLink(common_local_url('public')); + + $atom->addLink( + $this->getSelfUri( + 'ApiTimelinePublic', array('format' => 'atom') + ), + array('rel' => 'self', 'type' => 'application/atom+xml') ); + + $atom->addEntryFromNotices($this->notices); + + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); @@ -145,7 +166,7 @@ class ApiTimelinePublicAction extends ApiPrivateAuthAction $notice = Notice::publicStream( ($this->page - 1) * $this->count, $this->count, $this->since_id, - $this->max_id, $this->since + $this->max_id ); while ($notice->fetch()) { diff --git a/actions/apitimelineretweetsofme.php b/actions/apitimelineretweetsofme.php index e4b09e9bd..26706a75e 100644 --- a/actions/apitimelineretweetsofme.php +++ b/actions/apitimelineretweetsofme.php @@ -99,6 +99,8 @@ class ApiTimelineRetweetsOfMeAction extends ApiAuthAction $strm = $this->auth_user->repeatsOfMe($offset, $limit, $this->since_id, $this->max_id); + common_debug(var_export($strm, true)); + switch ($this->format) { case 'xml': $this->showXmlTimeline($strm); @@ -112,10 +114,38 @@ class ApiTimelineRetweetsOfMeAction extends ApiAuthAction $title = sprintf(_("Repeats of %s"), $this->auth_user->nickname); $taguribase = common_config('integration', 'taguri'); $id = "tag:$taguribase:RepeatsOfMe:" . $this->auth_user->id; - $link = common_local_url('showstream', - array('nickname' => $this->auth_user->nickname)); - $this->showAtomTimeline($strm, $title, $id, $link); + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'showstream', + array('nickname' => $this->auth_user->nickname) + ) + ); + + $id = $this->arg('id'); + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; + } + + $atom->addLink( + $this->getSelfUri('ApiTimelineRetweetsOfMe', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') + ); + + $atom->addEntryFromNotices($strm); + + $this->raw($atom->getString()); + break; default: diff --git a/actions/apitimelinetag.php b/actions/apitimelinetag.php index 1427d23b6..5b6ded4c0 100644 --- a/actions/apitimelinetag.php +++ b/actions/apitimelinetag.php @@ -100,10 +100,6 @@ class ApiTimelineTagAction extends ApiPrivateAuthAction $sitename = common_config('site', 'name'); $sitelogo = (common_config('site', 'logo')) ? common_config('site', 'logo') : Theme::path('logo.png'); $title = sprintf(_("Notices tagged with %s"), $this->tag); - $link = common_local_url( - 'tag', - array('tag' => $this->tag) - ); $subtitle = sprintf( _('Updates tagged with %1$s on %2$s!'), $this->tag, @@ -117,23 +113,52 @@ class ApiTimelineTagAction extends ApiPrivateAuthAction $this->showXmlTimeline($this->notices); break; case 'rss': - $this->showRssTimeline($this->notices, $title, $link, $subtitle, null, $sitelogo); - break; - case 'atom': - $selfuri = common_root_url() . - 'api/statusnet/tags/timeline/' . - $this->tag . '.atom'; - $this->showAtomTimeline( + $link = common_local_url( + 'tag', + array('tag' => $this->tag) + ); + $this->showRssTimeline( $this->notices, $title, - $id, $link, $subtitle, null, - $selfuri, $sitelogo ); break; + case 'atom': + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'tag', + array('tag' => $this->tag) + ) + ); + + $aargs = array('format' => 'atom'); + if (!empty($this->tag)) { + $aargs['tag'] = $this->tag; + } + + $atom->addLink( + $this->getSelfUri('ApiTimelineTag', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') + ); + + $atom->addEntryFromNotices($this->notices); + $this->raw($atom->getString()); + + break; case 'json': $this->showJsonTimeline($this->notices); break; diff --git a/actions/apitimelineuser.php b/actions/apitimelineuser.php index ed9104905..d20bb0d20 100644 --- a/actions/apitimelineuser.php +++ b/actions/apitimelineuser.php @@ -145,19 +145,47 @@ class ApiTimelineUserAction extends ApiBareAuthAction ); break; case 'atom': + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $atom = new AtomNoticeFeed(); + + $atom->setId($id); + $atom->setTitle($title); + $atom->setSubtitle($subtitle); + $atom->setLogo($logo); + $atom->setUpdated('now'); + + $atom->addLink( + common_local_url( + 'showstream', + array('nickname' => $this->user->nickname) + ) + ); + $id = $this->arg('id'); - if ($id) { - $selfuri = common_root_url() . - 'api/statuses/user_timeline/' . - rawurlencode($id) . '.atom'; - } else { - $selfuri = common_root_url() . - 'api/statuses/user_timeline.atom'; + $aargs = array('format' => 'atom'); + if (!empty($id)) { + $aargs['id'] = $id; } - $this->showAtomTimeline( - $this->notices, $title, $id, $link, - $subtitle, $suplink, $selfuri, $logo + + $atom->addLink( + $this->getSelfUri('ApiTimelineUser', $aargs), + array('rel' => 'self', 'type' => 'application/atom+xml') + ); + + $atom->addLink( + $suplink, + array( + 'rel' => 'http://api.friendfeed.com/2008/03#sup', + 'type' => 'application/json' + ) ); + + $atom->addEntryFromNotices($this->notices); + + $this->raw($atom->getString()); + break; case 'json': $this->showJsonTimeline($this->notices); diff --git a/actions/showgroup.php b/actions/showgroup.php index 8042a4951..eb1238902 100644 --- a/actions/showgroup.php +++ b/actions/showgroup.php @@ -330,13 +330,13 @@ class ShowgroupAction extends GroupDesignAction new Feed(Feed::RSS2, common_local_url('ApiTimelineGroup', array('format' => 'rss', - 'id' => $this->group->nickname)), + 'id' => $this->group->id)), sprintf(_('Notice feed for %s group (RSS 2.0)'), $this->group->nickname)), new Feed(Feed::ATOM, common_local_url('ApiTimelineGroup', array('format' => 'atom', - 'id' => $this->group->nickname)), + 'id' => $this->group->id)), sprintf(_('Notice feed for %s group (Atom)'), $this->group->nickname)), new Feed(Feed::FOAF, diff --git a/classes/Nonce.php b/classes/Nonce.php index 486a65a3c..2f8ab00b5 100644 --- a/classes/Nonce.php +++ b/classes/Nonce.php @@ -22,4 +22,19 @@ class Nonce extends Memcached_DataObject /* the code above is auto generated do not remove the tag below */ ###END_AUTOCODE + + /** + * Compatibility hack for PHP 5.3 + * + * The statusnet.links.ini entry cannot be read because "," is no longer + * allowed in key names when read by parse_ini_file(). + * + * @return array + * @access public + */ + function links() + { + return array('consumer_key,token' => 'token:consumer_key,token'); + } + } diff --git a/classes/Notice.php b/classes/Notice.php index fca1c599c..924931e42 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -783,7 +783,7 @@ class Notice extends Memcached_DataObject $result = $gi->insert(); - if (!result) { + if (!$result) { common_log_db_error($gi, 'INSERT', __FILE__); throw new ServerException(_('Problem saving group inbox.')); } @@ -917,7 +917,7 @@ class Notice extends Memcached_DataObject /** * Same calculation as saveGroups but without the saving * @fixme merge the functions - * @return array of Group objects + * @return array of Group_inbox objects */ function getGroups() { @@ -957,7 +957,10 @@ class Notice extends Memcached_DataObject if ($namespace) { $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom', - 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0'); + 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0', + 'xmlns:georss' => 'http://www.georss.org/georss', + 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/', + 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0'); } else { $attrs = array(); } @@ -983,11 +986,6 @@ class Notice extends Memcached_DataObject $xs->element('icon', null, $profile->avatarUrl(AVATAR_PROFILE_SIZE)); } - $xs->elementStart('author'); - $xs->element('name', null, $profile->nickname); - $xs->element('uri', null, $profile->profileurl); - $xs->elementEnd('author'); - if ($source) { $xs->elementEnd('source'); } @@ -995,6 +993,9 @@ class Notice extends Memcached_DataObject $xs->element('title', null, $this->content); $xs->element('summary', null, $this->content); + $xs->raw($profile->asAtomAuthor()); + $xs->raw($profile->asActivityActor()); + $xs->element('link', array('rel' => 'alternate', 'href' => $this->bestUrl())); @@ -1014,6 +1015,43 @@ class Notice extends Memcached_DataObject } } + if (!empty($this->conversation) + && $this->conversation != $this->notice->id) { + $xs->element( + 'link', array( + 'rel' => 'ostatus:conversation', + 'href' => common_local_url( + 'conversation', + array('id' => $this->conversation) + ) + ) + ); + } + + $reply_ids = $this->getReplies(); + + foreach ($reply_ids as $id) { + $profile = Profile::staticGet('id', $id); + if (!empty($profile)) { + $xs->element( + 'link', array( + 'rel' => 'ostatus:attention', + 'href' => $profile->getAcctUri() + ) + ); + } + } + + if (!empty($this->repeat_of)) { + $repeat = Notice::staticGet('id', $this->repeat_of); + if (!empty($repeat)) { + $xs->element( + 'ostatus:forward', + array('ref' => $repeat->uri, 'href' => $repeat->bestUrl()) + ); + } + } + $xs->element('content', array('type' => 'html'), $this->rendered); $tag = new Notice_tag(); @@ -1041,9 +1079,7 @@ class Notice extends Memcached_DataObject } if (!empty($this->lat) && !empty($this->lon)) { - $xs->elementStart('geo', array('xmlns:georss' => 'http://www.georss.org/georss')); $xs->element('georss:point', null, $this->lat . ' ' . $this->lon); - $xs->elementEnd('geo'); } $xs->elementEnd('entry'); diff --git a/classes/Profile.php b/classes/Profile.php index feabc2508..ab05bb854 100644 --- a/classes/Profile.php +++ b/classes/Profile.php @@ -754,4 +754,89 @@ class Profile extends Memcached_DataObject return !empty($notice); } + + /** + * Returns an XML string fragment with limited profile information + * as an Atom <author> element. + * + * Assumes that Atom has been previously set up as the base namespace. + * + * @return string + */ + function asAtomAuthor() + { + $xs = new XMLStringer(true); + + $xs->elementStart('author'); + $xs->element('name', null, $this->nickname); + $xs->element('uri', null, $this->profileurl); + $xs->elementEnd('author'); + + return $xs->getString(); + } + + /** + * Returns an XML string fragment with profile information as an + * Activity Streams <activity:actor> element. + * + * Assumes that 'activity' namespace has been previously defined. + * + * @return string + */ + function asActivityActor() + { + return $this->asActivityNoun('actor'); + } + + /** + * Returns an XML string fragment with profile information as an + * Activity Streams noun object with the given element type. + * + * Assumes that 'activity' namespace has been previously defined. + * + * @param string $element one of 'actor', 'subject', 'object', 'target' + * @return string + */ + function asActivityNoun($element) + { + $xs = new XMLStringer(true); + + $xs->elementStart('activity:' . $element); + $xs->element( + 'activity:object-type', + null, + 'http://activitystrea.ms/schema/1.0/person' + ); + $xs->element( + 'id', + null, + common_local_url( + 'userbyid', + array('id' => $this->id) + ) + ); + $xs->element('title', null, $this->getBestName()); + + $avatar = $this->getAvatar(AVATAR_PROFILE_SIZE); + + $xs->element( + 'link', array( + 'type' => empty($avatar) ? 'image/png' : $avatar->mediatype, + 'href' => empty($avatar) + ? Avatar::defaultImage(AVATAR_PROFILE_SIZE) + : $avatar->displayUrl() + ), + '' + ); + + $xs->elementEnd('activity:' . $element); + + return $xs->getString(); + } + + function getAcctUri() + { + return $this->nickname . '@' . common_config('site', 'server'); + } + } diff --git a/classes/User_group.php b/classes/User_group.php index c86eadf8f..1fbb50a6e 100644 --- a/classes/User_group.php +++ b/classes/User_group.php @@ -49,12 +49,12 @@ class User_group extends Memcached_DataObject array('id' => $this->id)); } - function getNotices($offset, $limit) + function getNotices($offset, $limit, $since_id=null, $max_id=null) { $ids = Notice::stream(array($this, '_streamDirect'), array(), 'user_group:notice_ids:' . $this->id, - $offset, $limit); + $offset, $limit, $since_id, $max_id); return Notice::getStreamByIds($ids); } diff --git a/classes/statusnet.links.ini b/classes/statusnet.links.ini index 7f233e676..b9dd5af0c 100644 --- a/classes/statusnet.links.ini +++ b/classes/statusnet.links.ini @@ -19,8 +19,11 @@ profile_id = profile:id [token] consumer_key = consumer:consumer_key -[nonce] -consumer_key,token = token:consumer_key,token +; Compatibility hack for PHP 5.3 +; This entry has been moved to the class definition, as commas are no longer +; considered valid in keys, causing parse_ini_file() to reject the whole file. +;[nonce] +;consumer_key,token = token:consumer_key,token [confirm_address] user_id = user:id diff --git a/lib/api.php b/lib/api.php index b987badc0..5758cc874 100644 --- a/lib/api.php +++ b/lib/api.php @@ -1322,4 +1322,22 @@ class ApiAction extends Action } } + function getSelfUri($action, $aargs) + { + parse_str($_SERVER['QUERY_STRING'], $params); + $pstring = ''; + if (!empty($params)) { + unset($params['p']); + $pstring = http_build_query($params); + } + + $uri = common_local_url($action, $aargs); + + if (!empty($pstring)) { + $uri .= '?' . $pstring; + } + + return $uri; + } + } diff --git a/lib/atom10entry.php b/lib/atom10entry.php new file mode 100644 index 000000000..5710c80fc --- /dev/null +++ b/lib/atom10entry.php @@ -0,0 +1,106 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Class for building / manipulating an Atom entry in memory + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET') +{ + exit(1); +} + +class Atom10EntryException extends Exception +{ +} + +/** + * Class for manipulating an Atom entry in memory. Get the entry as an XML + * string with Atom10Entry::getString(). + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ +class Atom10Entry extends XMLStringer +{ + private $namespaces; + private $categories; + private $content; + private $contributors; + private $id; + private $links; + private $published; + private $rights; + private $source; + private $summary; + private $title; + + function __construct($indent = true) { + parent::__construct($indent); + $this->namespaces = array(); + } + + function addNamespace($namespace, $uri) + { + $ns = array($namespace => $uri); + $this->namespaces = array_merge($this->namespaces, $ns); + } + + function initEntry() + { + + } + + function endEntry() + { + + } + + /** + * Check that all required elements have been set, etc. + * Throws an Atom10EntryException if something's missing. + * + * @return void + */ + function validate + { + + } + + function getString() + { + $this->validate(); + + $this->initEntry(); + $this->renderEntries(); + $this->endEntry(); + + return $this->xw->outputMemory(); + } + +}
\ No newline at end of file diff --git a/lib/atom10feed.php b/lib/atom10feed.php new file mode 100644 index 000000000..ccca76a09 --- /dev/null +++ b/lib/atom10feed.php @@ -0,0 +1,227 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Class for building an Atom feed in memory + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) +{ + exit(1); +} + +class Atom10FeedException extends Exception +{ +} + +/** + * Class for building an Atom feed in memory. Get the finished doc + * as a string with Atom10Feed::getString(). + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ +class Atom10Feed extends XMLStringer +{ + public $xw; + private $namespaces; + private $authors; + private $categories; + private $contributors; + private $generator; + private $icon; + private $links; + private $logo; + private $rights; + private $subtitle; + private $title; + private $published; + private $updated; + private $entries; + + /** + * Constructor + * + * @param boolean $indent flag to turn indenting on or off + * + * @return void + */ + function __construct($indent = true) { + parent::__construct($indent); + $this->namespaces = array(); + $this->links = array(); + $this->entries = array(); + $this->addNamespace('xmlns', 'http://www.w3.org/2005/Atom'); + } + + /** + * Add another namespace to the feed + * + * @param string $namespace the namespace + * @param string $uri namspace uri + * + * @return void + */ + function addNamespace($namespace, $uri) + { + $ns = array($namespace => $uri); + $this->namespaces = array_merge($this->namespaces, $ns); + } + + function getNamespaces() + { + return $this->namespaces; + } + + function initFeed() + { + $this->xw->startDocument('1.0', 'UTF-8'); + $commonAttrs = array('xml:lang' => 'en-US'); + $commonAttrs = array_merge($commonAttrs, $this->namespaces); + $this->elementStart('feed', $commonAttrs); + + $this->element('id', null, $this->id); + $this->element('title', null, $this->title); + $this->element('subtitle', null, $this->subtitle); + + if (!empty($this->logo)) { + $this->element('logo', null, $this->logo); + } + + $this->element('updated', null, $this->updated); + + $this->renderLinks(); + } + + /** + * Check that all required elements have been set, etc. + * Throws an Atom10FeedException if something's missing. + * + * @return void + */ + function validate() + { + } + + function renderLinks() + { + foreach ($this->links as $attrs) + { + $this->element('link', $attrs, null); + } + } + + function addEntryRaw($entry) + { + array_push($this->entries, $entry); + } + + function addEntry($entry) + { + array_push($this->entries, $entry->getString()); + } + + function renderEntries() + { + foreach ($this->entries as $entry) { + $this->raw($entry); + } + } + + function endFeed() + { + $this->elementEnd('feed'); + $this->xw->endDocument(); + } + + function getString() + { + $this->validate(); + + $this->initFeed(); + $this->renderEntries(); + $this->endFeed(); + + return $this->xw->outputMemory(); + } + + function setId($id) + { + $this->id = $id; + } + + function setTitle($title) + { + $this->title = $title; + } + + function setSubtitle($subtitle) + { + $this->subtitle = $subtitle; + } + + function setLogo($logo) + { + $this->logo = $logo; + } + + function setUpdated($dt) + { + $this->updated = common_date_iso8601($dt); + } + + function setPublished($dt) + { + $this->published = common_date_iso8601($dt); + } + + /** + * Adds a link element into the Atom document + * + * Assumes you want rel="alternate" and type="text/html" unless + * you send in $otherAttrs. + * + * @param string $uri the uri the href needs to point to + * @param array $otherAttrs other attributes to stick in + * + * @return void + */ + function addLink($uri, $otherAttrs = null) { + $attrs = array('href' => $uri); + + if (is_null($otherAttrs)) { + $attrs['rel'] = 'alternate'; + $attrs['type'] = 'text/html'; + } else { + $attrs = array_merge($attrs, $otherAttrs); + } + + array_push($this->links, $attrs); + } + +} diff --git a/lib/atomnoticefeed.php b/lib/atomnoticefeed.php new file mode 100644 index 000000000..34ed44b2e --- /dev/null +++ b/lib/atomnoticefeed.php @@ -0,0 +1,103 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * Class for building and Atom feed from a collection of notices + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) +{ + exit(1); +} + +/** + * Class for creating a feed that represents a collection of notices. Builds the + * feed in memory. Get the feed as a string with AtomNoticeFeed::getString(). + * + * @category Feed + * @package StatusNet + * @author Zach Copley <zach@status.net> + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html GNU Affero General Public License version 3.0 + * @link http://status.net/ + */ +class AtomNoticeFeed extends Atom10Feed +{ + function __construct($indent = true) { + parent::__construct($indent); + + // Feeds containing notice info use these namespaces + + $this->addNamespace( + 'xmlns:thr', + 'http://purl.org/syndication/thread/1.0' + ); + + $this->addNamespace( + 'xmlns:georss', + 'http://www.georss.org/georss' + ); + + $this->addNamespace( + 'xmlns:activity', + 'http://activitystrea.ms/spec/1.0/' + ); + + // XXX: What should the uri be? + $this->addNamespace( + 'xmlns:ostatus', + 'http://ostatus.org/schema/1.0' + ); + } + + /** + * Add more than one Notice to the feed + * + * @param mixed $notices an array of Notice objects or handle + * + */ + function addEntryFromNotices($notices) + { + if (is_array($notices)) { + foreach ($notices as $notice) { + $this->addEntryFromNotice($notice); + } + } else { + while ($notices->fetch()) { + $this->addEntryFromNotice($notices); + } + } + } + + /** + * Add a single Notice to the feed + * + * @param Notice $notice a Notice to add + */ + function addEntryFromNotice($notice) + { + $this->addEntryRaw($notice->asAtomEntry()); + } + +} diff --git a/lib/default.php b/lib/default.php index 16d1330f0..cc6863488 100644 --- a/lib/default.php +++ b/lib/default.php @@ -88,6 +88,7 @@ $default = 'stomp_manual_failover' => true, // if multiple servers are listed, treat them as separate (enqueue on one randomly, listen on all) 'monitor' => null, // URL to monitor ping endpoint (work in progress) 'softlimit' => '90%', // total size or % of memory_limit at which to restart queue threads gracefully + 'spawndelay' => 1, // Wait at least N seconds between (re)spawns of child processes to avoid slamming the queue server with subscription startup 'debug_memory' => false, // true to spit memory usage to log 'inboxes' => true, // true to do inbox distribution & output queueing from in background via 'distrib' queue ), diff --git a/lib/queuemanager.php b/lib/queuemanager.php index 274e1c2f6..64bb52e10 100644 --- a/lib/queuemanager.php +++ b/lib/queuemanager.php @@ -155,26 +155,26 @@ abstract class QueueManager extends IoManager } /** - * Encode an object for queued storage. - * Next gen may use serialization. + * Encode an object or variable for queued storage. + * Notice objects are currently stored as an id reference; + * other items are serialized. * - * @param mixed $object + * @param mixed $item * @return string */ - protected function encode($object) + protected function encode($item) { - if ($object instanceof Notice) { - return $object->id; - } else if (is_string($object)) { - return $object; + if ($item instanceof Notice) { + // Backwards compat + return $item->id; } else { - throw new ServerException("Can't queue this type", 500); + return serialize($item); } } /** * Decode an object from queued storage. - * Accepts back-compat notice reference entries and strings for now. + * Accepts notice reference entries and serialized items. * * @param string * @return mixed @@ -182,9 +182,23 @@ abstract class QueueManager extends IoManager protected function decode($frame) { if (is_numeric($frame)) { + // Back-compat for notices... return Notice::staticGet(intval($frame)); - } else { + } elseif (substr($frame, 0, 1) == '<') { + // Back-compat for XML source return $frame; + } else { + // Deserialize! + #$old = error_reporting(); + #error_reporting($old & ~E_NOTICE); + $out = unserialize($frame); + #error_reporting($old); + + if ($out === false && $frame !== 'b:0;') { + common_log(LOG_ERR, "Couldn't unserialize queued frame: $frame"); + return false; + } + return $out; } } diff --git a/lib/spawningdaemon.php b/lib/spawningdaemon.php index b1961d688..862cbb4fa 100644 --- a/lib/spawningdaemon.php +++ b/lib/spawningdaemon.php @@ -83,6 +83,7 @@ abstract class SpawningDaemon extends Daemon $this->log(LOG_INFO, "Spawned thread $i as pid $pid"); $children[$i] = $pid; } + sleep(common_config('queue', 'spawndelay')); } $this->log(LOG_INFO, "Waiting for children to complete."); @@ -111,6 +112,7 @@ abstract class SpawningDaemon extends Daemon $this->log(LOG_INFO, "Respawned thread $i as pid $pid"); $children[$i] = $pid; } + sleep(common_config('queue', 'spawndelay')); } else { $this->log(LOG_INFO, "Thread $i pid $pid exited with status $exitCode; closing out thread."); } diff --git a/lib/stompqueuemanager.php b/lib/stompqueuemanager.php index 6730cd213..cd62c25bd 100644 --- a/lib/stompqueuemanager.php +++ b/lib/stompqueuemanager.php @@ -107,9 +107,10 @@ class StompQueueManager extends QueueManager $message .= ':' . $param; } $this->_connect(); - $result = $this->_send($this->control, - $message, - array ('created' => common_sql_now())); + $con = $this->cons[$this->defaultIdx]; + $result = $con->send($this->control, + $message, + array ('created' => common_sql_now())); if ($result) { $this->_log(LOG_INFO, "Sent control ping to queue daemons: $message"); return true; @@ -368,17 +369,10 @@ class StompQueueManager extends QueueManager foreach ($this->cons as $i => $con) { if ($con) { $this->rollback($i); - $con->unsubscribe($this->control); + $con->disconnect(); + $this->cons[$i] = null; } } - if ($this->sites) { - foreach ($this->sites as $server) { - StatusNet::init($server); - $this->doUnsubscribe(); - } - } else { - $this->doUnsubscribe(); - } return true; } @@ -555,26 +549,14 @@ class StompQueueManager extends QueueManager } $host = $this->cons[$idx]->getServer(); - if (is_numeric($frame->body)) { - $id = intval($frame->body); - $info = "notice $id posted at {$frame->headers['created']} in queue $queue from $host"; - - $notice = Notice::staticGet('id', $id); - if (empty($notice)) { - $this->_log(LOG_WARNING, "Skipping missing $info"); - $this->ack($idx, $frame); - $this->commit($idx); - $this->begin($idx); - $this->stats('badnotice', $queue); - return false; - } - - $item = $notice; - } else { - // @fixme should we serialize, or json, or what here? - $info = "string posted at {$frame->headers['created']} in queue $queue from $host"; - $item = $frame->body; + $item = $this->decode($frame->body); + if (empty($item)) { + $this->_log(LOG_ERR, "Skipping empty or deleted item in queue $queue from $host"); + return true; } + $info = $this->logrep($item) . " posted at " . + $frame->headers['created'] . " in queue $queue from $host"; + $this->_log(LOG_DEBUG, "Dequeued $info"); $handler = $this->getHandler($queue); if (!$handler) { diff --git a/lib/util.php b/lib/util.php index 879834a3d..e255c5fe0 100644 --- a/lib/util.php +++ b/lib/util.php @@ -690,7 +690,7 @@ function common_group_link($sender_id, $nickname) { $sender = Profile::staticGet($sender_id); $group = User_group::getForNickname($nickname); - if ($group && $sender->isMember($group)) { + if ($sender && $group && $sender->isMember($group)) { $attrs = array('href' => $group->permalink(), 'class' => 'url'); if (!empty($group->fullname)) { diff --git a/plugins/OStatus/OStatusPlugin.php b/plugins/OStatus/OStatusPlugin.php index 4e8b892c6..8444c3d73 100644 --- a/plugins/OStatus/OStatusPlugin.php +++ b/plugins/OStatus/OStatusPlugin.php @@ -53,6 +53,21 @@ class OStatusPlugin extends Plugin */ function onRouterInitialized($m) { + // Discovery actions + $m->connect('.well-known/host-meta', + array('action' => 'hostmeta')); + $m->connect('main/webfinger', + array('action' => 'webfinger')); + $m->connect('main/ostatus', + array('action' => 'ostatusinit')); + $m->connect('main/ostatus?nickname=:nickname', + array('action' => 'ostatusinit'), array('nickname' => '[A-Za-z0-9_-]+')); + $m->connect('main/ostatussub', + array('action' => 'ostatussub')); + $m->connect('main/ostatussub', + array('action' => 'ostatussub'), array('feed' => '[A-Za-z0-9\.\/\:]+')); + + // PuSH actions $m->connect('main/push/hub', array('action' => 'pushhub')); $m->connect('main/push/callback/:feed', @@ -60,6 +75,14 @@ class OStatusPlugin extends Plugin array('feed' => '[0-9]+')); $m->connect('settings/feedsub', array('action' => 'feedsubsettings')); + + // Salmon endpoint + $m->connect('main/salmon/user/:id', + array('action' => 'salmon'), + array('id' => '[0-9]+')); + $m->connect('main/salmon/group/:id', + array('action' => 'salmongroup'), + array('id' => '[0-9]+')); return true; } @@ -87,22 +110,37 @@ class OStatusPlugin extends Plugin /** * Set up a PuSH hub link to our internal link for canonical timeline - * Atom feeds for users. + * Atom feeds for users and groups. */ function onStartApiAtom(Action $action) { if ($action instanceof ApiTimelineUserAction) { - $id = $action->arg('id'); - if (strval(intval($id)) === strval($id)) { - // Canonical form of id in URL? - // Updates will be handled for our internal PuSH hub. - $action->element('link', array('rel' => 'hub', - 'href' => common_local_url('pushhub'))); + $salmonAction = 'salmon'; + } else if ($action instanceof ApiTimelineGroupAction) { + $salmonAction = 'salmongroup'; + } else { + return; + } + + $id = $action->arg('id'); + if (strval(intval($id)) === strval($id)) { + // Canonical form of id in URL? These are used for OStatus syndication. + + $hub = common_config('ostatus', 'hub'); + if (empty($hub)) { + // Updates will be handled through our internal PuSH hub. + $hub = common_local_url('pushhub'); } + $action->element('link', array('rel' => 'hub', + 'href' => $hub)); + + // Also, we'll add in the salmon link + $salmon = common_local_url($salmonAction, array('id' => $id)); + $action->element('link', array('rel' => 'salmon', + 'href' => $salmon)); } - return true; } - + /** * Add the feed settings page to the Connect Settings menu * @@ -148,11 +186,90 @@ class OStatusPlugin extends Plugin return true; } + /** + * Add in an OStatus subscribe button + */ + function onStartProfilePageActionsElements($output, $profile) + { + $cur = common_current_user(); + + if (empty($cur)) { + // Add an OStatus subscribe + $output->elementStart('li', 'entity_subscribe'); + $url = common_local_url('ostatusinit', + array('nickname' => $profile->nickname)); + $output->element('a', array('href' => $url, + 'class' => 'entity_remote_subscribe'), + _m('OStatus')); + + $output->elementEnd('li'); + } + } + + /** + * Check if we've got remote replies to send via Salmon. + * + * @fixme push webfinger lookup & sending to a background queue + * @fixme also detect short-form name for remote subscribees where not ambiguous + */ + function onEndNoticeSave($notice) + { + $count = preg_match_all('/(\w+\.)*\w+@(\w+\.)*\w+(\w+\-\w+)*\.\w+/', $notice->content, $matches); + if ($count) { + foreach ($matches[0] as $webfinger) { + // Check to see if we've got an actual webfinger + $w = new Webfinger; + + $endpoint_uri = ''; + + $result = $w->lookup($webfinger); + if (empty($result)) { + continue; + } + + foreach ($result->links as $link) { + if ($link['rel'] == 'salmon') { + $endpoint_uri = $link['href']; + } + } + + if (empty($endpoint_uri)) { + continue; + } + + $xml = '<?xml version="1.0" encoding="UTF-8" ?>'; + $xml .= $notice->asAtomEntry(); + + $salmon = new Salmon(); + $salmon->post($endpoint_uri, $xml); + } + } + } + + /** + * Garbage collect unused feeds on unsubscribe + */ + function onEndUnsubscribe($user, $other) + { + $profile = Ostatus_profile::staticGet('profile_id', $other->id); + if ($feed) { + $sub = new Subscription(); + $sub->subscribed = $other->id; + $sub->limit(1); + if (!$sub->find(true)) { + common_log(LOG_INFO, "Unsubscribing from now-unused feed $feed->feeduri on hub $feed->huburi"); + $profile->unsubscribe(); + } + } + return true; + } + + /** + * Make sure necessary tables are filled out. + */ function onCheckSchema() { - // warning: the autoincrement doesn't seem to set. - // alter table feedinfo change column id id int(11) not null auto_increment; $schema = Schema::get(); - $schema->ensureTable('feedinfo', Feedinfo::schemaDef()); + $schema->ensureTable('ostatus_profile', Ostatus_profile::schemaDef()); $schema->ensureTable('hubsub', HubSub::schemaDef()); return true; } diff --git a/plugins/OStatus/actions/feedsubsettings.php b/plugins/OStatus/actions/feedsubsettings.php index 4d5b7b60f..6933c9bf2 100644 --- a/plugins/OStatus/actions/feedsubsettings.php +++ b/plugins/OStatus/actions/feedsubsettings.php @@ -182,9 +182,9 @@ class FeedSubSettingsAction extends ConnectSettingsAction } $this->munger = $discover->feedMunger(); - $this->feedinfo = $this->munger->feedInfo(); + $this->profile = $this->munger->ostatusProfile(); - if ($this->feedinfo->huburi == '' && !common_config('feedsub', 'nohub')) { + if ($this->profile->huburi == '' && !common_config('feedsub', 'nohub')) { $this->showForm(_m('Feed is not PuSH-enabled; cannot subscribe.')); return false; } @@ -196,33 +196,44 @@ class FeedSubSettingsAction extends ConnectSettingsAction { if ($this->validateFeed()) { $this->preview = true; - $this->feedinfo = Feedinfo::ensureProfile($this->munger); + $this->profile = Ostatus_profile::ensureProfile($this->munger); + if (!$this->profile) { + throw new ServerException("Feed profile was not saved properly."); + } // If not already in use, subscribe to updates via the hub - if ($this->feedinfo->sub_start) { - common_log(LOG_INFO, __METHOD__ . ": double the fun! new sub for {$this->feedinfo->feeduri} last subbed {$this->feedinfo->sub_start}"); + if ($this->profile->sub_start) { + common_log(LOG_INFO, __METHOD__ . ": double the fun! new sub for {$this->profile->feeduri} last subbed {$this->profile->sub_start}"); } else { - $ok = $this->feedinfo->subscribe(); + $ok = $this->profile->subscribe(); common_log(LOG_INFO, __METHOD__ . ": sub was $ok"); if (!$ok) { $this->showForm(_m('Feed subscription failed! Bad response from hub.')); return; } } - + // And subscribe the current user to the local profile $user = common_current_user(); - $profile = $this->feedinfo->getProfile(); - if (!$profile) { - throw new ServerException("Feed profile was not saved properly."); - } - if ($user->isSubscribed($profile)) { - $this->showForm(_m('Already subscribed!')); - } elseif ($user->subscribeTo($profile)) { - $this->showForm(_m('Feed subscribed!')); + if ($this->profile->isGroup()) { + $group = $this->profile->localGroup(); + if ($user->isMember($group)) { + $this->showForm(_m('Already a member!')); + } elseif (Group_member::join($this->profile->group_id, $user->id)) { + $this->showForm(_m('Joined remote group!')); + } else { + $this->showForm(_m('Remote group join failed!')); + } } else { - $this->showForm(_m('Feed subscription failed!')); + $local = $this->profile->localProfile(); + if ($user->isSubscribed($local)) { + $this->showForm(_m('Already subscribed!')); + } elseif ($user->subscribeTo($local)) { + $this->showForm(_m('Feed subscribed!')); + } else { + $this->showForm(_m('Feed subscription failed!')); + } } } } @@ -237,7 +248,7 @@ class FeedSubSettingsAction extends ConnectSettingsAction function previewFeed() { - $feedinfo = $this->munger->feedinfo(); + $profile = $this->munger->ostatusProfile(); $notice = $this->munger->notice(0, true); // preview if ($notice) { diff --git a/plugins/OStatus/actions/hostmeta.php b/plugins/OStatus/actions/hostmeta.php new file mode 100644 index 000000000..850b8a0fe --- /dev/null +++ b/plugins/OStatus/actions/hostmeta.php @@ -0,0 +1,42 @@ +<?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') && !defined('LACONICA')) { exit(1); } + +class HostMetaAction extends Action +{ + + function handle() + { + parent::handle(); + + $w = new Webfinger(); + + + $domain = common_config('site', 'server'); + $url = common_local_url('webfinger'); + $url.= '?uri={uri}'; + print $w->getHostMeta($domain, $url); + } +} diff --git a/plugins/OStatus/actions/ostatusinit.php b/plugins/OStatus/actions/ostatusinit.php new file mode 100644 index 000000000..bac2c4d43 --- /dev/null +++ b/plugins/OStatus/actions/ostatusinit.php @@ -0,0 +1,128 @@ +<?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') && !defined('LACONICA')) { exit(1); } + + +class OStatusInitAction extends Action +{ + + var $nickname; + var $acct; + var $err; + + function prepare($args) + { + parent::prepare($args); + + if (common_logged_in()) { + $this->clientError(_('You can use the local subscription!')); + return false; + } + + $this->nickname = $this->trimmed('nickname'); + $this->acct = $this->trimmed('acct'); + + return true; + } + + function handle($args) + { + parent::handle($args); + + if ($_SERVER['REQUEST_METHOD'] == 'POST') { + /* Use a session token for CSRF protection. */ + $token = $this->trimmed('token'); + if (!$token || $token != common_session_token()) { + $this->showForm(_('There was a problem with your session token. '. + 'Try again, please.')); + return; + } + $this->ostatusConnect(); + } else { + $this->showForm(); + } + } + + function showForm($err = null) + { + $this->err = $err; + $this->showPage(); + + } + + function showContent() + { + $this->elementStart('form', array('id' => 'form_ostatus_connect', + 'method' => 'post', + 'class' => 'form_settings', + 'action' => common_local_url('ostatusinit'))); + $this->elementStart('fieldset'); + $this->element('legend', _('Subscribe to a remote user')); + $this->hidden('token', common_session_token()); + + $this->elementStart('ul', 'form_data'); + $this->elementStart('li'); + $this->input('nickname', _('User nickname'), $this->nickname, + _('Nickname of the user you want to follow')); + $this->elementEnd('li'); + $this->elementStart('li'); + $this->input('acct', _('Profile Account'), $this->acct, + _('Your account id (i.e. user@identi.ca)')); + $this->elementEnd('li'); + $this->elementEnd('ul'); + $this->submit('submit', _('Subscribe')); + $this->elementEnd('fieldset'); + $this->elementEnd('form'); + } + + function ostatusConnect() + { + $w = new Webfinger; + + $result = $w->lookup($this->acct); + foreach ($result->links as $link) { + if ($link['rel'] == 'http://ostatus.org/schema/1.0/subscribe') { + // We found a URL - let's redirect! + + $user = User::staticGet('nickname', $this->nickname); + + $feed_url = common_local_url('ApiTimelineUser', + array('id' => $user->id, + 'format' => 'atom')); + $url = $w->applyTemplate($link['template'], $feed_url); + + common_redirect($url, 303); + } + + } + + } + + function title() + { + return _('OStatus Connect'); + } + +}
\ No newline at end of file diff --git a/plugins/OStatus/actions/ostatussub.php b/plugins/OStatus/actions/ostatussub.php new file mode 100644 index 000000000..9774286fd --- /dev/null +++ b/plugins/OStatus/actions/ostatussub.php @@ -0,0 +1,226 @@ +<?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') && !defined('LACONICA')) { exit(1); } + +class OStatusSubAction extends Action +{ + + protected $feedurl; + + function title() + { + return _m("OStatus Subscribe"); + } + + function handle($args) + { + if ($this->validateFeed()) { + $this->showForm(); + } + + return true; + + } + + function showForm($err = null) + { + $this->err = $err; + $this->showPage(); + } + + + function showContent() + { + $user = common_current_user(); + + $profile = $user->getProfile(); + + $fuser = null; + + $flink = Foreign_link::getByUserID($user->id, FEEDSUB_SERVICE); + + if (!empty($flink)) { + $fuser = $flink->getForeignUser(); + } + + $this->elementStart('form', array('method' => 'post', + 'id' => 'form_settings_feedsub', + 'class' => 'form_settings', + 'action' => + common_local_url('feedsubsettings'))); + + $this->hidden('token', common_session_token()); + + $this->elementStart('fieldset', array('id' => 'settings_feeds')); + + $this->elementStart('ul', 'form_data'); + $this->elementStart('li', array('id' => 'settings_twitter_login_button')); + $this->input('feedurl', _('Feed URL'), $this->feedurl, _('Enter the URL of a PubSubHubbub-enabled feed')); + $this->elementEnd('li'); + $this->elementEnd('ul'); + + $this->submit('subscribe', _m('Subscribe')); + + $this->elementEnd('fieldset'); + + $this->elementEnd('form'); + + $this->previewFeed(); + } + + /** + * Handle posts to this form + * + * Based on the button that was pressed, muxes out to other functions + * to do the actual task requested. + * + * All sub-functions reload the form with a message -- success or failure. + * + * @return void + */ + + function handlePost() + { + // CSRF protection + $token = $this->trimmed('token'); + if (!$token || $token != common_session_token()) { + $this->showForm(_('There was a problem with your session token. '. + 'Try again, please.')); + return; + } + + if ($this->arg('subscribe')) { + $this->saveFeed(); + } else { + $this->showForm(_('Unexpected form submission.')); + } + } + + + /** + * Set up and add a feed + * + * @return boolean true if feed successfully read + * Sends you back to input form if not. + */ + function validateFeed() + { + $feedurl = $this->trimmed('feed'); + + if ($feedurl == '') { + $this->showForm(_m('Empty feed URL!')); + return; + } + $this->feedurl = $feedurl; + + // Get the canonical feed URI and check it + try { + $discover = new FeedDiscovery(); + $uri = $discover->discoverFromURL($feedurl); + } catch (FeedSubBadURLException $e) { + $this->showForm(_m('Invalid URL or could not reach server.')); + return false; + } catch (FeedSubBadResponseException $e) { + $this->showForm(_m('Cannot read feed; server returned error.')); + return false; + } catch (FeedSubEmptyException $e) { + $this->showForm(_m('Cannot read feed; server returned an empty page.')); + return false; + } catch (FeedSubBadHTMLException $e) { + $this->showForm(_m('Bad HTML, could not find feed link.')); + return false; + } catch (FeedSubNoFeedException $e) { + $this->showForm(_m('Could not find a feed linked from this URL.')); + return false; + } catch (FeedSubUnrecognizedTypeException $e) { + $this->showForm(_m('Not a recognized feed type.')); + return false; + } catch (FeedSubException $e) { + // Any new ones we forgot about + $this->showForm(_m('Bad feed URL.')); + return false; + } + + $this->munger = $discover->feedMunger(); + $this->profile = $this->munger->ostatusProfile(); + + if ($this->profile->huburi == '') { + $this->showForm(_m('Feed is not PuSH-enabled; cannot subscribe.')); + return false; + } + + return true; + } + + function saveFeed() + { + if ($this->validateFeed()) { + $this->preview = true; + $this->profile = Ostatus_profile::ensureProfile($this->munger); + + // If not already in use, subscribe to updates via the hub + if ($this->profile->sub_start) { + common_log(LOG_INFO, __METHOD__ . ": double the fun! new sub for {$this->profile->feeduri} last subbed {$this->profile->sub_start}"); + } else { + $ok = $this->profile->subscribe(); + common_log(LOG_INFO, __METHOD__ . ": sub was $ok"); + if (!$ok) { + $this->showForm(_m('Feed subscription failed! Bad response from hub.')); + return; + } + } + + // And subscribe the current user to the local profile + $user = common_current_user(); + $profile = $this->profile->getProfile(); + + if ($user->isSubscribed($profile)) { + $this->showForm(_m('Already subscribed!')); + } elseif ($user->subscribeTo($profile)) { + $this->showForm(_m('Feed subscribed!')); + } else { + $this->showForm(_m('Feed subscription failed!')); + } + } + } + + + function previewFeed() + { + $profile = $this->munger->ostatusProfile(); + $notice = $this->munger->notice(0, true); // preview + + if ($notice) { + $this->element('b', null, 'Preview of latest post from this feed:'); + + $item = new NoticeList($notice, $this); + $item->show(); + } else { + $this->element('b', null, 'No posts in this feed yet.'); + } + } + + +}
\ No newline at end of file diff --git a/plugins/OStatus/actions/pushcallback.php b/plugins/OStatus/actions/pushcallback.php index a5e02e08f..2601a377a 100644 --- a/plugins/OStatus/actions/pushcallback.php +++ b/plugins/OStatus/actions/pushcallback.php @@ -48,9 +48,9 @@ class PushCallbackAction extends Action throw new ServerException('Empty or invalid feed id', 400); } - $feedinfo = Feedinfo::staticGet('id', $feedid); - if (!$feedinfo) { - throw new ServerException('Unknown feed id ' . $feedid, 400); + $profile = Ostatus_profile::staticGet('id', $feedid); + if (!$profile) { + throw new ServerException('Unknown OStatus/PuSH feed id ' . $feedid, 400); } $hmac = ''; @@ -59,7 +59,7 @@ class PushCallbackAction extends Action } $post = file_get_contents('php://input'); - $feedinfo->postUpdates($post, $hmac); + $profile->postUpdates($post, $hmac); } /** @@ -78,28 +78,30 @@ class PushCallbackAction extends Action throw new ServerException("Bogus hub callback: bad mode", 404); } - $feedinfo = Feedinfo::staticGet('feeduri', $topic); - if (!$feedinfo) { + $profile = Ostatus_profile::staticGet('feeduri', $topic); + if (!$profile) { common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback for unknown feed $topic"); throw new ServerException("Bogus hub callback: unknown feed", 404); } - # Can't currently set the token in our sub api - #if ($feedinfo->verify_token !== $verify_token) { - # common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with bad token \"$verify_token\" for feed $topic"); - # throw new ServerError("Bogus hub callback: bad token", 404); - #} - + if ($profile->verify_token !== $verify_token) { + common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with bad token \"$verify_token\" for feed $topic"); + throw new ServerError("Bogus hub callback: bad token", 404); + } + + if ($mode != $profile->sub_state) { + common_log(LOG_WARNING, __METHOD__ . ": bogus hub callback with bad mode \"$mode\" for feed $topic in state \"{$profile->sub_state}\""); + throw new ServerException("Bogus hub callback: mode doesn't match subscription state.", 404); + } + // OK! - common_log(LOG_INFO, __METHOD__ . ': sub confirmed'); - $feedinfo->sub_start = common_sql_date(time()); - if ($lease_seconds > 0) { - $feedinfo->sub_end = common_sql_date(time() + $lease_seconds); + if ($mode == 'subscribe') { + common_log(LOG_INFO, __METHOD__ . ': sub confirmed'); + $profile->confirmSubscribe($lease_seconds); } else { - $feedinfo->sub_end = null; + common_log(LOG_INFO, __METHOD__ . ": unsub confirmed; deleting sub record for $topic"); + $profile->confirmUnsubscribe(); } - $feedinfo->update(); - print $challenge; } } diff --git a/plugins/OStatus/actions/salmon.php b/plugins/OStatus/actions/salmon.php new file mode 100644 index 000000000..b616027a9 --- /dev/null +++ b/plugins/OStatus/actions/salmon.php @@ -0,0 +1,81 @@ +<?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 + * @author James Walker <james@status.net> + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class SalmonAction extends Action +{ + var $user = null; + var $xml = null; + var $activity = null; + + function prepare($args) + { + if ($_SERVER['REQUEST_METHOD'] != 'POST') { + $this->clientError(_('This method requires a POST.')); + } + + if ($_SERVER['CONTENT_TYPE'] != 'application/atom+xml') { + $this->clientError(_('Salmon requires application/atom+xml')); + } + + $id = $this->trimmed('id'); + + if (!$id) { + $this->clientError(_('No ID.')); + } + + $this->user = User::staticGet($id); + + if (empty($this->user)) { + $this->clientError(_('No such user.')); + } + + $xml = file_get_contents('php://input'); + + $dom = DOMDocument::loadXML($xml); + + // XXX: check that document element is Atom entry + // XXX: check the signature + + $this->act = Activity::fromAtomEntry($dom->documentElement); + } + + function handle($args) + { + common_log(LOG_DEBUG, 'Salmon: incoming post for user: '. $user_id); + + // TODO : Insert new $xml -> notice code + + switch ($this->act->verb) + { + case Activity::POST: + case Activity::SHARE: + case Activity::FAVORITE: + case Activity::FOLLOW: + } + } +} diff --git a/plugins/OStatus/actions/webfinger.php b/plugins/OStatus/actions/webfinger.php new file mode 100644 index 000000000..75ba16638 --- /dev/null +++ b/plugins/OStatus/actions/webfinger.php @@ -0,0 +1,77 @@ +<?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') && !defined('LACONICA')) { exit(1); } + +class WebfingerAction extends Action +{ + + public $uri; + + function prepare($args) + { + parent::prepare($args); + + $this->uri = $this->trimmed('uri'); + + return true; + } + + function handle() + { + $acct = Webfinger::normalize($this->uri); + + $xrd = new XRD(); + + list($nick, $domain) = explode('@', urldecode($acct)); + $nick = common_canonical_nickname($nick); + + $this->user = User::staticGet('nickname', $nick); + if (!$this->user) { + $this->clientError(_('No such user.'), 404); + return false; + } + + $xrd->subject = $this->uri; + $xrd->alias[] = common_profile_url($nick); + $xrd->links[] = array('rel' => 'http://webfinger.net/rel/profile-page', + 'type' => 'text/html', + 'href' => common_profile_url($nick)); + + $salmon_url = common_local_url('salmon', + array('id' => $this->user->id)); + + $xrd->links[] = array('rel' => 'salmon', + 'href' => $salmon_url); + + // TODO - finalize where the redirect should go on the publisher + $url = common_local_url('ostatussub') . '?feed={uri}'; + $xrd->links[] = array('rel' => 'http://ostatus.org/schema/1.0/subscribe', + 'template' => $url ); + + header('Content-type: text/xml'); + print $xrd->toXML(); + } + +} diff --git a/plugins/OStatus/classes/Feedinfo.php b/plugins/OStatus/classes/Feedinfo.php deleted file mode 100644 index 107faf012..000000000 --- a/plugins/OStatus/classes/Feedinfo.php +++ /dev/null @@ -1,345 +0,0 @@ -<?php -/* - * StatusNet - the distributed open-source microblogging tool - * Copyright (C) 2009-2010, StatusNet, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see <http://www.gnu.org/licenses/>. - */ - -/** - * @package FeedSubPlugin - * @maintainer Brion Vibber <brion@status.net> - */ - -/* -PuSH subscription flow: - - $feedinfo->subscribe() - generate random verification token - save to verify_token - sends a sub request to the hub... - - feedsub/callback - hub sends confirmation back to us via GET - We verify the request, then echo back the challenge. - On our end, we save the time we subscribed and the lease expiration - - feedsub/callback - hub sends us updates via POST - -*/ - -class FeedDBException extends FeedSubException -{ - public $obj; - - function __construct($obj) - { - parent::__construct('Database insert failure'); - $this->obj = $obj; - } -} - -class Feedinfo extends Memcached_DataObject -{ - public $__table = 'feedinfo'; - - public $id; - public $profile_id; - - public $feeduri; - public $homeuri; - public $huburi; - - // PuSH subscription data - public $secret; - public $verify_token; - public $sub_start; - public $sub_end; - - public $created; - public $lastupdate; - - - public /*static*/ function staticGet($k, $v=null) - { - return parent::staticGet(__CLASS__, $k, $v); - } - - /** - * return table definition for DB_DataObject - * - * DB_DataObject needs to know something about the table to manipulate - * instances. This method provides all the DB_DataObject needs to know. - * - * @return array array of column definitions - */ - - function table() - { - return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, - 'profile_id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, - 'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, - 'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, - 'huburi' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, - 'secret' => DB_DATAOBJECT_STR, - 'verify_token' => DB_DATAOBJECT_STR, - 'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, - 'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, - 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL, - 'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); - } - - static function schemaDef() - { - return array(new ColumnDef('id', 'integer', - /*size*/ null, - /*nullable*/ false, - /*key*/ 'PRI', - /*default*/ '0', - /*extra*/ null, - /*auto_increment*/ true), - new ColumnDef('profile_id', 'integer', - null, false), - new ColumnDef('feeduri', 'varchar', - 255, false, 'UNI'), - new ColumnDef('homeuri', 'varchar', - 255, false), - new ColumnDef('huburi', 'varchar', - 255, false), - new ColumnDef('verify_token', 'varchar', - 32, true), - new ColumnDef('secret', 'varchar', - 64, true), - new ColumnDef('sub_start', 'datetime', - null, true), - new ColumnDef('sub_end', 'datetime', - null, true), - new ColumnDef('created', 'datetime', - null, false), - new ColumnDef('lastupdate', 'datetime', - null, false)); - } - - /** - * return key definitions for DB_DataObject - * - * DB_DataObject needs to know about keys that the table has; this function - * defines them. - * - * @return array key definitions - */ - - function keys() - { - return array_keys($this->keyTypes()); - } - - /** - * return key definitions for Memcached_DataObject - * - * Our caching system uses the same key definitions, but uses a different - * method to get them. - * - * @return array key definitions - */ - - function keyTypes() - { - return array('id' => 'K'); // @fixme we'll need a profile_id key at least - } - - function sequenceKey() - { - return array('id', true, false); - } - - /** - * Fetch the StatusNet-side profile for this feed - * @return Profile - */ - public function getProfile() - { - return Profile::staticGet('id', $this->profile_id); - } - - /** - * @param FeedMunger $munger - * @return Feedinfo - */ - public static function ensureProfile($munger) - { - $feedinfo = $munger->feedinfo(); - - $current = self::staticGet('feeduri', $feedinfo->feeduri); - if ($current) { - // @fixme we should probably update info as necessary - return $current; - } - - $feedinfo->query('BEGIN'); - - // Awful hack! Awful hack! - $feedinfo->verify = common_good_rand(16); - $feedinfo->secret = common_good_rand(32); - - try { - $profile = $munger->profile(); - $result = $profile->insert(); - if (empty($result)) { - throw new FeedDBException($profile); - } - - $avatar = $munger->getAvatar(); - if ($avatar) { - // @fixme this should be better encapsulated - // ripped from oauthstore.php (for old OMB client) - $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar'); - copy($avatar, $temp_filename); - $imagefile = new ImageFile($profile->id, $temp_filename); - $filename = Avatar::filename($profile->id, - image_type_to_extension($imagefile->type), - null, - common_timestamp()); - rename($temp_filename, Avatar::path($filename)); - $profile->setOriginal($filename); - } - - $feedinfo->profile_id = $profile->id; - $result = $feedinfo->insert(); - if (empty($result)) { - throw new FeedDBException($feedinfo); - } - - $feedinfo->query('COMMIT'); - } catch (FeedDBException $e) { - common_log_db_error($e->obj, 'INSERT', __FILE__); - $feedinfo->query('ROLLBACK'); - return false; - } - return $feedinfo; - } - - /** - * Send a subscription request to the hub for this feed. - * The hub will later send us a confirmation POST to /feedsub/callback. - * - * @return bool true on success, false on failure - */ - public function subscribe() - { - if (common_config('feedsub', 'nohub')) { - // Fake it! We're just testing remote feeds w/o hubs. - return true; - } - // @fixme use the verification token - #$token = md5(mt_rand() . ':' . $this->feeduri); - #$this->verify_token = $token; - #$this->update(); // @fixme - try { - $callback = common_local_url('pushcallback', array('feed' => $this->id)); - $headers = array('Content-Type: application/x-www-form-urlencoded'); - $post = array('hub.mode' => 'subscribe', - 'hub.callback' => $callback, - 'hub.verify' => 'async', - 'hub.verify_token' => $this->verify_token, - 'hub.secret' => $this->secret, - //'hub.lease_seconds' => 0, - 'hub.topic' => $this->feeduri); - $client = new HTTPClient(); - $response = $client->post($this->huburi, $headers, $post); - $status = $response->getStatus(); - if ($status == 202) { - common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback'); - return true; - } else if ($status == 204) { - common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified'); - return true; - } else if ($status >= 200 && $status < 300) { - common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody()); - return false; - } else { - common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody()); - return false; - } - } catch (Exception $e) { - // wtf! - common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri"); - return false; - } - } - - /** - * Read and post notices for updates from the feed. - * Currently assumes that all items in the feed are new, - * coming from a PuSH hub. - * - * @param string $xml source of Atom or RSS feed - * @param string $hmac X-Hub-Signature header, if present - */ - public function postUpdates($xml, $hmac) - { - common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml"); - - if ($this->secret) { - if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { - $their_hmac = strtolower($matches[1]); - $our_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; - } - - require_once "XML/Feed/Parser.php"; - $feed = new XML_Feed_Parser($xml, false, false, true); - $munger = new FeedMunger($feed); - - $hits = 0; - foreach ($feed as $index => $entry) { - // @fixme this might sort in wrong order if we get multiple updates - - $notice = $munger->notice($index); - $notice->profile_id = $this->profile_id; - - // Double-check for oldies - // @fixme this could explode horribly for multiple feeds on a blog. sigh - $dupe = new Notice(); - $dupe->uri = $notice->uri; - if ($dupe->find(true)) { - common_log(LOG_WARNING, __METHOD__ . ": tried to save dupe notice for entry {$notice->uri} of feed {$this->feeduri}"); - continue; - } - - if (Event::handle('StartNoticeSave', array(&$notice))) { - $id = $notice->insert(); - Event::handle('EndNoticeSave', array($notice)); - } - $notice->addToInboxes(); - - common_log(LOG_INFO, __METHOD__ . ": saved notice {$notice->id} for entry $index of update to \"{$this->feeduri}\""); - $hits++; - } - if ($hits == 0) { - common_log(LOG_INFO, __METHOD__ . ": no updates in packet for \"$this->feeduri\"! $xml"); - } - } -} diff --git a/plugins/OStatus/classes/HubSub.php b/plugins/OStatus/classes/HubSub.php index 1769f6c94..7071ee5b4 100644 --- a/plugins/OStatus/classes/HubSub.php +++ b/plugins/OStatus/classes/HubSub.php @@ -242,7 +242,7 @@ class HubSub extends Memcached_DataObject { $headers = array('Content-Type: application/atom+xml'); if ($this->secret) { - $hmac = sha1($atom . $this->secret); + $hmac = hash_hmac('sha1', $atom, $this->secret); $headers[] = "X-Hub-Signature: sha1=$hmac"; } else { $hmac = '(none)'; diff --git a/plugins/OStatus/classes/Ostatus_profile.php b/plugins/OStatus/classes/Ostatus_profile.php new file mode 100644 index 000000000..733d8843b --- /dev/null +++ b/plugins/OStatus/classes/Ostatus_profile.php @@ -0,0 +1,644 @@ +<?php +/* + * StatusNet - the distributed open-source microblogging tool + * Copyright (C) 2009-2010, StatusNet, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +/** + * @package FeedSubPlugin + * @maintainer Brion Vibber <brion@status.net> + */ + +/* +PuSH subscription flow: + + $profile->subscribe() + generate random verification token + save to verify_token + sends a sub request to the hub... + + main/push/callback + hub sends confirmation back to us via GET + We verify the request, then echo back the challenge. + On our end, we save the time we subscribed and the lease expiration + + main/push/callback + hub sends us updates via POST + +*/ + +class FeedDBException extends FeedSubException +{ + public $obj; + + function __construct($obj) + { + parent::__construct('Database insert failure'); + $this->obj = $obj; + } +} + +class Ostatus_profile extends Memcached_DataObject +{ + public $__table = 'ostatus_profile'; + + public $id; + public $profile_id; + public $group_id; + + public $feeduri; + public $homeuri; + + // PuSH subscription data + public $huburi; + public $secret; + public $verify_token; + public $sub_state; // subscribe, active, unsubscribe + public $sub_start; + public $sub_end; + + public $salmonuri; + + public $created; + public $lastupdate; + + + public /*static*/ function staticGet($k, $v=null) + { + return parent::staticGet(__CLASS__, $k, $v); + } + + /** + * return table definition for DB_DataObject + * + * DB_DataObject needs to know something about the table to manipulate + * instances. This method provides all the DB_DataObject needs to know. + * + * @return array array of column definitions + */ + + function table() + { + return array('id' => DB_DATAOBJECT_INT + DB_DATAOBJECT_NOTNULL, + 'profile_id' => DB_DATAOBJECT_INT, + 'group_id' => DB_DATAOBJECT_INT, + 'feeduri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'homeuri' => DB_DATAOBJECT_STR + DB_DATAOBJECT_NOTNULL, + 'huburi' => DB_DATAOBJECT_STR, + 'secret' => DB_DATAOBJECT_STR, + 'verify_token' => DB_DATAOBJECT_STR, + 'sub_state' => DB_DATAOBJECT_STR, + 'sub_start' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, + 'sub_end' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME, + 'salmonuri' => DB_DATAOBJECT_STR, + 'created' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL, + 'lastupdate' => DB_DATAOBJECT_STR + DB_DATAOBJECT_DATE + DB_DATAOBJECT_TIME + DB_DATAOBJECT_NOTNULL); + } + + static function schemaDef() + { + return array(new ColumnDef('id', 'integer', + /*size*/ null, + /*nullable*/ false, + /*key*/ 'PRI', + /*default*/ '0', + /*extra*/ null, + /*auto_increment*/ true), + new ColumnDef('profile_id', 'integer', + null, true, 'UNI'), + new ColumnDef('group_id', 'integer', + null, true, 'UNI'), + new ColumnDef('feeduri', 'varchar', + 255, false, 'UNI'), + new ColumnDef('homeuri', 'varchar', + 255, false), + new ColumnDef('huburi', 'text', + null, true), + new ColumnDef('verify_token', 'varchar', + 32, true), + new ColumnDef('secret', 'varchar', + 64, true), + new ColumnDef('sub_state', "enum('subscribe','active','unsubscribe')", + null, true), + new ColumnDef('sub_start', 'datetime', + null, true), + new ColumnDef('sub_end', 'datetime', + null, true), + new ColumnDef('salmonuri', 'text', + null, true), + new ColumnDef('created', 'datetime', + null, false), + new ColumnDef('lastupdate', 'datetime', + null, false)); + } + + /** + * return key definitions for DB_DataObject + * + * DB_DataObject needs to know about keys that the table has; this function + * defines them. + * + * @return array key definitions + */ + + function keys() + { + return array_keys($this->keyTypes()); + } + + /** + * return key definitions for Memcached_DataObject + * + * Our caching system uses the same key definitions, but uses a different + * method to get them. + * + * @return array key definitions + */ + + function keyTypes() + { + return array('id' => 'K', 'profile_id' => 'U', 'group_id' => 'U', 'feeduri' => 'U'); + } + + function sequenceKey() + { + return array('id', true, false); + } + + /** + * Fetch the StatusNet-side profile for this feed + * @return Profile + */ + public function localProfile() + { + if ($this->profile_id) { + return Profile::staticGet('id', $this->profile_id); + } + return null; + } + + /** + * Fetch the StatusNet-side profile for this feed + * @return Profile + */ + public function localGroup() + { + if ($this->group_id) { + return User_group::staticGet('id', $this->group_id); + } + return null; + } + + /** + * @param FeedMunger $munger + * @param boolean $isGroup is this a group record? + * @return Ostatus_profile + */ + public static function ensureProfile($munger) + { + $profile = $munger->ostatusProfile(); + + $current = self::staticGet('feeduri', $profile->feeduri); + if ($current) { + // @fixme we should probably update info as necessary + return $current; + } + + $profile->query('BEGIN'); + + // Awful hack! Awful hack! + $profile->verify = common_good_rand(16); + $profile->secret = common_good_rand(32); + + try { + $local = $munger->profile(); + + if ($entity->isGroup()) { + $group = new User_group(); + $group->nickname = $local->nickname . '@remote'; // @fixme + $group->fullname = $local->fullname; + $group->homepage = $local->homepage; + $group->location = $local->location; + $group->created = $local->created; + $group->insert(); + if (empty($result)) { + throw new FeedDBException($group); + } + $profile->group_id = $group->id; + } else { + $result = $local->insert(); + if (empty($result)) { + throw new FeedDBException($local); + } + $profile->profile_id = $local->id; + } + + $profile->created = sql_common_date(); + $profile->lastupdate = sql_common_date(); + $result = $profile->insert(); + if (empty($result)) { + throw new FeedDBException($profile); + } + + $entity->query('COMMIT'); + } catch (FeedDBException $e) { + common_log_db_error($e->obj, 'INSERT', __FILE__); + $entity->query('ROLLBACK'); + return false; + } + + $avatar = $munger->getAvatar(); + if ($avatar) { + try { + $this->updateAvatar($avatar); + } catch (Exception $e) { + common_log(LOG_ERR, "Exception setting OStatus avatar: " . + $e->getMessage()); + } + } + + return $entity; + } + + /** + * Download and update given avatar image + * @param string $url + * @throws Exception in various failure cases + */ + public function updateAvatar($url) + { + // @fixme this should be better encapsulated + // ripped from oauthstore.php (for old OMB client) + $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar'); + copy($url, $temp_filename); + $imagefile = new ImageFile($profile->id, $temp_filename); + $filename = Avatar::filename($profile->id, + image_type_to_extension($imagefile->type), + null, + common_timestamp()); + rename($temp_filename, Avatar::path($filename)); + if ($this->isGroup()) { + $group = $this->localGroup(); + $group->setOriginal($filename); + } else { + $profile = $this->localProfile(); + $profile->setOriginal($filename); + } + } + + /** + * Returns an XML string fragment with profile information as an + * Activity Streams noun object with the given element type. + * + * Assumes that 'activity' namespace has been previously defined. + * + * @param string $element one of 'actor', 'subject', 'object', 'target' + * @return string + */ + function asActivityNoun($element) + { + $xs = new XMLStringer(true); + + $avatarHref = Avatar::defaultImage(AVATAR_PROFILE_SIZE); + $avatarType = 'image/png'; + if ($this->isGroup()) { + $type = 'http://activitystrea.ms/schema/1.0/group'; + $self = $this->localGroup(); + + // @fixme put a standard getAvatar() interface on groups too + if ($self->homepage_logo) { + $avatarHref = $self->homepage_logo; + $map = array('png' => 'image/png', + 'jpg' => 'image/jpeg', + 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif'); + $extension = pathinfo(parse_url($avatarHref, PHP_URL_PATH), PATHINFO_EXTENSION); + if (isset($map[$extension])) { + $avatarType = $map[$extension]; + } + } + } else { + $type = 'http://activitystrea.ms/schema/1.0/person'; + $self = $this->localProfile(); + $avatar = $self->getAvatar(AVATAR_PROFILE_SIZE); + if ($avatar) { + $avatarHref = $avatar-> + $avatarType = $avatar->mediatype; + } + } + $xs->elementStart('activity:' . $element); + $xs->element( + 'activity:object-type', + null, + $type + ); + $xs->element( + 'id', + null, + $this->homeuri); // ? + $xs->element('title', null, $self->getBestName()); + + $xs->element( + 'link', array( + 'type' => $avatarType, + 'href' => $avatarHref + ), + '' + ); + + $xs->elementEnd('activity:' . $element); + + return $xs->getString(); + } + + /** + * Damn dirty hack! + */ + function isGroup() + { + return (strpos($this->feeduri, '/groups/') !== false); + } + + /** + * Send a subscription 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 subscribe($mode='subscribe') + { + if (common_config('feedsub', 'nohub')) { + // Fake it! We're just testing remote feeds w/o hubs. + return true; + } + // @fixme use the verification token + #$token = md5(mt_rand() . ':' . $this->feeduri); + #$this->verify_token = $token; + #$this->update(); // @fixme + try { + $callback = common_local_url('pushcallback', array('feed' => $this->id)); + $headers = array('Content-Type: application/x-www-form-urlencoded'); + $post = array('hub.mode' => $mode, + 'hub.callback' => $callback, + 'hub.verify' => 'async', + 'hub.verify_token' => $this->verify_token, + 'hub.secret' => $this->secret, + //'hub.lease_seconds' => 0, + 'hub.topic' => $this->feeduri); + $client = new HTTPClient(); + $response = $client->post($this->huburi, $headers, $post); + $status = $response->getStatus(); + if ($status == 202) { + common_log(LOG_INFO, __METHOD__ . ': sub req ok, awaiting verification callback'); + return true; + } else if ($status == 204) { + common_log(LOG_INFO, __METHOD__ . ': sub req ok and verified'); + return true; + } else if ($status >= 200 && $status < 300) { + common_log(LOG_ERR, __METHOD__ . ": sub req returned unexpected HTTP $status: " . $response->getBody()); + return false; + } else { + common_log(LOG_ERR, __METHOD__ . ": sub req failed with HTTP $status: " . $response->getBody()); + return false; + } + } catch (Exception $e) { + // wtf! + common_log(LOG_ERR, __METHOD__ . ": error \"{$e->getMessage()}\" hitting hub $this->huburi subscribing to $this->feeduri"); + return false; + } + } + + /** + * Save PuSH subscription confirmation. + * Sets approximate lease start and end times and finalizes state. + * + * @param int $lease_seconds provided hub.lease_seconds parameter, if given + */ + public function confirmSubscribe($lease_seconds=0) + { + $original = clone($this); + + $this->sub_state = 'active'; + $this->sub_start = common_sql_date(time()); + if ($lease_seconds > 0) { + $this->sub_end = common_sql_date(time() + $lease_seconds); + } else { + $this->sub_end = null; + } + $this->lastupdate = common_sql_date(); + + return $this->update($original); + } + + /** + * Save PuSH unsubscription confirmation. + * Wipes active PuSH sub info and resets state. + */ + public function confirmUnsubscribe() + { + $original = clone($this); + + $this->verify_token = null; + $this->secret = null; + $this->sub_state = null; + $this->sub_start = null; + $this->sub_end = null; + $this->lastupdate = common_sql_date(); + + 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, + * if so configured. + * + * @param Profile $actor + * @param $verb eg Activity::SUBSCRIBE or Activity::JOIN + * @param $object object of the action; if null, the remote entity itself is assumed + */ + public function notify(Profile $actor, $verb, $object=null) + { + if ($object == null) { + $object = $this; + } + if ($this->salmonuri) { + $text = 'update'; // @fixme + $id = 'tag:' . common_config('site', 'server') . + ':' . $verb . + ':' . $actor->id . + ':' . time(); // @fixme + + $entry = new Atom10Entry(); + $entry->elementStart('entry'); + $entry->element('id', null, $id); + $entry->element('title', null, $text); + $entry->element('summary', null, $text); + $entry->element('published', null, common_date_w3dtf()); + + $entry->element('activity:verb', null, $verb); + $entry->raw($profile->asAtomAuthor()); + $entry->raw($profile->asActivityActor()); + $entry->raw($object->asActivityNoun('object')); + $entry->elmentEnd('entry'); + + $feed = $this->atomFeed($actor); + $feed->initFeed(); + $feed->addEntry($entry); + $feed->renderEntries(); + $feed->endFeed(); + + $xml = $feed->getString(); + common_log(LOG_INFO, "Posting to Salmon endpoint $salmon: $xml"); + + $salmon = new Salmon(); // ? + $salmon->post($this->salmonuri, $xml); + } + } + + function getBestName() + { + if ($this->isGroup()) { + return $this->localGroup()->getBestName(); + } else { + return $this->localProfile()->getBestName(); + } + } + + function atomFeed($actor) + { + $feed = new Atom10Feed(); + // @fixme should these be set up somewhere else? + $feed->addNamespace('activity', 'http://activitystrea.ms/spec/1.0/'); + $feed->addNamesapce('thr', 'http://purl.org/syndication/thread/1.0'); + $feed->addNamespace('georss', 'http://www.georss.org/georss'); + $feed->addNamespace('ostatus', 'http://ostatus.org/schema/1.0'); + + $taguribase = common_config('integration', 'taguri'); + $feed->setId("tag:{$taguribase}:UserTimeline:{$actor->id}"); // ??? + + $feed->setTitle($actor->getBestName() . ' timeline'); // @fixme + $feed->setUpdated(time()); + $feed->setPublished(time()); + + $feed->addLink(common_url('ApiTimelineUser', + array('id' => $actor->id, + 'type' => 'atom')), + array('rel' => 'self', + 'type' => 'application/atom+xml')); + + $feed->addLink(common_url('userbyid', + array('id' => $actor->id)), + array('rel' => 'alternate', + 'type' => 'text/html')); + + return $feed; + } + + /** + * Read and post notices for updates from the feed. + * Currently assumes that all items in the feed are new, + * coming from a PuSH hub. + * + * @param string $xml source of Atom or RSS feed + * @param string $hmac X-Hub-Signature header, if present + */ + public function postUpdates($xml, $hmac) + { + common_log(LOG_INFO, __METHOD__ . ": packet for \"$this->feeduri\"! $hmac $xml"); + + if ($this->secret) { + if (preg_match('/^sha1=([0-9a-fA-F]{40})$/', $hmac, $matches)) { + $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; + } + + require_once "XML/Feed/Parser.php"; + $feed = new XML_Feed_Parser($xml, false, false, true); + $munger = new FeedMunger($feed); + + $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 = new Notice(); + $dupe->uri = $notice->uri; + if ($dupe->find(true)) { + 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"); + } + } +} diff --git a/plugins/OStatus/lib/activity.php b/plugins/OStatus/lib/activity.php new file mode 100644 index 000000000..36e227913 --- /dev/null +++ b/plugins/OStatus/lib/activity.php @@ -0,0 +1,85 @@ +<?php +/** + * StatusNet, the distributed open-source microblogging tool + * + * An activity + * + * PHP version 5 + * + * LICENCE: This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + * @category 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/ + */ + +if (!defined('STATUSNET')) { + exit(1); +} + +class ActivityNoun +{ + const ARTICLE = 'http://activitystrea.ms/schema/1.0/article'; + const BLOGENTRY = 'http://activitystrea.ms/schema/1.0/blog-entry'; + const NOTE = 'http://activitystrea.ms/schema/1.0/note'; + const STATUS = 'http://activitystrea.ms/schema/1.0/status'; + const FILE = 'http://activitystrea.ms/schema/1.0/file'; + const PHOTO = 'http://activitystrea.ms/schema/1.0/photo'; + const ALBUM = 'http://activitystrea.ms/schema/1.0/photo-album'; + const PLAYLIST = 'http://activitystrea.ms/schema/1.0/playlist'; + const VIDEO = 'http://activitystrea.ms/schema/1.0/video'; + const AUDIO = 'http://activitystrea.ms/schema/1.0/audio'; + const BOOKMARK = 'http://activitystrea.ms/schema/1.0/bookmark'; + const PERSON = 'http://activitystrea.ms/schema/1.0/person'; + const GROUP = 'http://activitystrea.ms/schema/1.0/group'; + const PLACE = 'http://activitystrea.ms/schema/1.0/place'; + const COMMENT = 'http://activitystrea.ms/schema/1.0/comment'; // tea + + public $type; + public $id; + public $title; + public $summary; + public $content; +} + +class Activity +{ + const NAMESPACE = 'http://activitystrea.ms/schema/1.0/'; + + const POST = 'http://activitystrea.ms/schema/1.0/post'; + const SHARE = 'http://activitystrea.ms/schema/1.0/share'; + const SAVE = 'http://activitystrea.ms/schema/1.0/save'; + const FAVORITE = 'http://activitystrea.ms/schema/1.0/favorite'; + const PLAY = 'http://activitystrea.ms/schema/1.0/play'; + const FOLLOW = 'http://activitystrea.ms/schema/1.0/follow'; + const FRIEND = 'http://activitystrea.ms/schema/1.0/make-friend'; + const JOIN = 'http://activitystrea.ms/schema/1.0/join'; + const TAG = 'http://activitystrea.ms/schema/1.0/tag'; + + public $actor; // an ActivityNoun + public $verb; // a string (the URL) + public $object; // an ActivityNoun + public $target; // an ActivityNoun + + static function fromAtomEntry($domEntry) + { + } + + function toAtomEntry() + { + } +} diff --git a/plugins/OStatus/lib/feedmunger.php b/plugins/OStatus/lib/feedmunger.php index cbaec6775..c895b6ce2 100644 --- a/plugins/OStatus/lib/feedmunger.php +++ b/plugins/OStatus/lib/feedmunger.php @@ -83,13 +83,17 @@ class FeedMunger $this->url = $url; } - function feedinfo() + function ostatusProfile() { - $feedinfo = new Feedinfo(); - $feedinfo->feeduri = $this->url; - $feedinfo->homeuri = $this->feed->link; - $feedinfo->huburi = $this->getHubLink(); - return $feedinfo; + $profile = new Ostatus_profile(); + $profile->feeduri = $this->url; + $profile->homeuri = $this->feed->link; + $profile->huburi = $this->getHubLink(); + $salmon = $this->getSalmonLink(); + if ($salmon) { + $profile->salmonuri = $salmon; + } + return $profile; } function getAtomLink($item, $attribs=array()) @@ -155,6 +159,16 @@ class FeedMunger return $this->getAtomLink($this->feed, array('rel' => 'hub')); } + function getSalmonLink() + { + return $this->getAtomLink($this->feed, array('rel' => 'salmon')); + } + + function getSelfLink() + { + return $this->getAtomLink($this->feed, array('rel' => 'self')); + } + /** * Get an appropriate avatar image source URL, if available. * @return mixed string or false @@ -203,12 +217,13 @@ class FeedMunger if (!$entry) { return null; } - + if ($preview) { $notice = new FeedSubPreviewNotice($this->profile(true)); $notice->id = -1; } else { $notice = new Notice(); + $notice->profile_id = $this->profileIdForEntry($index); } $link = $this->getAltLink($entry); @@ -221,7 +236,7 @@ class FeedMunger $notice->uri = $link; $notice->url = $link; $notice->content = $this->noticeFromEntry($entry); - $notice->rendered = common_render_content($notice->content, $notice); + $notice->rendered = common_render_content($notice->content, $notice); // @fixme this is failing on group posts $notice->created = common_sql_date($entry->updated); // @fixme $notice->is_local = Notice::GATEWAY; $notice->source = 'feed'; @@ -239,7 +254,22 @@ class FeedMunger return $notice; } + function profileIdForEntry($index=1) + { + // hack hack hack + // should get profile for this entry's author... + $remote = Ostatus_profile::staticGet('feeduri', $this->getSelfLink()); + if ($feed) { + return $feed->profile_id; + } else { + throw new Exception("Can't find feed profile"); + } + } + /** + * Parse location given as a GeoRSS-simple point, if provided. + * http://www.georss.org/simple + * * @param feed item $entry * @return mixed Location or false */ @@ -249,7 +279,10 @@ class FeedMunger $points = $dom->getElementsByTagNameNS('http://www.georss.org/georss', 'point'); for ($i = 0; $i < $points->length; $i++) { - $point = trim($points->item(0)->textContent); + $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; diff --git a/plugins/OStatus/lib/hubdistribqueuehandler.php b/plugins/OStatus/lib/hubdistribqueuehandler.php index 126f1355f..245a57f72 100644 --- a/plugins/OStatus/lib/hubdistribqueuehandler.php +++ b/plugins/OStatus/lib/hubdistribqueuehandler.php @@ -34,27 +34,101 @@ class HubDistribQueueHandler extends QueueHandler { assert($notice instanceof Notice); + $this->pushUser($notice); + foreach ($notice->getGroups() as $group) { + $this->pushGroup($notice, $group->group_id); + } + return true; + } + + function pushUser($notice) + { // See if there's any PuSH subscriptions, including OStatus clients. // @fixme handle group subscriptions as well // http://identi.ca/api/statuses/user_timeline/1.atom $feed = common_local_url('ApiTimelineUser', array('id' => $notice->profile_id, 'format' => 'atom')); + $this->pushFeed($feed, array($this, 'userFeedForNotice'), $notice); + } + + function pushGroup($notice, $group_id) + { + $feed = common_local_url('ApiTimelineGroup', + array('id' => $group_id, + 'format' => 'atom')); + $this->pushFeed($feed, array($this, 'groupFeedForNotice'), $group_id, $notice); + } + + /** + * @param string $feed URI to the feed + * @param callable $callback function to generate Atom feed update if needed + * any additional params are passed to the callback. + */ + function pushFeed($feed, $callback) + { + $hub = common_config('ostatus', 'hub'); + if ($hub) { + $this->pushFeedExternal($feed, $hub); + } + $sub = new HubSub(); $sub->topic = $feed; if ($sub->find()) { - common_log(LOG_INFO, "Preparing $sub->N PuSH distribution(s) for $feed"); - $qm = QueueManager::get(); - $atom = $this->userFeedForNotice($notice); - while ($sub->fetch()) { - common_log(LOG_INFO, "Prepping PuSH distribution to $sub->callback for $feed"); - $data = array('sub' => clone($sub), - 'atom' => $atom); - $qm->enqueue($data, 'hubout'); - } + $args = array_slice(func_get_args(), 2); + $atom = call_user_func_array($callback, $args); + $this->pushFeedInternal($atom, $sub); } else { common_log(LOG_INFO, "No PuSH subscribers for $feed"); } + return true; + } + + /** + * Ping external hub about this update. + * The hub will pull the feed and check for new items later. + * Not guaranteed safe in an environment with database replication. + * + * @param string $feed feed topic URI + * @param string $hub PuSH hub URI + * @fixme can consolidate pings for user & group posts + */ + function pushFeedExternal($feed, $hub) + { + $client = new HTTPClient(); + try { + $data = array('hub.mode' => 'publish', + 'hub.url' => $feed); + $response = $client->post($hub, array(), $data); + if ($response->getStatus() == 204) { + common_log(LOG_INFO, "PuSH ping to hub $hub for $feed ok"); + return true; + } else { + common_log(LOG_ERR, "PuSH ping to hub $hub for $feed failed with HTTP " . + $response->getStatus() . ': ' . + $response->getBody()); + } + } catch (Exception $e) { + common_log(LOG_ERR, "PuSH ping to hub $hub for $feed failed: " . $e->getMessage()); + return false; + } + } + + /** + * Queue up direct feed update pushes to subscribers on our internal hub. + * @param string $atom update feed, containing only new/changed items + * @param HubSub $sub open query of subscribers + */ + function pushFeedInternal($atom, $sub) + { + common_log(LOG_INFO, "Preparing $sub->N PuSH distribution(s) for $sub->topic"); + $qm = QueueManager::get(); + while ($sub->fetch()) { + common_log(LOG_INFO, "Prepping PuSH distribution to $sub->callback for $sub->topic"); + $data = array('sub' => clone($sub), + 'atom' => $atom); + $qm->enqueue($data, 'hubout'); + } } /** @@ -83,5 +157,29 @@ class HubDistribQueueHandler extends QueueHandler common_log(LOG_DEBUG, $feed); return $feed; } + + function groupFeedForNotice($group_id, $notice) + { + // @fixme this feels VERY hacky... + // should probably be a cleaner way to do it + + ob_start(); + $api = new ApiTimelineGroupAction(); + $args = array('id' => $group_id, + 'format' => 'atom', + 'max_id' => $notice->id, + 'since_id' => $notice->id - 1); + $api->prepare($args); + $api->handle($args); + $feed = ob_get_clean(); + + // ...and override the content-type back to something normal... eww! + // hope there's no other headers that got set while we weren't looking. + header('Content-Type: text/html; charset=utf-8'); + + common_log(LOG_DEBUG, $feed); + return $feed; + } + } diff --git a/plugins/OStatus/lib/huboutqueuehandler.php b/plugins/OStatus/lib/huboutqueuehandler.php index cb44ad2c4..0791c7e5d 100644 --- a/plugins/OStatus/lib/huboutqueuehandler.php +++ b/plugins/OStatus/lib/huboutqueuehandler.php @@ -43,7 +43,7 @@ class HubOutQueueHandler extends QueueHandler common_log(LOG_ERR, "Failed PuSH to $sub->callback for $sub->topic: " . $e->getMessage()); // @fixme Reschedule a later delivery? - // Currently we have no way to do this other than 'send NOW' + return true; } return true; diff --git a/plugins/OStatus/lib/salmon.php b/plugins/OStatus/lib/salmon.php new file mode 100644 index 000000000..8c77222a6 --- /dev/null +++ b/plugins/OStatus/lib/salmon.php @@ -0,0 +1,64 @@ +<?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 Salmon +{ + public function post($endpoint_uri, $xml) + { + if (empty($endpoint_uri)) { + return FALSE; + } + + $headers = array('Content-type: application/atom+xml'); + + try { + $client = new HTTPClient(); + $client->setBody($xml); + $response = $client->post($endpoint_uri, $headers); + } catch (HTTP_Request2_Exception $e) { + return false; + } + if ($response->getStatus() != 200) { + return false; + } + + } + + public function createMagicEnv($text, $userid) + { + + + } + + + public function verifyMagicEnv($env) + { + + + } +} diff --git a/plugins/OStatus/lib/webfinger.php b/plugins/OStatus/lib/webfinger.php new file mode 100644 index 000000000..417d54904 --- /dev/null +++ b/plugins/OStatus/lib/webfinger.php @@ -0,0 +1,143 @@ +<?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/ + */ + +define('WEBFINGER_SERVICE_REL_VALUE', 'lrdd'); + +/** + * Implement the webfinger protocol. + */ +class Webfinger +{ + /** + * Perform a webfinger lookup given an account. + */ + public function lookup($id) + { + $id = $this->normalize($id); + list($name, $domain) = explode('@', $id); + + $links = $this->getServiceLinks($domain); + if (!$links) { + return false; + } + + $services = array(); + foreach ($links as $link) { + if ($link['template']) { + return $this->getServiceDescription($link['template'], $id); + } + if ($link['href']) { + return $this->getServiceDescription($link['href'], $id); + } + } + } + + /** + * Normalize an account ID + */ + function normalize($id) + { + if (substr($id, 0, 7) == 'acct://') { + return substr($id, 7); + } else if (substr($id, 0, 5) == 'acct:') { + return substr($id, 5); + } + + return $id; + } + + function getServiceLinks($domain) + { + $url = 'http://'. $domain .'/.well-known/host-meta'; + $content = $this->fetchURL($url); + if (empty($content)) { + common_log(LOG_DEBUG, 'Error fetching host-meta'); + return false; + } + $result = XRD::parse($content); + + // Ensure that the host == domain (spec may include signing later) + if ($result->host != $domain) { + return false; + } + + $links = array(); + foreach ($result->links as $link) { + if ($link['rel'] == WEBFINGER_SERVICE_REL_VALUE) { + $links[] = $link; + } + + } + return $links; + } + + function getServiceDescription($template, $id) + { + $url = $this->applyTemplate($template, 'acct:' . $id); + + $content = $this->fetchURL($url); + + return XRD::parse($content); + } + + function fetchURL($url) + { + try { + $client = new HTTPClient(); + $response = $client->get($url); + } catch (HTTP_Request2_Exception $e) { + return false; + } + + if ($response->getStatus() != 200) { + return false; + } + + return $response->getBody(); + } + + function applyTemplate($template, $id) + { + $template = str_replace('{uri}', urlencode($id), $template); + + return $template; + } + + function getHostMeta($domain, $template) { + $xrd = new XRD(); + $xrd->host = $domain; + $xrd->links[] = array('rel' => 'lrdd', + 'template' => $template, + 'title' => array('Resource Descriptor')); + + return $xrd->toXML(); + } +} + + diff --git a/plugins/OStatus/lib/xrd.php b/plugins/OStatus/lib/xrd.php new file mode 100644 index 000000000..16d27f8eb --- /dev/null +++ b/plugins/OStatus/lib/xrd.php @@ -0,0 +1,183 @@ +<?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(); + $dom->loadXML($xml); + $xrd_element = $dom->getElementsByTagName('XRD')->item(0); + + // Check for host-meta host + $host = $xrd_element->getElementsByTagName('Host')->item(0)->nodeValue; + if ($host) { + $xrd->host = $host; + } + + // Loop through other elements + foreach ($xrd_element->childNodes as $node) { + 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() + { + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->formatOutput = true; + + $xrd_dom = $dom->createElementNS(XRD::XRD_NS, 'XRD'); + $dom->appendChild($xrd_dom); + + if ($this->host) { + $host_dom = $dom->createElement('hm:Host', $this->host); + $xrd_dom->setAttributeNS(XRD::XML_NS, 'xmlns:hm', XRD::HOST_META_NS); + $xrd_dom->appendChild($host_dom); + } + + if ($this->expires) { + $expires_dom = $dom->createElement('Expires', $this->expires); + $xrd_dom->appendChild($expires_dom); + } + + if ($this->subject) { + $subject_dom = $dom->createElement('Subject', $this->subject); + $xrd_dom->appendChild($subject_dom); + } + + foreach ($this->alias as $alias) { + $alias_dom = $dom->createElement('Alias', $alias); + $xrd_dom->appendChild($alias_dom); + } + + foreach ($this->types as $type) { + $type_dom = $dom->createElement('Type', $type); + $xrd_dom->appendChild($type_dom); + } + + foreach ($this->links as $link) { + $link_dom = $this->saveLink($dom, $link); + $xrd_dom->appendChild($link_dom); + } + + return $dom->saveXML(); + } + + 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) { + switch($node->tagName) { + case 'Title': + $link['title'][] = $node->nodeValue; + } + } + + return $link; + } + + function saveLink($doc, $link) + { + $link_element = $doc->createElement('Link'); + if ($link['rel']) { + $link_element->setAttribute('rel', $link['rel']); + } + if ($link['type']) { + $link_element->setAttribute('type', $link['type']); + } + if ($link['href']) { + $link_element->setAttribute('href', $link['href']); + } + if ($link['template']) { + $link_element->setAttribute('template', $link['template']); + } + + if (is_array($link['title'])) { + foreach($link['title'] as $title) { + $title = $doc->createElement('Title', $title); + $link_element->appendChild($title); + } + } + + + return $link_element; + } +} + diff --git a/theme/base/css/display.css b/theme/base/css/display.css index 70ddc411f..3218276a6 100644 --- a/theme/base/css/display.css +++ b/theme/base/css/display.css @@ -1104,10 +1104,9 @@ left:0; .dialogbox { position:absolute; -top:-4px; -right:29px; +top:-1px; +right:-1px; z-index:9; -min-width:199px; float:none; padding:11px; border-radius:7px; @@ -1120,6 +1119,7 @@ border-width:1px; .dialogbox legend { display:block !important; margin-right:18px; +margin-bottom:18px; } .dialogbox button.close { @@ -1128,11 +1128,22 @@ right:3px; top:3px; } +.dialogbox .form_guide { +font-weight:normal; +padding:0; +} + .dialogbox .submit_dialogbox { font-weight:bold; text-indent:0; min-width:46px; } +.dialogbox input { +padding-left:4px; +} +.dialogbox fieldset { +margin-bottom:0; +} #wrap form.processing input.submit, .entity_actions a.processing, @@ -1142,6 +1153,12 @@ outline:none; text-indent:-9999px; } +.form_repeat.dialogbox { +top:-4px; +right:29px; +min-width:199px; +} + .notice-options { position:relative; font-size:0.95em; diff --git a/theme/default/css/display.css b/theme/default/css/display.css index 02e1645f4..a2f101342 100644 --- a/theme/default/css/display.css +++ b/theme/default/css/display.css @@ -30,7 +30,9 @@ border-radius:4px; input, textarea, select, option { font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif; } -input, textarea, select { +input, textarea, select, +.entity_actions .dialogbox input, +.mark-top { border-color:#AAAAAA; } @@ -79,7 +81,8 @@ background-color:transparent; input:focus, textarea:focus, select:focus, .form_notice.warning #notice_data-text, .form_notice.warning #notice_text-count, -.form_settings .form_note { +.form_settings .form_note, +.entity_actions .dialogbox .form_data input:focus { border-color:#9BB43E; } input.submit { @@ -134,9 +137,6 @@ color:#002FA7; #content tbody tr { border-top-color:#C8D1D5; } -.mark-top { -border-color:#AAAAAA; -} #aside_primary { background-color:#C8D1D5; @@ -145,7 +145,9 @@ background-color:#C8D1D5; #notice_text-count { color:#333333; } -.form_notice.warning #notice_text-count { +.form_notice.warning #notice_text-count, +.dialogbox, +.entity_actions .dialogbox input { color:#000000; } .form_notice label[for=notice_data-attach] { diff --git a/theme/identica/css/display.css b/theme/identica/css/display.css index 6dc7d21df..e21404745 100644 --- a/theme/identica/css/display.css +++ b/theme/identica/css/display.css @@ -30,7 +30,9 @@ border-radius:4px; input, textarea, select, option { font-family: "Lucida Sans Unicode", "Lucida Grande", sans-serif; } -input, textarea, select { +input, textarea, select, +.entity_actions .dialogbox input, +.mark-top { border-color:#AAAAAA; } @@ -135,9 +137,6 @@ color:#002FA7; #content tbody tr { border-top-color:#CEE1E9; } -.mark-top { -border-color:#AAAAAA; -} #aside_primary { background-color:#CEE1E9; @@ -146,7 +145,9 @@ background-color:#CEE1E9; #notice_text-count { color:#333333; } -.form_notice.warning #notice_text-count { +.form_notice.warning #notice_text-count, +.dialogbox, +.entity_actions .dialogbox input { color:#000000; } .form_notice label[for=notice_data-attach] { |