Merge branch 'testing' into 0.9.x

Conflicts:
	lib/activity.php
This commit is contained in:
Evan Prodromou 2010-03-20 16:11:42 -05:00
commit e458e9fe63
11 changed files with 1490 additions and 1136 deletions

File diff suppressed because it is too large Load Diff

121
lib/activitycontext.php Normal file
View File

@ -0,0 +1,121 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
class ActivityContext
{
public $replyToID;
public $replyToUrl;
public $location;
public $attention = array();
public $conversation;
const THR = 'http://purl.org/syndication/thread/1.0';
const GEORSS = 'http://www.georss.org/georss';
const OSTATUS = 'http://ostatus.org/schema/1.0';
const INREPLYTO = 'in-reply-to';
const REF = 'ref';
const HREF = 'href';
const POINT = 'point';
const ATTENTION = 'ostatus:attention';
const CONVERSATION = 'ostatus:conversation';
function __construct($element)
{
$replyToEl = ActivityUtils::child($element, self::INREPLYTO, self::THR);
if (!empty($replyToEl)) {
$this->replyToID = $replyToEl->getAttribute(self::REF);
$this->replyToUrl = $replyToEl->getAttribute(self::HREF);
}
$this->location = $this->getLocation($element);
$this->conversation = ActivityUtils::getLink($element, self::CONVERSATION);
// Multiple attention links allowed
$links = $element->getElementsByTagNameNS(ActivityUtils::ATOM, ActivityUtils::LINK);
for ($i = 0; $i < $links->length; $i++) {
$link = $links->item($i);
$linkRel = $link->getAttribute(ActivityUtils::REL);
if ($linkRel == self::ATTENTION) {
$this->attention[] = $link->getAttribute(self::HREF);
}
}
}
/**
* Parse location given as a GeoRSS-simple point, if provided.
* http://www.georss.org/simple
*
* @param feed item $entry
* @return mixed Location or false
*/
function getLocation($dom)
{
$points = $dom->getElementsByTagNameNS(self::GEORSS, self::POINT);
for ($i = 0; $i < $points->length; $i++) {
$point = $points->item($i)->textContent;
return self::locationFromPoint($point);
}
return null;
}
// XXX: Move to ActivityUtils or Location?
static function locationFromPoint($point)
{
$point = str_replace(',', ' ', $point); // per spec "treat commas as whitespace"
$point = preg_replace('/\s+/', ' ', $point);
$point = trim($point);
$coords = explode(' ', $point);
if (count($coords) == 2) {
list($lat, $lon) = $coords;
if (is_numeric($lat) && is_numeric($lon)) {
common_log(LOG_INFO, "Looking up location for $lat $lon from georss point");
return Location::fromLatLon($lat, $lon);
}
}
common_log(LOG_ERR, "Ignoring bogus georss:point value $point");
return null;
}
}

494
lib/activityobject.php Normal file
View File

@ -0,0 +1,494 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
/**
* A noun-ish thing in the activity universe
*
* The activity streams spec talks about activity objects, while also having
* a tag activity:object, which is in fact an activity object. Aaaaaah!
*
* This is just a thing in the activity universe. Can be the subject, object,
* or indirect object (target!) of an activity verb. Rotten name, and I'm
* propagating it. *sigh*
*
* @category OStatus
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
class ActivityObject
{
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!
// Atom elements we snarf
const TITLE = 'title';
const SUMMARY = 'summary';
const ID = 'id';
const SOURCE = 'source';
const NAME = 'name';
const URI = 'uri';
const EMAIL = 'email';
public $element;
public $type;
public $id;
public $title;
public $summary;
public $content;
public $link;
public $source;
public $avatarLinks = array();
public $geopoint;
public $poco;
public $displayName;
/**
* Constructor
*
* This probably needs to be refactored
* to generate a local class (ActivityPerson, ActivityFile, ...)
* based on the object type.
*
* @param DOMElement $element DOM thing to turn into an Activity thing
*/
function __construct($element = null)
{
if (empty($element)) {
return;
}
$this->element = $element;
$this->geopoint = $this->_childContent(
$element,
ActivityContext::POINT,
ActivityContext::GEORSS
);
if ($element->tagName == 'author') {
$this->_fromAuthor($element);
} else if ($element->tagName == 'item') {
$this->_fromRssItem($element);
} else {
$this->_fromAtomEntry($element);
}
// Some per-type attributes...
if ($this->type == self::PERSON || $this->type == self::GROUP) {
$this->displayName = $this->title;
$photos = ActivityUtils::getLinks($element, 'photo');
if (count($photos)) {
foreach ($photos as $link) {
$this->avatarLinks[] = new AvatarLink($link);
}
} else {
$avatars = ActivityUtils::getLinks($element, 'avatar');
foreach ($avatars as $link) {
$this->avatarLinks[] = new AvatarLink($link);
}
}
$this->poco = new PoCo($element);
}
}
private function _fromAuthor($element)
{
$this->type = self::PERSON; // XXX: is this fair?
$this->title = $this->_childContent($element, self::NAME);
$this->id = $this->_childContent($element, self::URI);
if (empty($this->id)) {
$email = $this->_childContent($element, self::EMAIL);
if (!empty($email)) {
// XXX: acct: ?
$this->id = 'mailto:'.$email;
}
}
}
private function _fromAtomEntry($element)
{
$this->type = $this->_childContent($element, Activity::OBJECTTYPE,
Activity::SPEC);
if (empty($this->type)) {
$this->type = ActivityObject::NOTE;
}
$this->id = $this->_childContent($element, self::ID);
$this->summary = ActivityUtils::childHtmlContent($element, self::SUMMARY);
$this->content = ActivityUtils::getContent($element);
// We don't like HTML in our titles, although it's technically allowed
$title = ActivityUtils::childHtmlContent($element, self::TITLE);
$this->title = html_entity_decode(strip_tags($title));
$this->source = $this->_getSource($element);
$this->link = ActivityUtils::getPermalink($element);
}
// @fixme rationalize with Activity::_fromRssItem()
private function _fromRssItem($item)
{
$this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, Activity::RSS);
$contentEl = ActivityUtils::child($item, ActivityUtils::CONTENT, Activity::CONTENTNS);
if (!empty($contentEl)) {
$this->content = htmlspecialchars_decode($contentEl->textContent, ENT_QUOTES);
} else {
$descriptionEl = ActivityUtils::child($item, Activity::DESCRIPTION, Activity::RSS);
if (!empty($descriptionEl)) {
$this->content = htmlspecialchars_decode($descriptionEl->textContent, ENT_QUOTES);
}
}
$this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, Activity::RSS);
$guidEl = ActivityUtils::child($item, Activity::GUID, Activity::RSS);
if (!empty($guidEl)) {
$this->id = $guidEl->textContent;
if ($guidEl->hasAttribute('isPermaLink')) {
// overwrites <link>
$this->link = $this->id;
}
}
}
public static function fromRssAuthor($el)
{
$text = $el->textContent;
if (preg_match('/^(.*?) \((.*)\)$/', $text, $match)) {
$email = $match[1];
$name = $match[2];
} else if (preg_match('/^(.*?) <(.*)>$/', $text, $match)) {
$name = $match[1];
$email = $match[2];
} else if (preg_match('/.*@.*/', $text)) {
$email = $text;
$name = null;
} else {
$name = $text;
$email = null;
}
// Not really enough info
$obj = new ActivityObject();
$obj->element = $el;
$obj->type = ActivityObject::PERSON;
$obj->title = $name;
if (!empty($email)) {
$obj->id = 'mailto:'.$email;
}
return $obj;
}
public static function fromDcCreator($el)
{
// Not really enough info
$text = $el->textContent;
$obj = new ActivityObject();
$obj->element = $el;
$obj->title = $text;
$obj->type = ActivityObject::PERSON;
return $obj;
}
public static function fromRssChannel($el)
{
$obj = new ActivityObject();
$obj->element = $el;
$obj->type = ActivityObject::PERSON; // @fixme guess better
$obj->title = ActivityUtils::childContent($el, ActivityObject::TITLE, Activity::RSS);
$obj->link = ActivityUtils::childContent($el, ActivityUtils::LINK, Activity::RSS);
$obj->id = ActivityUtils::getLink($el, Activity::SELF);
if (empty($obj->id)) {
$obj->id = $obj->link;
}
$desc = ActivityUtils::childContent($el, Activity::DESCRIPTION, Activity::RSS);
if (!empty($desc)) {
$obj->content = htmlspecialchars_decode($desc, ENT_QUOTES);
}
$imageEl = ActivityUtils::child($el, Activity::IMAGE, Activity::RSS);
if (!empty($imageEl)) {
$obj->avatarLinks[] = ActivityUtils::childContent($imageEl, Activity::URL, Activity::RSS);
}
return $obj;
}
private function _childContent($element, $tag, $namespace=ActivityUtils::ATOM)
{
return ActivityUtils::childContent($element, $tag, $namespace);
}
// Try to get a unique id for the source feed
private function _getSource($element)
{
$sourceEl = ActivityUtils::child($element, 'source');
if (empty($sourceEl)) {
return null;
} else {
$href = ActivityUtils::getLink($sourceEl, 'self');
if (!empty($href)) {
return $href;
} else {
return ActivityUtils::childContent($sourceEl, 'id');
}
}
}
static function fromNotice(Notice $notice)
{
$object = new ActivityObject();
$object->type = ActivityObject::NOTE;
$object->id = $notice->uri;
$object->title = $notice->content;
$object->content = $notice->rendered;
$object->link = $notice->bestUrl();
return $object;
}
static function fromProfile(Profile $profile)
{
$object = new ActivityObject();
$object->type = ActivityObject::PERSON;
$object->id = $profile->getUri();
$object->title = $profile->getBestName();
$object->link = $profile->profileurl;
$orig = $profile->getOriginalAvatar();
if (!empty($orig)) {
$object->avatarLinks[] = AvatarLink::fromAvatar($orig);
}
$sizes = array(
AVATAR_PROFILE_SIZE,
AVATAR_STREAM_SIZE,
AVATAR_MINI_SIZE
);
foreach ($sizes as $size) {
$alink = null;
$avatar = $profile->getAvatar($size);
if (!empty($avatar)) {
$alink = AvatarLink::fromAvatar($avatar);
} else {
$alink = new AvatarLink();
$alink->type = 'image/png';
$alink->height = $size;
$alink->width = $size;
$alink->url = Avatar::defaultImage($size);
}
$object->avatarLinks[] = $alink;
}
if (isset($profile->lat) && isset($profile->lon)) {
$object->geopoint = (float)$profile->lat
. ' ' . (float)$profile->lon;
}
$object->poco = PoCo::fromProfile($profile);
return $object;
}
static function fromGroup($group)
{
$object = new ActivityObject();
$object->type = ActivityObject::GROUP;
$object->id = $group->getUri();
$object->title = $group->getBestName();
$object->link = $group->getUri();
$object->avatarLinks[] = AvatarLink::fromFilename(
$group->homepage_logo,
AVATAR_PROFILE_SIZE
);
$object->avatarLinks[] = AvatarLink::fromFilename(
$group->stream_logo,
AVATAR_STREAM_SIZE
);
$object->avatarLinks[] = AvatarLink::fromFilename(
$group->mini_logo,
AVATAR_MINI_SIZE
);
$object->poco = PoCo::fromGroup($group);
return $object;
}
function asString($tag='activity:object')
{
$xs = new XMLStringer(true);
$xs->elementStart($tag);
$xs->element('activity:object-type', null, $this->type);
$xs->element(self::ID, null, $this->id);
if (!empty($this->title)) {
$xs->element(
self::TITLE,
null,
common_xml_safe_str($this->title)
);
}
if (!empty($this->summary)) {
$xs->element(
self::SUMMARY,
null,
common_xml_safe_str($this->summary)
);
}
if (!empty($this->content)) {
// XXX: assuming HTML content here
$xs->element(
ActivityUtils::CONTENT,
array('type' => 'html'),
common_xml_safe_str($this->content)
);
}
if (!empty($this->link)) {
$xs->element(
'link',
array(
'rel' => 'alternate',
'type' => 'text/html',
'href' => $this->link
),
null
);
}
if ($this->type == ActivityObject::PERSON
|| $this->type == ActivityObject::GROUP) {
foreach ($this->avatarLinks as $avatar) {
$xs->element(
'link', array(
'rel' => 'avatar',
'type' => $avatar->type,
'media:width' => $avatar->width,
'media:height' => $avatar->height,
'href' => $avatar->url
),
null
);
}
}
if (!empty($this->geopoint)) {
$xs->element(
'georss:point',
null,
$this->geopoint
);
}
if (!empty($this->poco)) {
$xs->raw($this->poco->asString());
}
$xs->elementEnd($tag);
return $xs->getString();
}
}

243
lib/activityutils.php Normal file
View File

@ -0,0 +1,243 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
/**
* Utilities for turning DOMish things into Activityish things
*
* Some common functions that I didn't have the bandwidth to try to factor
* into some kind of reasonable superclass, so just dumped here. Might
* be useful to have an ActivityObject parent class or something.
*
* @category OStatus
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
class ActivityUtils
{
const ATOM = 'http://www.w3.org/2005/Atom';
const LINK = 'link';
const REL = 'rel';
const TYPE = 'type';
const HREF = 'href';
const CONTENT = 'content';
const SRC = 'src';
/**
* Get the permalink for an Activity object
*
* @param DOMElement $element A DOM element
*
* @return string related link, if any
*/
static function getPermalink($element)
{
return self::getLink($element, 'alternate', 'text/html');
}
/**
* Get the permalink for an Activity object
*
* @param DOMElement $element A DOM element
*
* @return string related link, if any
*/
static function getLink(DOMNode $element, $rel, $type=null)
{
$els = $element->childNodes;
foreach ($els as $link) {
if (!($link instanceof DOMElement)) {
continue;
}
if ($link->localName == self::LINK && $link->namespaceURI == self::ATOM) {
$linkRel = $link->getAttribute(self::REL);
$linkType = $link->getAttribute(self::TYPE);
if ($linkRel == $rel &&
(is_null($type) || $linkType == $type)) {
return $link->getAttribute(self::HREF);
}
}
}
return null;
}
static function getLinks(DOMNode $element, $rel, $type=null)
{
$els = $element->childNodes;
$out = array();
foreach ($els as $link) {
if ($link->localName == self::LINK && $link->namespaceURI == self::ATOM) {
$linkRel = $link->getAttribute(self::REL);
$linkType = $link->getAttribute(self::TYPE);
if ($linkRel == $rel &&
(is_null($type) || $linkType == $type)) {
$out[] = $link;
}
}
}
return $out;
}
/**
* Gets the first child element with the given tag
*
* @param DOMElement $element element to pick at
* @param string $tag tag to look for
* @param string $namespace Namespace to look under
*
* @return DOMElement found element or null
*/
static function child(DOMNode $element, $tag, $namespace=self::ATOM)
{
$els = $element->childNodes;
if (empty($els) || $els->length == 0) {
return null;
} else {
for ($i = 0; $i < $els->length; $i++) {
$el = $els->item($i);
if ($el->localName == $tag && $el->namespaceURI == $namespace) {
return $el;
}
}
}
}
/**
* Grab the text content of a DOM element child of the current element
*
* @param DOMElement $element Element whose children we examine
* @param string $tag Tag to look up
* @param string $namespace Namespace to use, defaults to Atom
*
* @return string content of the child
*/
static function childContent(DOMNode $element, $tag, $namespace=self::ATOM)
{
$el = self::child($element, $tag, $namespace);
if (empty($el)) {
return null;
} else {
return $el->textContent;
}
}
static function childHtmlContent(DOMNode $element, $tag, $namespace=self::ATOM)
{
$el = self::child($element, $tag, $namespace);
if (empty($el)) {
return null;
} else {
return self::textConstruct($el);
}
}
/**
* Get the content of an atom:entry-like object
*
* @param DOMElement $element The element to examine.
*
* @return string unencoded HTML content of the element, like "This -&lt; is <b>HTML</b>."
*
* @todo handle remote content
* @todo handle embedded XML mime types
* @todo handle base64-encoded non-XML and non-text mime types
*/
static function getContent($element)
{
return self::childHtmlContent($element, self::CONTENT, self::ATOM);
}
static function textConstruct($el)
{
$src = $el->getAttribute(self::SRC);
if (!empty($src)) {
throw new ClientException(_("Can't handle remote content yet."));
}
$type = $el->getAttribute(self::TYPE);
// slavishly following http://atompub.org/rfc4287.html#rfc.section.4.1.3.3
if (empty($type) || $type == 'text') {
return $el->textContent;
} else if ($type == 'html') {
$text = $el->textContent;
return htmlspecialchars_decode($text, ENT_QUOTES);
} else if ($type == 'xhtml') {
$divEl = ActivityUtils::child($el, 'div', 'http://www.w3.org/1999/xhtml');
if (empty($divEl)) {
return null;
}
$doc = $divEl->ownerDocument;
$text = '';
$children = $divEl->childNodes;
for ($i = 0; $i < $children->length; $i++) {
$child = $children->item($i);
$text .= $doc->saveXML($child);
}
return trim($text);
} else if (in_array($type, array('text/xml', 'application/xml')) ||
preg_match('#(+|/)xml$#', $type)) {
throw new ClientException(_("Can't handle embedded XML content yet."));
} else if (strncasecmp($type, 'text/', 5)) {
return $el->textContent;
} else {
throw new ClientException(_("Can't handle embedded Base64 content yet."));
}
}
}

66
lib/activityverb.php Normal file
View File

@ -0,0 +1,66 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
/**
* Utility class to hold a bunch of constant defining default verb types
*
* @category OStatus
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
class ActivityVerb
{
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';
// Custom OStatus verbs for the flipside until they're standardized
const DELETE = 'http://ostatus.org/schema/1.0/unfollow';
const UNFAVORITE = 'http://ostatus.org/schema/1.0/unfavorite';
const UNFOLLOW = 'http://ostatus.org/schema/1.0/unfollow';
const LEAVE = 'http://ostatus.org/schema/1.0/leave';
// For simple profile-update pings; no content to share.
const UPDATE_PROFILE = 'http://ostatus.org/schema/1.0/update-profile';
}

102
lib/avatarlink.php Normal file
View File

@ -0,0 +1,102 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
// XXX: Arg! This wouldn't be necessary if we used Avatars conistently
class AvatarLink
{
public $url;
public $type;
public $size;
public $width;
public $height;
function __construct($element=null)
{
if ($element) {
// @fixme use correct namespaces
$this->url = $element->getAttribute('href');
$this->type = $element->getAttribute('type');
$width = $element->getAttribute('media:width');
if ($width != null) {
$this->width = intval($width);
}
$height = $element->getAttribute('media:height');
if ($height != null) {
$this->height = intval($height);
}
}
}
static function fromAvatar($avatar)
{
if (empty($avatar)) {
return null;
}
$alink = new AvatarLink();
$alink->type = $avatar->mediatype;
$alink->height = $avatar->height;
$alink->width = $avatar->width;
$alink->url = $avatar->displayUrl();
return $alink;
}
static function fromFilename($filename, $size)
{
$alink = new AvatarLink();
$alink->url = $filename;
$alink->height = $size;
if (!empty($filename)) {
$alink->width = $size;
$alink->type = self::mediatype($filename);
} else {
$alink->url = User_group::defaultLogo($size);
$alink->type = 'image/png';
}
return $alink;
}
// yuck!
static function mediatype($filename) {
$ext = strtolower(end(explode('.', $filename)));
if ($ext == 'jpeg') {
$ext = 'jpg';
}
// hope we don't support any others
$types = array('png', 'gif', 'jpg', 'jpeg');
if (in_array($ext, $types)) {
return 'image/' . $ext;
}
return null;
}
}

View File

@ -123,7 +123,6 @@ require_once INSTALLDIR.'/lib/util.php';
require_once INSTALLDIR.'/lib/action.php'; require_once INSTALLDIR.'/lib/action.php';
require_once INSTALLDIR.'/lib/mail.php'; require_once INSTALLDIR.'/lib/mail.php';
require_once INSTALLDIR.'/lib/subs.php'; require_once INSTALLDIR.'/lib/subs.php';
require_once INSTALLDIR.'/lib/activity.php';
require_once INSTALLDIR.'/lib/clientexception.php'; require_once INSTALLDIR.'/lib/clientexception.php';
require_once INSTALLDIR.'/lib/serverexception.php'; require_once INSTALLDIR.'/lib/serverexception.php';

240
lib/poco.php Normal file
View File

@ -0,0 +1,240 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
class PoCo
{
const NS = 'http://portablecontacts.net/spec/1.0';
const USERNAME = 'preferredUsername';
const DISPLAYNAME = 'displayName';
const NOTE = 'note';
public $preferredUsername;
public $displayName;
public $note;
public $address;
public $urls = array();
function __construct($element = null)
{
if (empty($element)) {
return;
}
$this->preferredUsername = ActivityUtils::childContent(
$element,
self::USERNAME,
self::NS
);
$this->displayName = ActivityUtils::childContent(
$element,
self::DISPLAYNAME,
self::NS
);
$this->note = ActivityUtils::childContent(
$element,
self::NOTE,
self::NS
);
$this->address = $this->_getAddress($element);
$this->urls = $this->_getURLs($element);
}
private function _getURLs($element)
{
$urlEls = $element->getElementsByTagnameNS(self::NS, PoCoURL::URLS);
$urls = array();
foreach ($urlEls as $urlEl) {
$type = ActivityUtils::childContent(
$urlEl,
PoCoURL::TYPE,
PoCo::NS
);
$value = ActivityUtils::childContent(
$urlEl,
PoCoURL::VALUE,
PoCo::NS
);
$primary = ActivityUtils::childContent(
$urlEl,
PoCoURL::PRIMARY,
PoCo::NS
);
$isPrimary = false;
if (isset($primary) && $primary == 'true') {
$isPrimary = true;
}
// @todo check to make sure a primary hasn't already been added
array_push($urls, new PoCoURL($type, $value, $isPrimary));
}
return $urls;
}
private function _getAddress($element)
{
$addressEl = ActivityUtils::child(
$element,
PoCoAddress::ADDRESS,
PoCo::NS
);
if (!empty($addressEl)) {
$formatted = ActivityUtils::childContent(
$addressEl,
PoCoAddress::FORMATTED,
self::NS
);
if (!empty($formatted)) {
$address = new PoCoAddress();
$address->formatted = $formatted;
return $address;
}
}
return null;
}
function fromProfile($profile)
{
if (empty($profile)) {
return null;
}
$poco = new PoCo();
$poco->preferredUsername = $profile->nickname;
$poco->displayName = $profile->getBestName();
$poco->note = $profile->bio;
$paddy = new PoCoAddress();
$paddy->formatted = $profile->location;
$poco->address = $paddy;
if (!empty($profile->homepage)) {
array_push(
$poco->urls,
new PoCoURL(
'homepage',
$profile->homepage,
true
)
);
}
return $poco;
}
function fromGroup($group)
{
if (empty($group)) {
return null;
}
$poco = new PoCo();
$poco->preferredUsername = $group->nickname;
$poco->displayName = $group->getBestName();
$poco->note = $group->description;
$paddy = new PoCoAddress();
$paddy->formatted = $group->location;
$poco->address = $paddy;
if (!empty($group->homepage)) {
array_push(
$poco->urls,
new PoCoURL(
'homepage',
$group->homepage,
true
)
);
}
return $poco;
}
function getPrimaryURL()
{
foreach ($this->urls as $url) {
if ($url->primary) {
return $url;
}
}
}
function asString()
{
$xs = new XMLStringer(true);
$xs->element(
'poco:preferredUsername',
null,
$this->preferredUsername
);
$xs->element(
'poco:displayName',
null,
$this->displayName
);
if (!empty($this->note)) {
$xs->element('poco:note', null, common_xml_safe_str($this->note));
}
if (!empty($this->address)) {
$xs->raw($this->address->asString());
}
foreach ($this->urls as $url) {
$xs->raw($url->asString());
}
return $xs->getString();
}
}

56
lib/pocoaddress.php Normal file
View File

@ -0,0 +1,56 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
class PoCoAddress
{
const ADDRESS = 'address';
const FORMATTED = 'formatted';
public $formatted;
// @todo Other address fields
function asString()
{
if (!empty($this->formatted)) {
$xs = new XMLStringer(true);
$xs->elementStart('poco:address');
$xs->element('poco:formatted', null, common_xml_safe_str($this->formatted));
$xs->elementEnd('poco:address');
return $xs->getString();
}
return null;
}
}

65
lib/pocourl.php Normal file
View File

@ -0,0 +1,65 @@
<?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 Feed
* @package StatusNet
* @author Evan Prodromou <evan@status.net>
* @author Zach Copley <zach@status.net>
* @copyright 2010 StatusNet, Inc.
* @license http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
* @link http://status.net/
*/
if (!defined('STATUSNET')) {
exit(1);
}
class PoCoURL
{
const URLS = 'urls';
const TYPE = 'type';
const VALUE = 'value';
const PRIMARY = 'primary';
public $type;
public $value;
public $primary;
function __construct($type, $value, $primary = false)
{
$this->type = $type;
$this->value = $value;
$this->primary = $primary;
}
function asString()
{
$xs = new XMLStringer(true);
$xs->elementStart('poco:urls');
$xs->element('poco:type', null, $this->type);
$xs->element('poco:value', null, $this->value);
if (!empty($this->primary)) {
$xs->element('poco:primary', null, 'true');
}
$xs->elementEnd('poco:urls');
return $xs->getString();
}
}

View File

@ -388,11 +388,17 @@ class Ostatus_profile extends Memcached_DataObject
{ {
$feed = $doc->documentElement; $feed = $doc->documentElement;
if ($feed->localName != 'feed' || $feed->namespaceURI != Activity::ATOM) { if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
common_log(LOG_ERR, __METHOD__ . ": not an Atom feed, ignoring"); $this->processAtomFeed($feed, $source);
return; } else if ($feed->localName == 'rss') { // @fixme check namespace
$this->processRssFeed($feed, $source);
} else {
throw new Exception("Unknown feed format.");
} }
}
public function processAtomFeed(DOMElement $feed, $source)
{
$entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
if ($entries->length == 0) { if ($entries->length == 0) {
common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring"); common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
@ -405,6 +411,26 @@ class Ostatus_profile extends Memcached_DataObject
} }
} }
public function processRssFeed(DOMElement $rss, $source)
{
$channels = $rss->getElementsByTagName('channel');
if ($channels->length == 0) {
throw new Exception("RSS feed without a channel.");
} else if ($channels->length > 1) {
common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
}
$channel = $channels->item(0);
$items = $channel->getElementsByTagName('item');
for ($i = 0; $i < $items->length; $i++) {
$item = $items->item($i);
$this->processEntry($item, $channel, $source);
}
}
/** /**
* Process a posted entry from this feed source. * Process a posted entry from this feed source.
* *
@ -442,24 +468,27 @@ class Ostatus_profile extends Memcached_DataObject
return false; return false;
} }
} else { } else {
// Individual user feeds may contain only posts from themselves. $actor = $activity->actor;
// Authorship is validated against the profile URI on upper layers,
// through PuSH setup or Salmon signature checks. if (empty($actor)) {
$actorUri = self::getActorProfileURI($activity); // OK here! assume the default
if ($actorUri == $this->uri) { } else if ($actor->id == $this->uri || $actor->link == $this->uri) {
// Check if profile info has changed and update it $this->updateFromActivityObject($actor);
$this->updateFromActivityObject($activity->actor);
} else { } else {
common_log(LOG_WARNING, "OStatus: skipping post with bad author: got $actorUri expected $this->uri"); throw new Exception("Got an actor '{$actor->title}' ({$actor->id}) on single-user feed for {$this->uri}");
return false;
} }
$oprofile = $this; $oprofile = $this;
} }
// It's not always an ActivityObject::NOTE, but... let's just say it is.
$note = $activity->object;
// The id URI will be used as a unique identifier for for the notice, // The id URI will be used as a unique identifier for for the notice,
// protecting against duplicate saves. It isn't required to be a URL; // protecting against duplicate saves. It isn't required to be a URL;
// tag: URIs for instance are found in Google Buzz feeds. // tag: URIs for instance are found in Google Buzz feeds.
$sourceUri = $activity->object->id; $sourceUri = $note->id;
$dupe = Notice::staticGet('uri', $sourceUri); $dupe = Notice::staticGet('uri', $sourceUri);
if ($dupe) { if ($dupe) {
common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri"); common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
@ -468,16 +497,30 @@ class Ostatus_profile extends Memcached_DataObject
// We'll also want to save a web link to the original notice, if provided. // We'll also want to save a web link to the original notice, if provided.
$sourceUrl = null; $sourceUrl = null;
if ($activity->object->link) { if ($note->link) {
$sourceUrl = $activity->object->link; $sourceUrl = $note->link;
} else if ($activity->link) { } else if ($activity->link) {
$sourceUrl = $activity->link; $sourceUrl = $activity->link;
} else if (preg_match('!^https?://!', $activity->object->id)) { } else if (preg_match('!^https?://!', $note->id)) {
$sourceUrl = $activity->object->id; $sourceUrl = $note->id;
}
// 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?
throw new ClientException("No content for notice {$sourceUri}");
} }
// Get (safe!) HTML and text versions of the content // Get (safe!) HTML and text versions of the content
$rendered = $this->purify($activity->object->content);
$rendered = $this->purify($sourceContent);
$content = html_entity_decode(strip_tags($rendered)); $content = html_entity_decode(strip_tags($rendered));
$shortened = common_shorten_links($content); $shortened = common_shorten_links($content);
@ -488,8 +531,8 @@ class Ostatus_profile extends Memcached_DataObject
$attachment = null; $attachment = null;
if (Notice::contentTooLong($shortened)) { if (Notice::contentTooLong($shortened)) {
$attachment = $this->saveHTMLFile($activity->object->title, $rendered); $attachment = $this->saveHTMLFile($note->title, $rendered);
$summary = $activity->object->summary; $summary = html_entity_decode(strip_tags($note->summary));
if (empty($summary)) { if (empty($summary)) {
$summary = $content; $summary = $content;
} }
@ -795,9 +838,20 @@ class Ostatus_profile extends Memcached_DataObject
throw new FeedSubNoHubException(); throw new FeedSubNoHubException();
} }
// Try to get a profile from the feed activity:subject $feedEl = $discover->root;
$feedEl = $discover->feed->documentElement; if ($feedEl->tagName == 'feed') {
return self::ensureAtomFeed($feedEl, $hints);
} else if ($feedEl->tagName == 'channel') {
return self::ensureRssChannel($feedEl, $hints);
} else {
throw new FeedSubBadXmlException($feeduri);
}
}
public static function ensureAtomFeed($feedEl, $hints)
{
// Try to get a profile from the feed activity:subject
$subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC); $subject = ActivityUtils::child($feedEl, Activity::SUBJECT, Activity::SPEC);
@ -818,7 +872,7 @@ class Ostatus_profile extends Memcached_DataObject
// Sheesh. Not a very nice feed! Let's try fingerpoken in the // Sheesh. Not a very nice feed! Let's try fingerpoken in the
// entries. // entries.
$entries = $discover->feed->getElementsByTagNameNS(Activity::ATOM, 'entry'); $entries = $feedEl->getElementsByTagNameNS(Activity::ATOM, 'entry');
if (!empty($entries) && $entries->length > 0) { if (!empty($entries) && $entries->length > 0) {
@ -845,6 +899,17 @@ class Ostatus_profile extends Memcached_DataObject
throw new FeedSubException("Can't find enough profile information to make a feed."); throw new FeedSubException("Can't find enough profile information to make a feed.");
} }
public static function ensureRssChannel($feedEl, $hints)
{
// @fixme we should check whether this feed has elements
// with different <author> or <dc:creator> elements, and... I dunno.
// Do something about that.
$obj = ActivityObject::fromRssChannel($feedEl);
return self::ensureActivityObjectProfile($obj, $hints);
}
/** /**
* Download and update given avatar image * Download and update given avatar image
* *
@ -1307,9 +1372,19 @@ class Ostatus_profile extends Memcached_DataObject
return $hints['nickname']; return $hints['nickname'];
} }
// Try the definitive ID // Try the profile url (like foo.example.com or example.com/user/foo)
$nickname = self::nicknameFromURI($object->id); $profileUrl = ($object->link) ? $object->link : $hints['profileurl'];
if (!empty($profileUrl)) {
$nickname = self::nicknameFromURI($profileUrl);
}
// Try the URI (may be a tag:, http:, acct:, ...
if (empty($nickname)) {
$nickname = self::nicknameFromURI($object->id);
}
// Try a Webfinger if one was passed (way) down // Try a Webfinger if one was passed (way) down