Merge branch 'atompub' into 0.9.x

Conflicts:
	actions/apistatusesshow.php
	actions/apitimelineuser.php
This commit is contained in:
Evan Prodromou 2010-11-15 11:57:19 -05:00
commit c1cee3b27f
9 changed files with 495 additions and 33 deletions

View File

@ -1172,3 +1172,11 @@ StartRevokeRole: when a role is being revoked
EndRevokeRole: when a role has been revoked EndRevokeRole: when a role has been revoked
- $profile: profile that lost the role - $profile: profile that lost the role
- $role: string name of the role - $role: string name of the role
StartAtomPubNewActivity: When a new activity comes in through Atom Pub API
- &$activity: received activity
EndAtomPubNewActivity: When a new activity comes in through Atom Pub API
- $activity: received activity
- $notice: notice that was created

100
actions/apiatomservice.php Normal file
View File

@ -0,0 +1,100 @@
<?php
/**
* StatusNet, the distributed open-source microblogging tool
*
* An AtomPub service document for a user
*
* 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 API
* @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/
*/
require_once INSTALLDIR.'/lib/apibareauth.php';
/**
* Shows an AtomPub service document for a user
*
* @category API
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
class ApiAtomServiceAction extends ApiBareAuthAction
{
/**
* Take arguments for running
*
* @param array $args $_REQUEST args
*
* @return boolean success flag
*
*/
function prepare($args)
{
parent::prepare($args);
$this->user = $this->getTargetUser($this->arg('id'));
if (empty($this->user)) {
$this->clientError(_('No such user.'), 404, $this->format);
return;
}
return true;
}
/**
* Handle the arguments. In our case, show a service document.
*
* @param Array $args unused.
*
* @return void
*/
function handle($args)
{
parent::handle($args);
header('Content-Type: application/atomsvc+xml');
$this->startXML();
$this->elementStart('service', array('xmlns' => 'http://www.w3.org/2007/app',
'xmlns:atom' => 'http://www.w3.org/2005/Atom'));
$this->elementStart('workspace');
$this->element('atom:title', null, _('Main'));
$this->elementStart('collection',
array('href' => common_local_url('ApiTimelineUser',
array('id' => $this->user->id,
'format' => 'atom'))));
$this->element('atom:title',
null,
sprintf(_("%s timeline"),
$this->user->nickname));
$this->element('accept', null, 'application/atom+xml;type=entry');
$this->elementEnd('collection');
$this->elementEnd('workspace');
$this->elementEnd('service');
$this->endXML();
}
}

View File

@ -100,13 +100,23 @@ class ApiStatusesShowAction extends ApiPrivateAuthAction
{ {
parent::handle($args); parent::handle($args);
if (!in_array($this->format, array('xml', 'json'))) { if (!in_array($this->format, array('xml', 'json', 'atom'))) {
// TRANS: Client error displayed when trying to handle an unknown API method. // TRANS: Client error displayed when trying to handle an unknown API method.
$this->clientError(_('API method not found.'), $code = 404); $this->clientError(_('API method not found.'), 404);
return; return;
} }
$this->showNotice(); switch ($_SERVER['REQUEST_METHOD']) {
case 'GET':
$this->showNotice();
break;
case 'DELETE':
$this->deleteNotice();
break;
default:
$this->clientError(_('HTTP method not supported.'), 405);
return;
}
} }
/** /**
@ -117,10 +127,18 @@ class ApiStatusesShowAction extends ApiPrivateAuthAction
function showNotice() function showNotice()
{ {
if (!empty($this->notice)) { if (!empty($this->notice)) {
if ($this->format == 'xml') { switch ($this->format) {
case 'xml':
$this->showSingleXmlStatus($this->notice); $this->showSingleXmlStatus($this->notice);
} elseif ($this->format == 'json') { break;
case 'json':
$this->show_single_json_status($this->notice); $this->show_single_json_status($this->notice);
break;
case 'atom':
$this->showSingleAtomStatus($this->notice);
break;
default:
throw new Exception(sprintf(_("Unsupported format: %s"), $this->format));
} }
} else { } else {
// XXX: Twitter just sets a 404 header and doens't bother // XXX: Twitter just sets a 404 header and doens't bother
@ -197,4 +215,31 @@ class ApiStatusesShowAction extends ApiPrivateAuthAction
return null; return null;
} }
function deleteNotice()
{
if ($this->format != 'atom') {
$this->clientError(_("Can only delete using the Atom format."));
return;
}
if (empty($this->auth_user) ||
($this->notice->profile_id != $this->auth_user->id &&
!$this->auth_user->hasRight(Right::DELETEOTHERSNOTICE))) {
$this->clientError(_('Can\'t delete this notice.'), 403);
return;
}
if (Event::handle('StartDeleteOwnNotice', array($this->auth_user, $this->notice))) {
$this->notice->delete();
Event::handle('EndDeleteOwnNotice', array($this->auth_user, $this->notice));
}
// @fixme is there better output we could do here?
header('HTTP/1.1 200 OK');
header('Content-Type: text/plain');
print(sprintf(_('Deleted notice %d'), $this->notice->id));
print("\n");
}
} }

View File

@ -97,7 +97,12 @@ class ApiTimelineUserAction extends ApiBareAuthAction
function handle($args) function handle($args)
{ {
parent::handle($args); parent::handle($args);
$this->showTimeline();
if ($this->isPost()) {
$this->handlePost();
} else {
$this->showTimeline();
}
} }
/** /**
@ -114,9 +119,9 @@ class ApiTimelineUserAction extends ApiBareAuthAction
$atom = new AtomUserNoticeFeed($this->user, $this->auth_user); $atom = new AtomUserNoticeFeed($this->user, $this->auth_user);
$link = common_local_url( $link = common_local_url(
'showstream', 'showstream',
array('nickname' => $this->user->nickname) array('nickname' => $this->user->nickname)
); );
$self = $this->getSelfUri(); $self = $this->getSelfUri();
@ -132,20 +137,63 @@ class ApiTimelineUserAction extends ApiBareAuthAction
break; break;
case 'rss': case 'rss':
$this->showRssTimeline( $this->showRssTimeline(
$this->notices, $this->notices,
$atom->title, $atom->title,
$link, $link,
$atom->subtitle, $atom->subtitle,
$suplink, $suplink,
$atom->logo, $atom->logo,
$self $self
); );
break; break;
case 'atom': case 'atom':
header('Content-Type: application/atom+xml; charset=utf-8'); header('Content-Type: application/atom+xml; charset=utf-8');
$atom->setId($self); $atom->setId($self);
$atom->setSelfLink($self); $atom->setSelfLink($self);
// Add navigation links: next, prev, first
// Note: we use IDs rather than pages for navigation; page boundaries
// change too quickly!
if (!empty($this->next_id)) {
$nextUrl = common_local_url('ApiTimelineUser',
array('format' => 'atom',
'id' => $this->user->id),
array('max_id' => $this->next_id));
$atom->addLink($nextUrl,
array('rel' => 'next',
'type' => 'application/atom+xml'));
}
if (($this->page > 1 || !empty($this->max_id)) && !empty($this->notices)) {
$lastNotice = $this->notices[0];
$lastId = $lastNotice->id;
$prevUrl = common_local_url('ApiTimelineUser',
array('format' => 'atom',
'id' => $this->user->id),
array('since_id' => $lastId));
$atom->addLink($prevUrl,
array('rel' => 'prev',
'type' => 'application/atom+xml'));
}
if ($this->page > 1 || !empty($this->since_id) || !empty($this->max_id)) {
$firstUrl = common_local_url('ApiTimelineUser',
array('format' => 'atom',
'id' => $this->user->id));
$atom->addLink($firstUrl,
array('rel' => 'first',
'type' => 'application/atom+xml'));
}
$atom->addEntryFromNotices($this->notices); $atom->addEntryFromNotices($this->notices);
$this->raw($atom->getString()); $this->raw($atom->getString());
@ -169,13 +217,18 @@ class ApiTimelineUserAction extends ApiBareAuthAction
{ {
$notices = array(); $notices = array();
$notice = $this->user->getNotices( $notice = $this->user->getNotices(($this->page-1) * $this->count,
($this->page-1) * $this->count, $this->count, $this->count + 1,
$this->since_id, $this->max_id $this->since_id,
); $this->max_id);
while ($notice->fetch()) { while ($notice->fetch()) {
$notices[] = clone($notice); if (count($notices) < $this->count) {
$notices[] = clone($notice);
} else {
$this->next_id = $notice->id;
break;
}
} }
return $notices; return $notices;
@ -221,17 +274,206 @@ class ApiTimelineUserAction extends ApiBareAuthAction
$last = count($this->notices) - 1; $last = count($this->notices) - 1;
return '"' . implode( return '"' . implode(
':', ':',
array($this->arg('action'), array($this->arg('action'),
common_user_cache_hash($this->auth_user), common_user_cache_hash($this->auth_user),
common_language(), common_language(),
$this->user->id, $this->user->id,
strtotime($this->notices[0]->created), strtotime($this->notices[0]->created),
strtotime($this->notices[$last]->created)) strtotime($this->notices[$last]->created))
) )
. '"'; . '"';
} }
return null; return null;
} }
function handlePost()
{
if (empty($this->auth_user) ||
$this->auth_user->id != $this->user->id) {
$this->clientError(_("Only the user can add to their own timeline."));
return;
}
if ($this->format != 'atom') {
// Only handle posts for Atom
$this->clientError(_("Only accept AtomPub for atom feeds."));
return;
}
$xml = file_get_contents('php://input');
$dom = DOMDocument::loadXML($xml);
if ($dom->documentElement->namespaceURI != Activity::ATOM ||
$dom->documentElement->localName != 'entry') {
$this->clientError(_('Atom post must be an Atom entry.'));
return;
}
$activity = new Activity($dom->documentElement);
if (Event::handle('StartAtomPubNewActivity', array(&$activity))) {
if ($activity->verb != ActivityVerb::POST) {
$this->clientError(_('Can only handle post activities.'));
return;
}
$note = $activity->objects[0];
if (!in_array($note->type, array(ActivityObject::NOTE,
ActivityObject::BLOGENTRY,
ActivityObject::STATUS))) {
$this->clientError(sprintf(_('Cannot handle activity object type "%s"',
$note->type)));
return;
}
$saved = $this->postNote($activity);
Event::handle('EndAtomPubNewActivity', array($activity, $saved));
}
if (!empty($saved)) {
header("Location: " . common_local_url('ApiStatusesShow', array('notice_id' => $saved->id,
'format' => 'atom')));
$this->showSingleAtomStatus($saved);
}
}
function postNote($activity)
{
$note = $activity->objects[0];
// Use summary as fallback for content
if (!empty($note->content)) {
$sourceContent = $note->content;
} else if (!empty($note->summary)) {
$sourceContent = $note->summary;
} else if (!empty($note->title)) {
$sourceContent = $note->title;
} else {
// @fixme fetch from $sourceUrl?
// @todo i18n FIXME: use sprintf and add i18n.
$this->clientError("No content for notice {$note->id}.");
return;
}
// Get (safe!) HTML and text versions of the content
$rendered = $this->purify($sourceContent);
$content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
$shortened = common_shorten_links($content);
$options = array('is_local' => Notice::LOCAL_PUBLIC,
'rendered' => $rendered,
'replies' => array(),
'groups' => array(),
'tags' => array(),
'urls' => array());
// accept remote URI (not necessarily a good idea)
common_debug("Note ID is {$note->id}");
if (!empty($note->id)) {
$notice = Notice::staticGet('uri', trim($note->id));
if (!empty($notice)) {
$this->clientError(sprintf(_('Notice with URI "%s" already exists.'),
$note->id));
return;
}
common_log(LOG_NOTICE, "Saving client-supplied notice URI '$note->id'");
$options['uri'] = $note->id;
}
// accept remote create time (also maybe not such a good idea)
if (!empty($activity->time)) {
common_log(LOG_NOTICE, "Saving client-supplied create time {$activity->time}");
$options['created'] = common_sql_date($activity->time);
}
// Check for optional attributes...
if (!empty($activity->context)) {
foreach ($activity->context->attention as $uri) {
$profile = Profile::fromURI($uri);
if (!empty($profile)) {
$options['replies'] = $uri;
} else {
$group = User_group::staticGet('uri', $uri);
if (!empty($group)) {
$options['groups'] = $uri;
} else {
// @fixme: hook for discovery here
common_log(LOG_WARNING, sprintf(_('AtomPub post with unknown attention URI %s'), $uri));
}
}
}
// Maintain direct reply associations
// @fixme what about conversation ID?
if (!empty($activity->context->replyToID)) {
$orig = Notice::staticGet('uri',
$activity->context->replyToID);
if (!empty($orig)) {
$options['reply_to'] = $orig->id;
}
}
$location = $activity->context->location;
if ($location) {
$options['lat'] = $location->lat;
$options['lon'] = $location->lon;
if ($location->location_id) {
$options['location_ns'] = $location->location_ns;
$options['location_id'] = $location->location_id;
}
}
}
// Atom categories <-> hashtags
foreach ($activity->categories as $cat) {
if ($cat->term) {
$term = common_canonical_tag($cat->term);
if ($term) {
$options['tags'][] = $term;
}
}
}
// Atom enclosures -> attachment URLs
foreach ($activity->enclosures as $href) {
// @fixme save these locally or....?
$options['urls'][] = $href;
}
$saved = Notice::saveNew($this->user->id,
$content,
'atompub', // TODO: deal with this
$options);
return $saved;
}
function purify($content)
{
require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
$config = array('safe' => 1,
'deny_attribute' => 'id,style,on*');
return htmLawed($content, $config);
}
} }

View File

@ -162,6 +162,20 @@ class RsdAction extends Action
'true'); 'true');
$this->elementEnd('settings'); $this->elementEnd('settings');
$this->elementEnd('api'); $this->elementEnd('api');
// Atom API
if (empty($this->user)) {
$service = common_local_url('ApiAtomService');
} else {
$service = common_local_url('ApiAtomService', array('id' => $this->user->nickname));
}
$this->element('api', array('name' => 'Atom',
'preferred' => 'false',
'apiLink' => $service,
'blogID' => $blogID));
Event::handle('EndRsdListApis', array($this, $this->user)); Event::handle('EndRsdListApis', array($this, $this->user));
} }
$this->elementEnd('apis'); $this->elementEnd('apis');

View File

@ -1611,6 +1611,35 @@ class Notice extends Memcached_DataObject
Event::handle('EndActivityGeo', array(&$this, &$xs, $lat, $lon)); Event::handle('EndActivityGeo', array(&$this, &$xs, $lat, $lon));
} }
// @fixme check this logic
if ($this->isLocal()) {
$selfUrl = common_local_url('ApiStatusesShow', array('id' => $this->id,
'format' => 'atom'));
if (Event::handle('StartActivityRelSelf', array(&$this, &$xs, &$selfUrl))) {
$xs->element('link', array('rel' => 'self',
'type' => 'application/atom+xml',
'href' => $selfUrl));
Event::handle('EndActivityRelSelf', array(&$this, &$xs, $selfUrl));
}
if (!empty($cur) && $cur->id == $this->profile_id) {
// note: $selfUrl may have been changed by a plugin
$relEditUrl = common_local_url('ApiStatusesShow', array('id' => $this->id,
'format' => 'atom'));
if (Event::handle('StartActivityRelEdit', array(&$this, &$xs, &$relEditUrl))) {
$xs->element('link', array('rel' => 'edit',
'type' => 'application/atom+xml',
'href' => $relEditUrl));
Event::handle('EndActivityRelEdit', array(&$this, &$xs, $relEditUrl));
}
}
}
if (Event::handle('StartActivityEnd', array(&$this, &$xs))) { if (Event::handle('StartActivityEnd', array(&$this, &$xs))) {
$xs->elementEnd('entry'); $xs->elementEnd('entry');
Event::handle('EndActivityEnd', array(&$this, &$xs)); Event::handle('EndActivityEnd', array(&$this, &$xs));

View File

@ -1404,4 +1404,15 @@ class Action extends HTMLOutputter // lawsuit
$this->clientError(_('There was a problem with your session token.')); $this->clientError(_('There was a problem with your session token.'));
} }
} }
/**
* Check if the current request is a POST
*
* @return boolean true if POST; otherwise false.
*/
function isPost()
{
return ($_SERVER['REQUEST_METHOD'] == 'POST');
}
} }

View File

@ -726,6 +726,12 @@ class ApiAction extends Action
$this->endDocument('xml'); $this->endDocument('xml');
} }
function showSingleAtomStatus($notice)
{
header('Content-Type: application/atom+xml; charset=utf-8');
print $notice->asAtomEntry(true, true, true, $this->auth_user);
}
function show_single_json_status($notice) function show_single_json_status($notice)
{ {
$this->initDocument('json'); $this->initDocument('json');

View File

@ -399,12 +399,12 @@ class Router
$m->connect('api/statuses/show.:format', $m->connect('api/statuses/show.:format',
array('action' => 'ApiStatusesShow', array('action' => 'ApiStatusesShow',
'format' => '(xml|json)')); 'format' => '(xml|json|atom)'));
$m->connect('api/statuses/show/:id.:format', $m->connect('api/statuses/show/:id.:format',
array('action' => 'ApiStatusesShow', array('action' => 'ApiStatusesShow',
'id' => '[0-9]+', 'id' => '[0-9]+',
'format' => '(xml|json)')); 'format' => '(xml|json|atom)'));
$m->connect('api/statuses/update.:format', $m->connect('api/statuses/update.:format',
array('action' => 'ApiStatusesUpdate', array('action' => 'ApiStatusesUpdate',
@ -686,6 +686,13 @@ class Router
$m->connect('api/oauth/authorize', $m->connect('api/oauth/authorize',
array('action' => 'ApiOauthAuthorize')); array('action' => 'ApiOauthAuthorize'));
$m->connect('api/statusnet/app/service/:id.xml',
array('action' => 'ApiAtomService',
'id' => '[a-zA-Z0-9]+'));
$m->connect('api/statusnet/app/service.xml',
array('action' => 'ApiAtomService'));
// Admin // Admin
$m->connect('admin/site', array('action' => 'siteadminpanel')); $m->connect('admin/site', array('action' => 'siteadminpanel'));