From b8b5b87c4c106f59e346ca20e45458192f68a744 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Wed, 8 Dec 2010 07:25:55 -0500 Subject: [PATCH 01/19] Don't cache user-specific information for Notice atom entries --- classes/Notice.php | 146 ++++++++++++++++++++++++++------------------- lib/activity.php | 14 +---- 2 files changed, 86 insertions(+), 74 deletions(-) diff --git a/classes/Notice.php b/classes/Notice.php index d6710c3e27..a067cd3741 100644 --- a/classes/Notice.php +++ b/classes/Notice.php @@ -1234,7 +1234,7 @@ class Notice extends Memcached_DataObject * @return Activity activity object representing this Notice. */ - function asActivity($cur = null, $source = false) + function asActivity() { $act = self::cacheGet(Cache::codeKey('notice:as-activity:'.$this->id)); @@ -1332,68 +1332,37 @@ class Notice extends Memcached_DataObject $act->context = $ctx; - $noticeInfoAttr = array('local_id' => $this->id); // local notice ID (useful to clients for ordering) + // Source - $ns = $this->getSource(); + $atom_feed = $profile->getAtomFeed(); - if (!empty($ns)) { - $noticeInfoAttr['source'] = $ns->code; - if (!empty($ns->url)) { - $noticeInfoAttr['source_link'] = $ns->url; - if (!empty($ns->name)) { - $noticeInfoAttr['source'] = '' - . htmlspecialchars($ns->name) - . ''; - } + if (!empty($atom_feed)) { + + $act->source = new ActivitySource(); + + // XXX: we should store the actual feed ID + + $act->source->id = $atom_feed; + + // XXX: we should store the actual feed title + + $act->source->title = $profile->getBestName(); + + $act->source->links['alternate'] = $profile->profileurl; + $act->source->links['self'] = $atom_feed; + + $act->source->icon = $profile->avatarUrl(AVATAR_PROFILE_SIZE); + + $notice = $profile->getCurrentNotice(); + + if (!empty($notice)) { + $act->source->updated = self::utcDate($notice->created); } - } - if (!empty($cur)) { - $noticeInfoAttr['favorite'] = ($cur->hasFave($this)) ? "true" : "false"; - $cp = $cur->getProfile(); - $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this->id)) ? "true" : "false"; - } + $user = User::staticGet('id', $profile->id); - if (!empty($this->repeat_of)) { - $noticeInfoAttr['repeat_of'] = $this->repeat_of; - } - - $act->extra[] = array('statusnet:notice_info', $noticeInfoAttr, null); - - if ($source) { - - $atom_feed = $profile->getAtomFeed(); - - if (!empty($atom_feed)) { - - $act->source = new ActivitySource(); - - // XXX: we should store the actual feed ID - - $act->source->id = $atom_feed; - - // XXX: we should store the actual feed title - - $act->source->title = $profile->getBestName(); - - $act->source->links['alternate'] = $profile->profileurl; - $act->source->links['self'] = $atom_feed; - - $act->source->icon = $profile->avatarUrl(AVATAR_PROFILE_SIZE); - - $notice = $profile->getCurrentNotice(); - - if (!empty($notice)) { - $act->source->updated = self::utcDate($notice->created); - } - - $user = User::staticGet('id', $profile->id); - - if (!empty($user)) { - $act->source->links['license'] = common_config('license', 'url'); - } + if (!empty($user)) { + $act->source->links['license'] = common_config('license', 'url'); } } @@ -1414,12 +1383,65 @@ class Notice extends Memcached_DataObject // This has gotten way too long. Needs to be sliced up into functional bits // or ideally exported to a utility class. - function asAtomEntry($namespace=false, $source=false, $author=true, $cur=null) + function asAtomEntry($namespace=false, + $source=false, + $author=true, + $cur=null) { - $act = $this->asActivity($cur, $source); - return $act->asString($namespace, $author); + $act = $this->asActivity(); + $act->extra[] = $this->noticeInfo($cur); + return $act->asString($namespace, $author, $source); + } + + /** + * Extra notice info for atom entries + * + * Clients use some extra notice info in the atom stream. + * This gives it to them. + * + * @param User $cur Current user + * + * @return array representation of element + */ + + function noticeInfo($cur) + { + // local notice ID (useful to clients for ordering) + + $noticeInfoAttr = array('local_id' => $this->id); + + // notice source + + $ns = $this->getSource(); + + if (!empty($ns)) { + $noticeInfoAttr['source'] = $ns->code; + if (!empty($ns->url)) { + $noticeInfoAttr['source_link'] = $ns->url; + if (!empty($ns->name)) { + $noticeInfoAttr['source'] = '' + . htmlspecialchars($ns->name) + . ''; + } + } + } + + // favorite and repeated + + if (!empty($cur)) { + $noticeInfoAttr['favorite'] = ($cur->hasFave($this)) ? "true" : "false"; + $cp = $cur->getProfile(); + $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this->id)) ? "true" : "false"; + } + + if (!empty($this->repeat_of)) { + $noticeInfoAttr['repeat_of'] = $this->repeat_of; + } + + return array('statusnet:notice_info', $noticeInfoAttr, null); } - /** * Returns an XML string fragment with a reference to a notice as an diff --git a/lib/activity.php b/lib/activity.php index d3eeadcee9..c3a984a7b9 100644 --- a/lib/activity.php +++ b/lib/activity.php @@ -327,16 +327,8 @@ class Activity return null; } - function asString($namespace=false, $author=true) + function asString($namespace=false, $author=true, $source=false) { - $c = Cache::instance(); - - $str = $c->get(Cache::codeKey('activity:as-string:'.$this->id)); - - if (!empty($str)) { - return $str; - } - $xs = new XMLStringer(true); if ($namespace) { @@ -502,7 +494,7 @@ class Activity // Info on the source feed - if (!empty($this->source)) { + if ($source && !empty($this->source)) { $xs->elementStart('source'); $xs->element('id', null, $this->source->id); @@ -559,8 +551,6 @@ class Activity $str = $xs->getString(); - $c->set(Cache::codeKey('activity:as-string:'.$this->id), $str); - return $str; } From 11a0bde4595d2779b2587398c4b69bfcee116880 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Thu, 9 Dec 2010 13:11:02 -0500 Subject: [PATCH 02/19] AtomPub for single subscription --- actions/atompubshowsubscription.php | 224 ++++++++++++++++++++++++++++ classes/Subscription.php | 9 ++ lib/router.php | 21 ++- 3 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 actions/atompubshowsubscription.php diff --git a/actions/atompubshowsubscription.php b/actions/atompubshowsubscription.php new file mode 100644 index 0000000000..a30b210963 --- /dev/null +++ b/actions/atompubshowsubscription.php @@ -0,0 +1,224 @@ +. + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Show a single subscription + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubshowsubscriptionAction extends ApiAuthAction +{ + private $_subscriber = null; + private $_subscribed = null; + private $_subscription = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + $subscriberId = $this->trimmed('subscriber'); + + $this->_subscriber = Profile::staticGet('id', $subscriberId); + + if (empty($this->_subscriber)) { + throw new ClientException(sprintf(_('No such profile id: %d'), + $subscriberId), 404); + } + + $subscribedId = $this->trimmed('subscribed'); + + $this->_subscribed = Profile::staticGet('id', $subscribedId); + + if (empty($this->_subscribed)) { + throw new ClientException(sprintf(_('No such profile id: %d'), + $subscribedId), 404); + } + + $this->_subscription = + Subscription::pkeyGet(array('subscriber' => $subscriberId, + 'subscribed' => $subscribedId)); + + if (empty($this->_subscription)) { + $msg = sprintf(_('Profile %d not subscribed to profile %d'), + $subscriberId, $subscribedId); + throw new ClientException($msg, 404); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + $this->showSubscription(); + break; + case 'DELETE': + $this->deleteSubscription(); + break; + default: + $this->clientError(_('HTTP method not supported.'), 405); + return; + } + + return; + } + + /** + * Show the subscription in ActivityStreams Atom format. + * + * @return void + */ + + function showSubscription() + { + $activity = $this->_subscription->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $this->startXML(); + $this->raw($activity->asString(true, true, true)); + $this->endXML(); + + return; + } + + /** + * Delete the subscription + * + * @return void + */ + + function deleteSubscription() + { + if (empty($this->auth_user) || + $this->auth_user->id != $this->_subscriber->id) { + throw new ClientException(_("Can't delete someone else's". + " subscription"), 403); + } + + Subscription::cancel($this->_subscriber, + $this->_subscribed); + + return; + } + + /** + * Is this action read only? + * + * @param array $args other arguments + * + * @return boolean true + */ + + function isReadOnly($args) + { + if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { + return false; + } else { + return true; + } + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + + function lastModified() + { + return max(strtotime($this->_subscriber->modified), + strtotime($this->_subscribed->modified), + strtotime($this->_subscription->modified)); + } + + /** + * Etag for this object + * + * @return string etag http header + */ + + function etag() + { + $mtime = strtotime($this->_subscription->modified); + + return 'W/"' . implode(':', array('AtomPubShowSubscription', + $this->_subscriber->id, + $this->_subscribed->id, + $mtime)) . '"'; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { + return true; + } else { + return false; + } + } +} diff --git a/classes/Subscription.php b/classes/Subscription.php index e9ad2a5a20..d413494123 100644 --- a/classes/Subscription.php +++ b/classes/Subscription.php @@ -245,6 +245,8 @@ class Subscription extends Memcached_DataObject $act->verb = ActivityVerb::FOLLOW; + // XXX: rationalize this with the URL + $act->id = TagURI::mint('follow:%d:%d:%s', $subscriber->id, $subscribed->id, @@ -262,6 +264,13 @@ class Subscription extends Memcached_DataObject $act->actor = ActivityObject::fromProfile($subscriber); $act->objects[] = ActivityObject::fromProfile($subscribed); + $url = common_local_url('AtomPubShowSubscription', + array('subscriber' => $subscriber->id, + 'subscribed' => $subscribed->id)); + + $act->selfLink = $url; + $act->editLink = $url; + return $act; } } diff --git a/lib/router.php b/lib/router.php index ca895c8bb6..4e718f1322 100644 --- a/lib/router.php +++ b/lib/router.php @@ -761,13 +761,6 @@ class Router $m->connect('api/oauth/authorize', array('action' => 'ApiOauthAuthorize')); - $m->connect('api/statusnet/app/service/:id.xml', - array('action' => 'ApiAtomService', - 'id' => Nickname::DISPLAY_FMT)); - - $m->connect('api/statusnet/app/service.xml', - array('action' => 'ApiAtomService')); - // Admin $m->connect('admin/site', array('action' => 'siteadminpanel')); @@ -909,6 +902,20 @@ class Router array('nickname' => Nickname::DISPLAY_FMT)); } + // AtomPub API + + $m->connect('api/statusnet/app/service/:id.xml', + array('action' => 'ApiAtomService', + 'id' => Nickname::DISPLAY_FMT)); + + $m->connect('api/statusnet/app/service.xml', + array('action' => 'ApiAtomService')); + + $m->connect('api/statusnet/app/subscriptions/:subscriber/:subscribed.atom', + array('action' => 'AtomPubShowSubscription'), + array('subscriber' => '[0-9]+', + 'subscribed' => '[0-9]+')); + // user stuff Event::handle('RouterInitialized', array($m)); From 34b8eb207d3d013ca36966ddb32a4977bd21639f Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Thu, 9 Dec 2010 14:25:57 -0500 Subject: [PATCH 03/19] make HEAD work for AtomPubShowSubscription --- actions/atompubshowsubscription.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/atompubshowsubscription.php b/actions/atompubshowsubscription.php index a30b210963..55ba68adc5 100644 --- a/actions/atompubshowsubscription.php +++ b/actions/atompubshowsubscription.php @@ -105,7 +105,9 @@ class AtompubshowsubscriptionAction extends ApiAuthAction function handle($argarray=null) { + parent::handle($argarray); switch ($_SERVER['REQUEST_METHOD']) { + case 'HEAD': case 'GET': $this->showSubscription(); break; @@ -179,8 +181,6 @@ class AtompubshowsubscriptionAction extends ApiAuthAction /** * Return last modified, if applicable. * - * MAY override - * * @return string last modified http header */ From c619a257fe04a5ad4cc670c14cc1eab2042b71d8 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Thu, 9 Dec 2010 16:05:07 -0500 Subject: [PATCH 04/19] Add subscription feed for AtomPub --- actions/atompubsubscriptionfeed.php | 262 ++++++++++++++++++++++++++++ actions/subscriptions.php | 17 ++ lib/router.php | 4 + 3 files changed, 283 insertions(+) create mode 100644 actions/atompubsubscriptionfeed.php diff --git a/actions/atompubsubscriptionfeed.php b/actions/atompubsubscriptionfeed.php new file mode 100644 index 0000000000..5eddc4cddc --- /dev/null +++ b/actions/atompubsubscriptionfeed.php @@ -0,0 +1,262 @@ +. + * + * @category Cache + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Subscription feed class for AtomPub + * + * Generates a list of the user's subscriptions + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubsubscriptionfeedAction extends ApiAuthAction +{ + private $_profile = null; + private $_subscribed = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $subscriber = $this->trimmed('subscriber'); + + $this->_profile = Profile::staticGet('id', $subscriber); + + if (empty($this->_profile)) { + throw new ClientException(sprintf(_('No such profile id: %d'), + $subscriber), 404); + } + + // page and count from ApiAction + // Note: this is a list of profiles, not subscriptions + + $this->_subscribed = + $this->_profile->getSubscriptions(($this->page-1) * $this->count, + $this->count + 1); + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($argarray); + switch ($_SERVER['REQUEST_METHOD']) { + case 'HEAD': + case 'GET': + $this->showFeed(); + break; + case 'POST': + $this->addSubscription(); + break; + default: + $this->clientError(_('HTTP method not supported.'), 405); + return; + } + + return; + } + + /** + * Show the feed of subscriptions + * + * @return void + */ + + function showFeed() + { + header('Content-Type: application/atom+xml; charset=utf-8'); + + $url = common_local_url('AtomPubSubscriptionFeed', + array('subscriber' => $this->_profile->id)); + + $feed = new Atom10Feed(true); + + $feed->addNamespace('activity', + 'http://activitystrea.ms/spec/1.0/'); + + $feed->addNamespace('poco', + 'http://portablecontacts.net/spec/1.0'); + + $feed->addNamespace('media', + 'http://purl.org/syndication/atommedia'); + + $feed->id = $url; + + $feed->setUpdated('now'); + + $feed->addAuthor($this->_profile->getBestName(), + $this->_profile->getURI()); + + $feed->setTitle(sprintf(_("%s subscriptions"), + $this->_profile->getBestName())); + + $feed->setSubtitle(sprintf(_("People %s has subscribed to on %s"), + $this->_profile->getBestName()), + common_config('site', 'name')); + + $feed->addLink(common_local_url('subscriptions', + array('nickname' => + $this->_profile->nickname))); + + $feed->addLink($url, + array('rel' => 'self', + 'type' => 'application/atom+xml')); + + // If there's more... + + if ($this->page > 1) { + $feed->addLink($url, + array('rel' => 'first', + 'type' => 'application/atom+xml')); + + $feed->addLink(common_local_url('AtomPubSubscriptionFeed', + array('subscriber' => + $this->_profile->id, + 'page' => + $this->page - 1)), + array('rel' => 'prev', + 'type' => 'application/atom+xml')); + } + + if ($this->_subscribed->N > $this->count) { + + $feed->addLink(common_local_url('AtomPubSubscriptionFeed', + array('subscriber' => + $this->_profile->id, + 'page' => + $this->page + 1)), + array('rel' => 'next', + 'type' => 'application/atom+xml')); + } + + $i = 0; + + // XXX: This is kind of inefficient + + while ($this->_subscribed->fetch()) { + + // We get one more than needed; skip that one + + $i++; + + if ($i > $this->count) { + break; + } + + $sub = Subscription::pkeyGet(array('subscriber' => + $this->_profile->id, + 'subscribed' => + $this->_subscribed->id)); + $act = $sub->asActivity(); + $feed->addEntryRaw($act->asString(false, false, false)); + } + + $this->raw($feed->getString()); + } + + /** + * Return true if read only. + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + return $_SERVER['REQUEST_METHOD'] != 'POST'; + } + + /** + * Return last modified, if applicable. + * + * @return string last modified http header + */ + + function lastModified() + { + return null; + } + + /** + * Return etag, if applicable. + * + * @return string etag http header + */ + + function etag() + { + return null; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { + return true; + } else { + return false; + } + } +} diff --git a/actions/subscriptions.php b/actions/subscriptions.php index ba2f67f2da..a814a4f354 100644 --- a/actions/subscriptions.php +++ b/actions/subscriptions.php @@ -163,6 +163,22 @@ class SubscriptionsAction extends GalleryAction $cloud2 = new SubscriptionsPeopleSelfTagCloudSection($this); $cloud2->show(); } + + /** + * Link to feeds of subscriptions + * + * @return array of Feed objects + */ + + function getFeeds() + { + return array(new Feed(Feed::ATOM, + common_local_url('AtomPubSubscriptionFeed', + array('subscriber' => $this->profile->id)), + sprintf(_('Subscription feed for %s (Atom)'), + $this->profile->nickname))); + + } } // XXX SubscriptionsList and SubscriptionList are dangerously close @@ -247,4 +263,5 @@ class SubscriptionsListItem extends SubscriptionListItem $this->out->elementEnd('form'); return; } + } diff --git a/lib/router.php b/lib/router.php index 4e718f1322..e6b4c0fbc8 100644 --- a/lib/router.php +++ b/lib/router.php @@ -916,6 +916,10 @@ class Router array('subscriber' => '[0-9]+', 'subscribed' => '[0-9]+')); + $m->connect('api/statusnet/app/subscriptions/:subscriber.atom', + array('action' => 'AtomPubSubscriptionFeed'), + array('subscriber' => '[0-9]+')); + // user stuff Event::handle('RouterInitialized', array($m)); From 5d56dba9046bd25e28918af443008bfe54a41db1 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Thu, 9 Dec 2010 16:25:47 -0500 Subject: [PATCH 05/19] add a new subscription using POST to APP --- actions/atompubsubscriptionfeed.php | 70 ++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/actions/atompubsubscriptionfeed.php b/actions/atompubsubscriptionfeed.php index 5eddc4cddc..b5577b71ac 100644 --- a/actions/atompubsubscriptionfeed.php +++ b/actions/atompubsubscriptionfeed.php @@ -210,6 +210,74 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction $this->raw($feed->getString()); } + function addSubscription() + { + if (empty($this->auth_user) || + $this->auth_user->id != $this->_profile->id) { + throw new ClientException(_("Can't add someone else's". + " subscription"), 403); + } + + $xml = file_get_contents('php://input'); + + $dom = DOMDocument::loadXML($xml); + + if ($dom->documentElement->namespaceURI != Activity::ATOM || + $dom->documentElement->localName != 'entry') { + // TRANS: Client error displayed when not using an Atom entry. + $this->clientError(_('Atom post must be an Atom entry.')); + return; + } + + $activity = new Activity($dom->documentElement); + + $sub = null; + + if (Event::handle('StartAtomPubNewActivity', array(&$activity))) { + + if ($activity->verb != ActivityVerb::FOLLOW) { + // TRANS: Client error displayed when not using the POST verb. + // TRANS: Do not translate POST. + $this->clientError(_('Can only handle Follow activities.')); + return; + } + + $person = $activity->objects[0]; + + if ($person->type != ActivityObject::PERSON) { + $this->clientError(_('Can only follow people.')); + return; + } + + // XXX: OStatus discovery (maybe) + + $profile = Profile::fromURI($person->id); + + if (empty($profile)) { + $this->clientError(sprintf(_('Unknown profile %s'), $person->id)); + return; + } + + if (Subscription::start($this->_profile, $profile)) { + $sub = Subscription::pkeyGet(array('subscriber' => $this->_profile->id, + 'subscribed' => $profile->id)); + } + + Event::handle('EndAtomPubNewActivity', array($activity, $sub)); + } + + if (!empty($sub)) { + $act = $sub->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + header('Content-Location: ' . $act->selfLink); + + $this->startXML(); + $this->raw($act->asString(true, true, true)); + $this->endXML(); + } + } + /** * Return true if read only. * @@ -253,7 +321,7 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction function requiresAuth() { - if ($_SERVER['REQUEST_METHOD'] == 'DELETE') { + if ($_SERVER['REQUEST_METHOD'] == 'POST') { return true; } else { return false; From 37c447be4637dc639f586846d35e14a4bfce069d Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Fri, 10 Dec 2010 18:50:50 -0500 Subject: [PATCH 06/19] Show a single favorite for AtomPub --- actions/atompubshowfavorite.php | 228 ++++++++++++++++++++++++++++++++ classes/Fave.php | 10 ++ lib/router.php | 9 ++ 3 files changed, 247 insertions(+) create mode 100644 actions/atompubshowfavorite.php diff --git a/actions/atompubshowfavorite.php b/actions/atompubshowfavorite.php new file mode 100644 index 0000000000..5fe680bb7b --- /dev/null +++ b/actions/atompubshowfavorite.php @@ -0,0 +1,228 @@ +. + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Show a single favorite in Atom Activity Streams format. + * + * Can also be used to delete a favorite. + * + * @category Action + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubshowfavoriteAction extends ApiAuthAction +{ + private $_profile = null; + private $_notice = null; + private $_fave = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $profileId = $this->trimmed('profile'); + $noticeId = $this->trimmed('notice'); + + $this->_profile = Profile::staticGet('id', $profileId); + + if (empty($this->_profile)) { + throw new ClientException(_('No such profile.'), 404); + } + + $this->_notice = Notice::staticGet('id', $noticeId); + + if (empty($this->_notice)) { + throw new ClientException(_('No such notice.'), 404); + } + + $this->_fave = Fave::pkeyGet(array('user_id' => $profileId, + 'notice_id' => $noticeId)); + + if (empty($this->_fave)) { + throw new ClientException(_('No such favorite.'), 404); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($argarray); + + switch ($_SERVER['REQUEST_METHOD']) { + case GET: + case HEAD: + $this->showFave(); + break; + case DELETE: + $this->deleteFave(); + break; + default: + throw new ClientException(_('HTTP method not supported.'), + 405); + } + return true; + } + + /** + * Show a single favorite, in ActivityStreams format + * + * @return void + */ + + function showFave() + { + $activity = $this->_fave->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $this->startXML(); + $this->raw($activity->asString(true, true, true)); + $this->endXML(); + + return; + } + + /** + * Delete the favorite + * + * @return void + */ + + function deleteFave() + { + if (empty($this->auth_user) || + $this->auth_user->id != $this->_profile->id) { + throw new ClientException(_("Can't delete someone else's". + " favorite"), 403); + } + + $this->_fave->delete(); + + return; + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return true; + } else { + return false; + } + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + + function lastModified() + { + return max(strtotime($this->_profile->modified), + strtotime($this->_notice->modified), + strtotime($this->_fave->modified)); + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + $mtime = strtotime($this->_fave->modified); + + return 'W/"' . implode(':', array('AtomPubShowFavorite', + $this->_profile->id, + $this->_notice->id, + $mtime)) . '"'; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return false; + } else { + return true; + } + } +} diff --git a/classes/Fave.php b/classes/Fave.php index 9922ae45c5..030e67b561 100644 --- a/classes/Fave.php +++ b/classes/Fave.php @@ -138,6 +138,9 @@ class Fave extends Memcached_DataObject $act = new Activity(); $act->verb = ActivityVerb::FAVORITE; + + // FIXME: rationalize this with URL below + $act->id = TagURI::mint('favor:%d:%d:%s', $profile->id, $notice->id, @@ -155,6 +158,13 @@ class Fave extends Memcached_DataObject $act->actor = ActivityObject::fromProfile($profile); $act->objects[] = ActivityObject::fromNotice($notice); + $url = common_local_url('AtomPubShowFavorite', + array('profile' => $this->user_id, + 'notice' => $this->notice_id)); + + $act->selfLink = $url; + $act->editLink = $url; + return $act; } } diff --git a/lib/router.php b/lib/router.php index b20336164f..0ced86f34e 100644 --- a/lib/router.php +++ b/lib/router.php @@ -920,6 +920,15 @@ class Router array('action' => 'AtomPubSubscriptionFeed'), array('subscriber' => '[0-9]+')); + $m->connect('api/statusnet/app/favorites/:profile/:notice.atom', + array('action' => 'AtomPubShowFavorite'), + array('profile' => '[0-9]+', + 'notice' => '[0-9]+')); + + $m->connect('api/statusnet/app/favorites/:profile.atom', + array('action' => 'AtomPubFavoriteFeed'), + array('profile' => '[0-9]+')); + // user stuff Event::handle('RouterInitialized', array($m)); From d9a614c57ec864fdb4e19e957cf50ac662a3679f Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Sat, 11 Dec 2010 11:24:07 -0500 Subject: [PATCH 07/19] use new Subscription stream methods for AtomPub --- actions/atompubsubscriptionfeed.php | 31 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/actions/atompubsubscriptionfeed.php b/actions/atompubsubscriptionfeed.php index b5577b71ac..065790c202 100644 --- a/actions/atompubsubscriptionfeed.php +++ b/actions/atompubsubscriptionfeed.php @@ -51,8 +51,8 @@ require_once INSTALLDIR . '/lib/apiauth.php'; class AtompubsubscriptionfeedAction extends ApiAuthAction { - private $_profile = null; - private $_subscribed = null; + private $_profile = null; + private $_subscriptions = null; /** * For initializing members of the class. @@ -76,11 +76,12 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction } // page and count from ApiAction - // Note: this is a list of profiles, not subscriptions - $this->_subscribed = - $this->_profile->getSubscriptions(($this->page-1) * $this->count, - $this->count + 1); + $offset = ($this->page-1) * $this->count; + + $this->_subscriptions = Subscription::bySubscriber($subscriber, + $offset, + $this->count + 1); return true; } @@ -174,7 +175,7 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction 'type' => 'application/atom+xml')); } - if ($this->_subscribed->N > $this->count) { + if ($this->_subscriptions->N > $this->count) { $feed->addLink(common_local_url('AtomPubSubscriptionFeed', array('subscriber' => @@ -189,7 +190,7 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction // XXX: This is kind of inefficient - while ($this->_subscribed->fetch()) { + while ($this->_subscriptions->fetch()) { // We get one more than needed; skip that one @@ -199,17 +200,21 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction break; } - $sub = Subscription::pkeyGet(array('subscriber' => - $this->_profile->id, - 'subscribed' => - $this->_subscribed->id)); - $act = $sub->asActivity(); + $act = $this->_subscriptions->asActivity(); $feed->addEntryRaw($act->asString(false, false, false)); } $this->raw($feed->getString()); } + /** + * Add a new subscription + * + * Handling the POST method for AtomPub + * + * @return void + */ + function addSubscription() { if (empty($this->auth_user) || From 1817aedb5cf5c1cc7cfe1cb5146d087903b58e49 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Sun, 12 Dec 2010 12:13:54 -0500 Subject: [PATCH 08/19] fix subtitle in subscriptions feed --- actions/atompubsubscriptionfeed.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/atompubsubscriptionfeed.php b/actions/atompubsubscriptionfeed.php index 065790c202..95a6fdd1ac 100644 --- a/actions/atompubsubscriptionfeed.php +++ b/actions/atompubsubscriptionfeed.php @@ -148,8 +148,8 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction $this->_profile->getBestName())); $feed->setSubtitle(sprintf(_("People %s has subscribed to on %s"), - $this->_profile->getBestName()), - common_config('site', 'name')); + $this->_profile->getBestName(), + common_config('site', 'name'))); $feed->addLink(common_local_url('subscriptions', array('nickname' => From 7c37aa802ba2bc0616c78f7490986af0051f7dc4 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Sun, 12 Dec 2010 12:22:04 -0500 Subject: [PATCH 09/19] a stream function for Fave class --- classes/Fave.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/classes/Fave.php b/classes/Fave.php index 030e67b561..3aa23e7b4e 100644 --- a/classes/Fave.php +++ b/classes/Fave.php @@ -167,4 +167,32 @@ class Fave extends Memcached_DataObject return $act; } + + /** + * Fetch a stream of favorites by profile + * + * @param integer $profileId Profile that faved + * @param integer $offset Offset from last + * @param integer $limit Number to get + * + * @return mixed stream of faves, use fetch() to iterate + * + * @todo Cache results + * @todo integrate with Fave::stream() + */ + + static function byProfile($profileId, $offset, $limit) + { + $fav = new Fave(); + + $fav->user_id = $profileId; + + $fav->orderBy('modified DESC'); + + $fav->limit($offset, $limit); + + $fav->find(); + + return $fav; + } } From 30f0defcf18df3fbcc6a8d333e00f5a0a8389275 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Sun, 12 Dec 2010 13:16:34 -0500 Subject: [PATCH 10/19] atompub favorite feed --- actions/atompubfavoritefeed.php | 374 ++++++++++++++++++++++++++++++++ 1 file changed, 374 insertions(+) create mode 100644 actions/atompubfavoritefeed.php diff --git a/actions/atompubfavoritefeed.php b/actions/atompubfavoritefeed.php new file mode 100644 index 0000000000..b4c15548f5 --- /dev/null +++ b/actions/atompubfavoritefeed.php @@ -0,0 +1,374 @@ +. + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Feed of ActivityStreams 'favorite' actions + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubfavoritefeedAction extends ApiAuthAction +{ + private $_profile = null; + private $_faves = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $this->_profile = Profile::staticGet('id', $this->trimmed('profile')); + + if (empty($this->_profile)) { + throw new ClientException(_('No such profile'), 404); + } + + $offset = ($this->page-1) * $this->count; + $limit = $this->count + 1; + + $this->_faves = Fave::byProfile($this->_profile->id, + $offset, + $limit); + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($argarray); + + switch ($_SERVER['REQUEST_METHOD']) { + case 'HEAD': + case 'GET': + $this->showFeed(); + break; + case 'POST': + $this->addFavorite(); + break; + default: + throw new ClientException(_('HTTP method not supported.'), 405); + return; + } + + return; + } + + /** + * Show a feed of favorite activity streams objects + * + * @return void + */ + + function showFeed() + { + header('Content-Type: application/atom+xml; charset=utf-8'); + + $url = common_local_url('AtomPubFavoriteFeed', + array('profile' => $this->_profile->id)); + + $feed = new Atom10Feed(true); + + $feed->addNamespace('activity', + 'http://activitystrea.ms/spec/1.0/'); + + $feed->addNamespace('poco', + 'http://portablecontacts.net/spec/1.0'); + + $feed->addNamespace('media', + 'http://purl.org/syndication/atommedia'); + + $feed->id = $url; + + $feed->setUpdated('now'); + + $feed->addAuthor($this->_profile->getBestName(), + $this->_profile->getURI()); + + $feed->setTitle(sprintf(_("%s favorites"), + $this->_profile->getBestName())); + + $feed->setSubtitle(sprintf(_("Notices %s has favorited to on %s"), + $this->_profile->getBestName(), + common_config('site', 'name'))); + + $feed->addLink(common_local_url('showfavorites', + array('nickname' => + $this->_profile->nickname))); + + $feed->addLink($url, + array('rel' => 'self', + 'type' => 'application/atom+xml')); + + // If there's more... + + if ($this->page > 1) { + $feed->addLink($url, + array('rel' => 'first', + 'type' => 'application/atom+xml')); + + $feed->addLink(common_local_url('AtomPubFavoriteFeed', + array('profile' => + $this->_profile->id, + 'page' => + $this->page - 1)), + array('rel' => 'prev', + 'type' => 'application/atom+xml')); + } + + if ($this->_faves->N > $this->count) { + + $feed->addLink(common_local_url('AtomPubFavoriteFeed', + array('profile' => + $this->_profile->id, + 'page' => + $this->page + 1)), + array('rel' => 'next', + 'type' => 'application/atom+xml')); + } + + $i = 0; + + while ($this->_faves->fetch()) { + + // We get one more than needed; skip that one + + $i++; + + if ($i > $this->count) { + break; + } + + $act = $this->_faves->asActivity(); + $feed->addEntryRaw($act->asString(false, false, false)); + } + + $this->raw($feed->getString()); + } + + /** + * add a new favorite + * + * @return void + */ + + function addFavorite() + { + // XXX: Refactor this; all the same for atompub + + if (empty($this->auth_user) || + $this->auth_user->id != $this->_profile->id) { + throw new ClientException(_("Can't add someone else's". + " subscription"), 403); + } + + $xml = file_get_contents('php://input'); + + $dom = DOMDocument::loadXML($xml); + + if ($dom->documentElement->namespaceURI != Activity::ATOM || + $dom->documentElement->localName != 'entry') { + // TRANS: Client error displayed when not using an Atom entry. + throw new ClientException(_('Atom post must be an Atom entry.')); + return; + } + + $activity = new Activity($dom->documentElement); + + $fave = null; + + if (Event::handle('StartAtomPubNewActivity', array(&$activity))) { + + if ($activity->verb != ActivityVerb::FAVORITE) { + // TRANS: Client error displayed when not using the POST verb. + // TRANS: Do not translate POST. + throw new ClientException(_('Can only handle Favorite activities.')); + return; + } + + $note = $activity->objects[0]; + + if (!in_array($note->type, array(ActivityObject::NOTE, + ActivityObject::BLOGENTRY, + ActivityObject::STATUS))) { + throw new ClientException(_('Can only fave notices.')); + return; + } + + $notice = Notice::staticGet('uri', $note->id); + + if (empty($notice)) { + // XXX: import from listed URL or something + throw new ClientException(_('Unknown note.')); + } + + $old = Fave::pkeyGet(array('user_id' => $this->auth_user->id, + 'notice_id' => $notice->id)); + + if (!empty($old)) { + throw new ClientException(_('Already a favorite.')); + } + + $profile = $this->auth_user->getProfile(); + + $fave = Fave::addNew($profile, $notice); + + if (!empty($fave)) { + $this->_profile->blowFavesCache(); + $this->notify($fave, $notice, $this->auth_user); + } + + Event::handle('EndAtomPubNewActivity', array($activity, $fave)); + } + + if (!empty($fave)) { + $act = $fave->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + header('Content-Location: ' . $act->selfLink); + + $this->startXML(); + $this->raw($act->asString(true, true, true)); + $this->endXML(); + } + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return true; + } else { + return false; + } + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + function lastModified() + { + // For comparison with If-Last-Modified + // If not applicable, return null + return null; + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + return null; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return false; + } else { + return true; + } + } + + /** + * Notify the author of the favorite that the user likes their notice + * + * @param Favorite $fave the favorite in question + * @param Notice $notice the notice that's been faved + * @param User $user the user doing the favoriting + * + * @return void + */ + + function notify($fave, $notice, $user) + { + $other = User::staticGet('id', $notice->profile_id); + if ($other && $other->id != $user->id) { + if ($other->email && $other->emailnotifyfav) { + mail_notify_fave($other, $user, $notice); + } + // XXX: notify by IM + // XXX: notify by SMS + } + } +} From 4b7a0d366ccc2da4c178ff0cff0f207732301a0a Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 12:40:01 -0500 Subject: [PATCH 11/19] add atompub membership actions to router --- lib/router.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/router.php b/lib/router.php index 0ced86f34e..e05fa096de 100644 --- a/lib/router.php +++ b/lib/router.php @@ -929,6 +929,15 @@ class Router array('action' => 'AtomPubFavoriteFeed'), array('profile' => '[0-9]+')); + $m->connect('api/statusnet/app/memberships/:profile/:group.atom', + array('action' => 'AtomPubShowMembership'), + array('profile' => '[0-9]+', + 'group' => '[0-9]+')); + + $m->connect('api/statusnet/app/membership/:profile.atom', + array('action' => 'AtomPubMembershipFeed'), + array('profile' => '[0-9]+')); + // user stuff Event::handle('RouterInitialized', array($m)); From 1a58fdd695be2e4c6485354a8bf46414ac041b65 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 12:40:22 -0500 Subject: [PATCH 12/19] add atompub show membership action --- actions/atompubshowmembership.php | 235 ++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 actions/atompubshowmembership.php diff --git a/actions/atompubshowmembership.php b/actions/atompubshowmembership.php new file mode 100644 index 0000000000..6d848a2290 --- /dev/null +++ b/actions/atompubshowmembership.php @@ -0,0 +1,235 @@ +. + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Show (or delete) a single membership event as an ActivityStreams entry + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubshowmembershipAction extends ApiAuthAction +{ + private $_profile = null; + private $_group = null; + private $_membership = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $profileId = $this->trimmed('profile'); + + $this->_profile = Profile::staticGet('id', $profileId); + + if (empty($this->_profile)) { + throw new ClientException(_('No such profile.'), 404); + } + + $groupId = $this->trimmed('group'); + + $this->_group = User_group::staticGet('id', $groupId); + + if (empty($this->_group)) { + throw new ClientException(_('No such group'), 404); + } + + $kv = array('group_id' => $groupId, + 'profile_id' => $profileId); + + $this->_membership = Group_member::pkeyGet($kv); + + if (empty($this->_membership)) { + throw new ClientException(_('Not a member'), 404); + } + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + case 'HEAD': + $this->showMembership(); + break; + case 'DELETE': + $this->deleteMembership(); + break; + default: + throw new ClientException(_('Method not supported'), 405); + break; + } + return; + } + + /** + * show a single membership + * + * @return void + */ + + function showMembership() + { + $activity = $this->_membership->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + + $this->startXML(); + $this->raw($activity->asString(true, true, true)); + $this->endXML(); + + return; + } + + /** + * Delete the membership (leave the group) + * + * @return void + */ + + function deleteMembership() + { + if (empty($this->auth_user) || + $this->auth_user->id != $this->_profile->id) { + throw new ClientException(_("Can't delete someone else's". + " membership"), 403); + } + + if (Event::handle('StartLeaveGroup', array($this->_group, $this->auth_user))) { + Group_member::leave($this->_group->id, $this->auth_user->id); + Event::handle('EndLeaveGroup', array($this->_group, $this->auth_user)); + } + + return; + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return true; + } else { + return false; + } + } + + /** + * Return last modified, if applicable. + * + * Because the representation depends on the profile and group, + * our last modified value is the maximum of their mod time + * with the actual membership's mod time. + * + * @return string last modified http header + */ + function lastModified() + { + return max(strtotime($this->_profile->modified), + strtotime($this->_group->modified), + strtotime($this->_membership->modified)); + } + + /** + * Return etag, if applicable. + * + * A "weak" Etag including the profile and group id as well as + * the admin flag and ctime of the membership. + * + * @return string etag http header + */ + + function etag() + { + $ctime = strtotime($this->_membership->created); + + $adminflag = ($this->_membership->is_admin) ? 't' : 'f'; + + return 'W/"' . implode(':', array('AtomPubShowMembership', + $this->_profile->id, + $this->_group->id, + $adminflag, + $ctime)) . '"'; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return false; + } else { + return true; + } + } +} From 5bbd77b761bfaed08903a3c3319c2ad0a99d7e3f Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 12:40:44 -0500 Subject: [PATCH 13/19] group_member includes self link, edit link --- classes/Group_member.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/classes/Group_member.php b/classes/Group_member.php index c40d06a1db..e513f6f3b2 100644 --- a/classes/Group_member.php +++ b/classes/Group_member.php @@ -118,6 +118,13 @@ class Group_member extends Memcached_DataObject $member->getBestName(), $group->getBestName()); + $url = common_local_url('AtomPubShowMembership', + array('profile' => $member->id, + 'group' => $group->id)); + + $act->selfLink = $url; + $act->editLink = $url; + return $act; } } From c5fee7573e486b572cb48d32f0286e76781775b6 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 13:07:25 -0500 Subject: [PATCH 14/19] fix navigation links for subscriptions feed --- actions/atompubsubscriptionfeed.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/atompubsubscriptionfeed.php b/actions/atompubsubscriptionfeed.php index 95a6fdd1ac..15ae79f6a6 100644 --- a/actions/atompubsubscriptionfeed.php +++ b/actions/atompubsubscriptionfeed.php @@ -168,8 +168,8 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction $feed->addLink(common_local_url('AtomPubSubscriptionFeed', array('subscriber' => - $this->_profile->id, - 'page' => + $this->_profile->id), + array('page' => $this->page - 1)), array('rel' => 'prev', 'type' => 'application/atom+xml')); @@ -179,8 +179,8 @@ class AtompubsubscriptionfeedAction extends ApiAuthAction $feed->addLink(common_local_url('AtomPubSubscriptionFeed', array('subscriber' => - $this->_profile->id, - 'page' => + $this->_profile->id), + array('page' => $this->page + 1)), array('rel' => 'next', 'type' => 'application/atom+xml')); From 156bd011afac0d5f4a37669c09e70a9b63d077ee Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 13:08:38 -0500 Subject: [PATCH 15/19] fix navigation links for favorite feed --- actions/atompubfavoritefeed.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/actions/atompubfavoritefeed.php b/actions/atompubfavoritefeed.php index b4c15548f5..478a01b7c6 100644 --- a/actions/atompubfavoritefeed.php +++ b/actions/atompubfavoritefeed.php @@ -163,8 +163,8 @@ class AtompubfavoritefeedAction extends ApiAuthAction $feed->addLink(common_local_url('AtomPubFavoriteFeed', array('profile' => - $this->_profile->id, - 'page' => + $this->_profile->id), + array('page' => $this->page - 1)), array('rel' => 'prev', 'type' => 'application/atom+xml')); @@ -174,8 +174,8 @@ class AtompubfavoritefeedAction extends ApiAuthAction $feed->addLink(common_local_url('AtomPubFavoriteFeed', array('profile' => - $this->_profile->id, - 'page' => + $this->_profile->id), + array('page' => $this->page + 1)), array('rel' => 'next', 'type' => 'application/atom+xml')); From a93f0fea618f189cedd0093f77437369d306db95 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 13:50:39 -0500 Subject: [PATCH 16/19] membership stream method and return membership from join() in Group_member class --- classes/Group_member.php | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/classes/Group_member.php b/classes/Group_member.php index e513f6f3b2..2cf31cf123 100644 --- a/classes/Group_member.php +++ b/classes/Group_member.php @@ -26,6 +26,15 @@ class Group_member extends Memcached_DataObject return Memcached_DataObject::pkeyGet('Group_member', $kv); } + /** + * Method to add a user to a group. + * + * @param integer $group_id Group to add to + * @param integer $profile_id Profile being added + * + * @return Group_member new membership object + */ + static function join($group_id, $profile_id) { $member = new Group_member(); @@ -42,7 +51,7 @@ class Group_member extends Memcached_DataObject throw new Exception(_("Group join failed.")); } - return true; + return $member; } static function leave($group_id, $profile_id) @@ -92,6 +101,31 @@ class Group_member extends Memcached_DataObject return $group; } + /** + * Get stream of memberships by member + * + * @param integer $memberId profile ID of the member to fetch for + * @param integer $offset offset from start of stream to get + * @param integer $limit number of memberships to get + * + * @return Group_member stream of memberships, use fetch() to iterate + */ + + static function byMember($memberId, $offset=0, $limit=GROUPS_PER_PAGE) + { + $membership = new Group_member(); + + $membership->profile_id = $memberId; + + $membership->orderBy('created DESC'); + + $membership->limit($offset, $limit); + + $membership->find(); + + return $membership; + } + function asActivity() { $member = $this->getMember(); From 4be9c0a0e98b2135347baa9aea257d819719c7bf Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 13:50:57 -0500 Subject: [PATCH 17/19] fix URL for memberships feed --- lib/router.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/router.php b/lib/router.php index e05fa096de..c42cca5f60 100644 --- a/lib/router.php +++ b/lib/router.php @@ -934,7 +934,7 @@ class Router array('profile' => '[0-9]+', 'group' => '[0-9]+')); - $m->connect('api/statusnet/app/membership/:profile.atom', + $m->connect('api/statusnet/app/memberships/:profile.atom', array('action' => 'AtomPubMembershipFeed'), array('profile' => '[0-9]+')); From c6d6f25b5240a6d1eae2ef0c8e5de84e46c79676 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 13:56:54 -0500 Subject: [PATCH 18/19] Atom pub feed for group memberships Feed for group memberships, in activity streams format. Shows a feed; has proper pagination; accepts activitystreams "join" activities to start a new membership. --- actions/atompubmembershipfeed.php | 355 ++++++++++++++++++++++++++++++ 1 file changed, 355 insertions(+) create mode 100644 actions/atompubmembershipfeed.php diff --git a/actions/atompubmembershipfeed.php b/actions/atompubmembershipfeed.php new file mode 100644 index 0000000000..3002576c15 --- /dev/null +++ b/actions/atompubmembershipfeed.php @@ -0,0 +1,355 @@ +. + * + * @category AtomPub + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +if (!defined('STATUSNET')) { + // This check helps protect against security problems; + // your code file can't be executed directly from the web. + exit(1); +} + +require_once INSTALLDIR . '/lib/apiauth.php'; + +/** + * Feed of group memberships for a user, in ActivityStreams format + * + * @category Action + * @package StatusNet + * @author Evan Prodromou + * @copyright 2010 StatusNet, Inc. + * @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPL 3.0 + * @link http://status.net/ + */ + +class AtompubmembershipfeedAction extends ApiAuthAction +{ + private $_profile = null; + private $_memberships = null; + + /** + * For initializing members of the class. + * + * @param array $argarray misc. arguments + * + * @return boolean true + */ + + function prepare($argarray) + { + parent::prepare($argarray); + + $profileId = $this->trimmed('profile'); + + $this->_profile = Profile::staticGet('id', $profileId); + + if (empty($this->_profile)) { + throw new ClientException(_('No such profile.'), 404); + } + + $offset = ($this->page-1) * $this->count; + $limit = $this->count + 1; + + $this->_memberships = Group_member::byMember($this->_profile->id, + $offset, + $limit); + + return true; + } + + /** + * Handler method + * + * @param array $argarray is ignored since it's now passed in in prepare() + * + * @return void + */ + + function handle($argarray=null) + { + parent::handle($argarray); + + switch ($_SERVER['REQUEST_METHOD']) { + case 'HEAD': + case 'GET': + $this->showFeed(); + break; + case 'POST': + $this->addMembership(); + break; + default: + throw new ClientException(_('HTTP method not supported.'), 405); + return; + } + + return; + } + + /** + * Show a feed of favorite activity streams objects + * + * @return void + */ + + function showFeed() + { + header('Content-Type: application/atom+xml; charset=utf-8'); + + $url = common_local_url('AtomPubMembershipFeed', + array('profile' => $this->_profile->id)); + + $feed = new Atom10Feed(true); + + $feed->addNamespace('activity', + 'http://activitystrea.ms/spec/1.0/'); + + $feed->addNamespace('poco', + 'http://portablecontacts.net/spec/1.0'); + + $feed->addNamespace('media', + 'http://purl.org/syndication/atommedia'); + + $feed->id = $url; + + $feed->setUpdated('now'); + + $feed->addAuthor($this->_profile->getBestName(), + $this->_profile->getURI()); + + $feed->setTitle(sprintf(_("%s group memberships"), + $this->_profile->getBestName())); + + $feed->setSubtitle(sprintf(_("Groups %s is a member of on %s"), + $this->_profile->getBestName(), + common_config('site', 'name'))); + + $feed->addLink(common_local_url('usergroups', + array('nickname' => + $this->_profile->nickname))); + + $feed->addLink($url, + array('rel' => 'self', + 'type' => 'application/atom+xml')); + + // If there's more... + + if ($this->page > 1) { + $feed->addLink($url, + array('rel' => 'first', + 'type' => 'application/atom+xml')); + + $feed->addLink(common_local_url('AtomPubMembershipFeed', + array('profile' => + $this->_profile->id), + array('page' => + $this->page - 1)), + array('rel' => 'prev', + 'type' => 'application/atom+xml')); + } + + if ($this->_memberships->N > $this->count) { + + $feed->addLink(common_local_url('AtomPubMembershipFeed', + array('profile' => + $this->_profile->id), + array('page' => + $this->page + 1)), + array('rel' => 'next', + 'type' => 'application/atom+xml')); + } + + $i = 0; + + while ($this->_memberships->fetch()) { + + // We get one more than needed; skip that one + + $i++; + + if ($i > $this->count) { + break; + } + + $act = $this->_memberships->asActivity(); + $feed->addEntryRaw($act->asString(false, false, false)); + } + + $this->raw($feed->getString()); + } + + /** + * add a new favorite + * + * @return void + */ + + function addMembership() + { + // XXX: Refactor this; all the same for atompub + + if (empty($this->auth_user) || + $this->auth_user->id != $this->_profile->id) { + throw new ClientException(_("Can't add someone else's". + " membership"), 403); + } + + $xml = file_get_contents('php://input'); + + $dom = DOMDocument::loadXML($xml); + + if ($dom->documentElement->namespaceURI != Activity::ATOM || + $dom->documentElement->localName != 'entry') { + // TRANS: Client error displayed when not using an Atom entry. + throw new ClientException(_('Atom post must be an Atom entry.')); + return; + } + + $activity = new Activity($dom->documentElement); + + $membership = null; + + if (Event::handle('StartAtomPubNewActivity', array(&$activity))) { + + if ($activity->verb != ActivityVerb::JOIN) { + // TRANS: Client error displayed when not using the POST verb. + // TRANS: Do not translate POST. + throw new ClientException(_('Can only handle Join activities.')); + return; + } + + $groupObj = $activity->objects[0]; + + if ($groupObj->type != ActivityObject::GROUP) { + throw new ClientException(_('Can only fave notices.')); + return; + } + + $group = User_group::staticGet('uri', $groupObj->id); + + if (empty($group)) { + // XXX: import from listed URL or something + throw new ClientException(_('Unknown group.')); + } + + $old = Group_member::pkeyGet(array('profile_id' => $this->auth_user->id, + 'group_id' => $group->id)); + + if (!empty($old)) { + throw new ClientException(_('Already a member.')); + } + + $profile = $this->auth_user->getProfile(); + + if (Group_block::isBlocked($group, $profile)) { + // XXX: import from listed URL or something + throw new ClientException(_('Blocked by admin.')); + } + + if (Event::handle('StartJoinGroup', array($group, $this->auth_user))) { + $membership = Group_member::join($group->id, $this->auth_user->id); + Event::handle('EndJoinGroup', array($group, $this->auth_user)); + } + + Event::handle('EndAtomPubNewActivity', array($activity, $membership)); + } + + if (!empty($membership)) { + $act = $membership->asActivity(); + + header('Content-Type: application/atom+xml; charset=utf-8'); + header('Content-Location: ' . $act->selfLink); + + $this->startXML(); + $this->raw($act->asString(true, true, true)); + $this->endXML(); + } + } + + /** + * Return true if read only. + * + * MAY override + * + * @param array $args other arguments + * + * @return boolean is read only action? + */ + + function isReadOnly($args) + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return true; + } else { + return false; + } + } + + /** + * Return last modified, if applicable. + * + * MAY override + * + * @return string last modified http header + */ + function lastModified() + { + // For comparison with If-Last-Modified + // If not applicable, return null + return null; + } + + /** + * Return etag, if applicable. + * + * MAY override + * + * @return string etag http header + */ + + function etag() + { + return null; + } + + /** + * Does this require authentication? + * + * @return boolean true if delete, else false + */ + + function requiresAuth() + { + if ($_SERVER['REQUEST_METHOD'] == 'GET' || + $_SERVER['REQUEST_METHOD'] == 'HEAD') { + return false; + } else { + return true; + } + } +} From 1033e1e5199bd0c5889ecbc915c6b644cff23c10 Mon Sep 17 00:00:00 2001 From: Evan Prodromou Date: Mon, 13 Dec 2010 14:35:29 -0500 Subject: [PATCH 19/19] add the other three feeds to AtomPub service document --- actions/apiatomservice.php | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/actions/apiatomservice.php b/actions/apiatomservice.php index fb9d6aee82..b60b312fc4 100644 --- a/actions/apiatomservice.php +++ b/actions/apiatomservice.php @@ -80,7 +80,8 @@ class ApiAtomServiceAction extends ApiBareAuthAction $this->startXML(); $this->elementStart('service', array('xmlns' => 'http://www.w3.org/2007/app', - 'xmlns:atom' => 'http://www.w3.org/2005/Atom')); + 'xmlns:atom' => 'http://www.w3.org/2005/Atom', + 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/')); $this->elementStart('workspace'); $this->element('atom:title', null, _('Main')); $this->elementStart('collection', @@ -92,6 +93,37 @@ class ApiAtomServiceAction extends ApiBareAuthAction sprintf(_("%s timeline"), $this->user->nickname)); $this->element('accept', null, 'application/atom+xml;type=entry'); + $this->element('activity:verb', null, ActivityVerb::POST); + $this->elementEnd('collection'); + $this->elementStart('collection', + array('href' => common_local_url('AtomPubSubscriptionFeed', + array('subscriber' => $this->user->id)))); + $this->element('atom:title', + null, + sprintf(_("%s subscriptions"), + $this->user->nickname)); + $this->element('accept', null, 'application/atom+xml;type=entry'); + $this->element('activity:verb', null, ActivityVerb::FOLLOW); + $this->elementEnd('collection'); + $this->elementStart('collection', + array('href' => common_local_url('AtomPubFavoriteFeed', + array('profile' => $this->user->id)))); + $this->element('atom:title', + null, + sprintf(_("%s favorites"), + $this->user->nickname)); + $this->element('accept', null, 'application/atom+xml;type=entry'); + $this->element('activity:verb', null, ActivityVerb::FAVORITE); + $this->elementEnd('collection'); + $this->elementStart('collection', + array('href' => common_local_url('AtomPubMembershipFeed', + array('profile' => $this->user->id)))); + $this->element('atom:title', + null, + sprintf(_("%s memberships"), + $this->user->nickname)); + $this->element('accept', null, 'application/atom+xml;type=entry'); + $this->element('activity:verb', null, ActivityVerb::JOIN); $this->elementEnd('collection'); $this->elementEnd('workspace'); $this->elementEnd('service');