* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-if (!defined('STATUSNET')) {
- exit(1);
-}
+if (!defined('GNUSOCIAL')) { exit(1); }
/**
* @package OStatusPlugin
- * @maintainer Brion Vibber <brion@status.net>
+ * @author Brion Vibber <brion@status.net>
+ * @maintainer Mikael Nordfeldth <mmn@hethane.se>
*/
class Ostatus_profile extends Managed_DataObject
{
{
return array(
'fields' => array(
- 'uri' => array('type' => 'varchar', 'length' => 255, 'not null' => true),
+ 'uri' => array('type' => 'varchar', 'length' => 191, 'not null' => true),
'profile_id' => array('type' => 'integer'),
'group_id' => array('type' => 'integer'),
'peopletag_id' => array('type' => 'integer'),
- 'feeduri' => array('type' => 'varchar', 'length' => 255),
- 'salmonuri' => array('type' => 'varchar', 'length' => 255),
+ 'feeduri' => array('type' => 'varchar', 'length' => 191),
+ 'salmonuri' => array('type' => 'varchar', 'length' => 191),
'avatar' => array('type' => 'text'),
'created' => array('type' => 'datetime', 'not null' => true),
'modified' => array('type' => 'datetime', 'not null' => true),
return $this->uri;
}
+ public function fromProfile(Profile $profile)
+ {
+ $oprofile = Ostatus_profile::getKV('profile_id', $profile->id);
+ if (!$oprofile instanceof Ostatus_profile) {
+ throw new Exception('No Ostatus_profile for Profile ID: '.$profile->id);
+ }
+ }
+
/**
* Fetch the locally stored profile for this feed
* @return Profile
*/
public function localProfile()
{
+ if ($this->isGroup()) {
+ return $this->localGroup()->getProfile();
+ }
+
$profile = Profile::getKV('id', $this->profile_id);
- if ($profile instanceof Profile) {
- return $profile;
+ if (!$profile instanceof Profile) {
+ throw new NoProfileException($this->profile_id);
}
- throw new NoProfileException($this->profile_id);
+ return $profile;
}
/**
*/
public function localGroup()
{
- if ($this->group_id) {
- return User_group::getKV('id', $this->group_id);
+ $group = User_group::getKV('id', $this->group_id);
+
+ if (!$group instanceof User_group) {
+ throw new NoSuchGroupException(array('id'=>$this->group_id));
}
- return null;
+
+ return $group;
}
/**
throw new ClientException(_m('Cannot handle that kind of post.'));
}
break;
- case ActivityVerb::SHARE:
- $notice = $this->processShare($activity, $source);
- break;
default:
common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
}
return $notice;
}
- public function processShare(Activity $activity, $method)
- {
- $notice = null;
-
- try {
- $profile = ActivityUtils::checkAuthorship($activity, $this->localProfile());
- } catch (ServerException $e) {
- return null;
- }
-
- // The id URI will be used as a unique identifier for the notice,
- // protecting against duplicate saves. It isn't required to be a URL;
- // tag: URIs for instance are found in Google Buzz feeds.
- $dupe = Notice::getKV('uri', $activity->id);
- if ($dupe instanceof Notice) {
- common_log(LOG_INFO, "OStatus: ignoring duplicate post: {$activity->id}");
- return $dupe;
- }
-
- if (count($activity->objects) != 1) {
- // TRANS: Client exception thrown when trying to share multiple activities at once.
- throw new ClientException(_m('Can only handle share activities with exactly one object.'));
- }
-
- $shared = $activity->objects[0];
-
- if (!$shared instanceof Activity) {
- // TRANS: Client exception thrown when trying to share a non-activity object.
- throw new ClientException(_m('Can only handle shared activities.'));
- }
-
- $sharedId = $shared->id;
- if (!empty($shared->objects[0]->id)) {
- // Because StatusNet since commit 8cc4660 sets $shared->id to a TagURI which
- // fucks up federation, because the URI is no longer recognised by the origin.
- // So we set it to the object ID if it exists, otherwise we trust $shared->id
- $sharedId = $shared->objects[0]->id;
- }
- if (empty($sharedId)) {
- throw new ClientException(_m('Shared activity does not have an id'));
- }
-
- // First check if we have the shared activity. This has to be done first, because
- // we can't use these functions to "ensureActivityObjectProfile" of a local user,
- // who might be the creator of the shared activity in question.
- $sharedNotice = Notice::getKV('uri', $sharedId);
- if (!$sharedNotice instanceof Notice) {
- // If no locally stored notice is found, process it!
- // TODO: Remember to check Deleted_notice!
- // TODO: If a post is shared that we can't retrieve - what to do?
- try {
- $other = self::ensureActivityObjectProfile($shared->actor);
- $sharedNotice = $other->processActivity($shared, $method);
- if (!$sharedNotice instanceof Notice) {
- // And if we apparently can't get the shared notice, we'll abort the whole thing.
- // TRANS: Client exception thrown when saving an activity share fails.
- // TRANS: %s is a share ID.
- throw new ClientException(sprintf(_m('Failed to save activity %s.'), $sharedId));
- }
- } catch (FeedSubException $e) {
- // Remote feed could not be found or verified, should we
- // transform this into an "RT @user Blah, blah, blah..."?
- common_log(LOG_INFO, __METHOD__ . ' got a ' . get_class($e) . ': ' . $e->getMessage());
- return null;
- }
- }
-
- // We'll want to save a web link to the original notice, if provided.
-
- $sourceUrl = null;
- if ($activity->link) {
- $sourceUrl = $activity->link;
- } else if ($activity->link) {
- $sourceUrl = $activity->link;
- } else if (preg_match('!^https?://!', $activity->id)) {
- $sourceUrl = $activity->id;
- }
-
- // Use summary as fallback for content
-
- if (!empty($activity->content)) {
- $sourceContent = $activity->content;
- } else if (!empty($activity->summary)) {
- $sourceContent = $activity->summary;
- } else if (!empty($activity->title)) {
- $sourceContent = $activity->title;
- } else {
- // @todo FIXME: Fetch from $sourceUrl?
- // TRANS: Client exception. %s is a source URI.
- throw new ClientException(sprintf(_m('No content for notice %s.'), $activity->id));
- }
-
- // Get (safe!) HTML and text versions of the content
-
- $rendered = $this->purify($sourceContent);
- $content = common_strip_html($rendered);
-
- $shortened = common_shorten_links($content);
-
- // If it's too long, try using the summary, and make the
- // HTML an attachment.
-
- $attachment = null;
-
- if (Notice::contentTooLong($shortened)) {
- $attachment = $this->saveHTMLFile($activity->title, $rendered);
- $summary = common_strip_html($activity->summary);
- if (empty($summary)) {
- $summary = $content;
- }
- $shortSummary = common_shorten_links($summary);
- if (Notice::contentTooLong($shortSummary)) {
- $url = common_shorten_url($sourceUrl);
- $shortSummary = substr($shortSummary,
- 0,
- Notice::maxContent() - (mb_strlen($url) + 2));
- $content = $shortSummary . ' ' . $url;
-
- // We mark up the attachment link specially for the HTML output
- // so we can fold-out the full version inline.
-
- // @todo FIXME i18n: This tooltip will be saved with the site's default language
- // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
- // TRANS: this will usually be replaced with localised text from StatusNet core messages.
- $showMoreText = _m('Show more');
- $attachUrl = common_local_url('attachment',
- array('attachment' => $attachment->id));
- $rendered = common_render_text($shortSummary) .
- '<a href="' . htmlspecialchars($attachUrl) .'"'.
- ' class="attachment more"' .
- ' title="'. htmlspecialchars($showMoreText) . '">' .
- '…' .
- '</a>';
- }
- }
-
- $options = array('is_local' => Notice::REMOTE,
- 'url' => $sourceUrl,
- 'uri' => $activity->id,
- 'rendered' => $rendered,
- 'replies' => array(),
- 'groups' => array(),
- 'peopletags' => array(),
- 'tags' => array(),
- 'urls' => array(),
- 'repeat_of' => $sharedNotice->id,
- 'scope' => $sharedNotice->scope);
-
- // Check for optional attributes...
-
- if (!empty($activity->time)) {
- $options['created'] = common_sql_date($activity->time);
- }
-
- if ($activity->context) {
- // TODO: context->attention
- list($options['groups'], $options['replies'])
- = self::filterAttention($profile, $activity->context->attention);
-
- // Maintain direct reply associations
- // @todo FIXME: What about conversation ID?
- if (!empty($activity->context->replyToID)) {
- $orig = Notice::getKV('uri',
- $activity->context->replyToID);
- if ($orig instanceof Notice) {
- $options['reply_to'] = $orig->id;
- }
- }
-
- $location = $activity->context->location;
- if ($location) {
- $options['lat'] = $location->lat;
- $options['lon'] = $location->lon;
- if ($location->location_id) {
- $options['location_ns'] = $location->location_ns;
- $options['location_id'] = $location->location_id;
- }
- }
- }
-
- if ($this->isPeopletag()) {
- $options['peopletags'][] = $this->localPeopletag();
- }
-
- // Atom categories <-> hashtags
- foreach ($activity->categories as $cat) {
- if ($cat->term) {
- $term = common_canonical_tag($cat->term);
- if ($term) {
- $options['tags'][] = $term;
- }
- }
- }
-
- // Atom enclosures -> attachment URLs
- foreach ($activity->enclosures as $href) {
- // @todo FIXME: Save these locally or....?
- $options['urls'][] = $href;
- }
-
- $notice = Notice::saveNew($profile->id,
- $content,
- 'ostatus',
- $options);
-
- return $notice;
- }
-
/**
* Process an incoming post activity from this remote feed.
* @param Activity $activity
{
$notice = null;
- $profile = $this->checkAuthorship($activity, $this->localProfile());
+ $profile = ActivityUtils::checkAuthorship($activity, $this->localProfile());
// It's not always an ActivityObject::NOTE, but... let's just say it is.
// Get (safe!) HTML and text versions of the content
- $rendered = $this->purify($sourceContent);
+ $rendered = common_purify($sourceContent);
$content = common_strip_html($rendered);
$shortened = common_shorten_links($content);
$options['reply_to'] = $orig->id;
}
}
+ if (!empty($activity->context->conversation)) {
+ // we store the URI here, Notice class can look it up later
+ $options['conversation'] = $activity->context->conversation;
+ }
$location = $activity->context->location;
if ($location) {
$options);
if ($saved instanceof Notice) {
Ostatus_source::saveNew($saved, $this, $method);
- if (!empty($attachment)) {
- File_to_post::processNew($attachment->id, $saved->id);
+ if ($attachment instanceof File) {
+ File_to_post::processNew($attachment, $saved);
}
}
} catch (Exception $e) {
return $saved;
}
- /**
- * Clean up HTML
- */
- protected function purify($html)
- {
- require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
- $config = array('safe' => 1,
- 'deny_attribute' => 'id,style,on*');
- return htmLawed($html, $config);
- }
-
/**
* Filters a list of recipient ID URIs to just those for local delivery.
* @param Profile local profile of sender
*/
static public function filterAttention(Profile $sender, array $attention)
{
- common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', array_keys($attention)));
+ common_debug("Original reply recipients: " . implode(', ', array_keys($attention)));
$groups = array();
$replies = array();
foreach ($attention as $recipient=>$type) {
if ($sender->isMember($group)) {
$groups[] = $group->id;
} else {
- common_log(LOG_DEBUG, sprintf('Skipping reply to local group %s as sender %d is not a member', $group->getNickname(), $sender->id));
+ common_debug(sprintf('Skipping reply to local group %s as sender %d is not a member', $group->getNickname(), $sender->id));
}
continue;
} else {
- common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
+ common_debug("Skipping reply to bogus group $recipient");
}
}
continue;
} catch (Exception $e) {
// Neither a recognizable local nor remote user!
- common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
+ common_debug("Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
}
}
- common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
- common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
+ common_debug("Local reply recipients: " . implode(', ', $replies));
+ common_debug("Local group recipients: " . implode(', ', $groups));
return array($groups, $replies);
}
* @return Ostatus_profile
* @throws Exception
*/
- public static function ensureFeedURL($feed_url, $hints=array())
+ public static function ensureFeedURL($feed_url, array $hints=array())
{
+ $oprofile = Ostatus_profile::getKV('feeduri', $feed_url);
+ if ($oprofile instanceof Ostatus_profile) {
+ return $oprofile;
+ }
+
$discover = new FeedDiscovery();
$feeduri = $discover->discoverFromFeedURL($feed_url);
?: $discover->getAtomLink(Salmon::NS_REPLIES);
$hints['salmon'] = $salmonuri;
- if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
+ if (!$huburi && !common_config('feedsub', 'fallback_hub') && !common_config('feedsub', 'nohub')) {
// We can only deal with folks with a PuSH hub
+ // unless we have something similar available locally.
throw new FeedSubNoHubException();
}
* @return Ostatus_profile
* @throws Exception
*/
- public static function ensureRssChannel($feedEl, $hints)
+ public static function ensureRssChannel(DOMElement $feedEl, array $hints)
{
// Special-case for Posterous. They have some nice metadata in their
// posterous:author elements. We should use them instead of the channel.
}
}
+ $obj = ActivityUtils::getFeedAuthor($feedEl);
+
// @todo 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);
+ if(empty($obj)) { $obj = ActivityObject::fromRssChannel($feedEl); }
return self::ensureActivityObjectProfile($obj, $hints);
}
* Download and update given avatar image
*
* @param string $url
+ * @return Avatar The Avatar we have on disk. (seldom used)
* @throws Exception in various failure cases
*/
- protected function updateAvatar($url)
+ public function updateAvatar($url, $force=false)
{
- if ($url == $this->avatar) {
- // We've already got this one.
- return;
+ try {
+ // If avatar URL differs: update. If URLs were identical but we're forced: update.
+ if ($url == $this->avatar && !$force) {
+ // If there's no locally stored avatar, throw an exception and continue fetching below.
+ $avatar = Avatar::getUploaded($this->localProfile()) instanceof Avatar;
+ return $avatar;
+ }
+ } catch (NoAvatarException $e) {
+ // No avatar available, let's fetch it.
}
+
if (!common_valid_http_url($url)) {
// TRANS: Server exception. %s is a URL.
throw new ServerException(sprintf(_m('Invalid avatar URL %s.'), $url));
}
- if ($this->isGroup()) {
- // FIXME: throw exception for localGroup
- $self = $this->localGroup();
- } else {
- // this throws an exception already
- $self = $this->localProfile();
- }
- if (!$self) {
- throw new ServerException(sprintf(
- // TRANS: Server exception. %s is a URI.
- _m('Tried to update avatar for unsaved remote profile %s.'),
- $this->getUri()));
- }
+ $self = $this->localProfile();
// @todo FIXME: This should be better encapsulated
// ripped from oauthstore.php (for old OMB client)
$temp_filename = tempnam(common_get_temp_dir(), 'listener_avatar');
try {
- if (!copy($url, $temp_filename)) {
- // TRANS: Server exception. %s is a URL.
- throw new ServerException(sprintf(_m('Unable to fetch avatar from %s to %s.'), $url, $temp_filename));
+ $imgData = HTTPClient::quickGet($url);
+ // Make sure it's at least an image file. ImageFile can do the rest.
+ if (false === getimagesizefromstring($imgData)) {
+ throw new UnsupportedMediaException(_('Downloaded group avatar was not an image.'));
}
+ file_put_contents($temp_filename, $imgData);
+ unset($imgData); // No need to carry this in memory.
if ($this->isGroup()) {
$id = $this->group_id;
} else {
$id = $this->profile_id;
}
- // @todo FIXME: Should we be using different ids?
- $imagefile = new ImageFile($id, $temp_filename);
+ $imagefile = new ImageFile(null, $temp_filename);
$filename = Avatar::filename($id,
image_type_to_extension($imagefile->type),
null,
$orig = clone($this);
$this->avatar = $url;
$this->update($orig);
+
+ return Avatar::getUploaded($self);
}
/**
$discover = false;
if (!$homeuri) {
- common_log(LOG_DEBUG, __METHOD__ . " empty actor profile URI: " . var_export($activity, true));
+ common_debug(__METHOD__ . " empty actor profile URI: " . var_export($activity, true));
// TRANS: Exception.
throw new Exception(_m('No profile URI.'));
}
$huburi = $discover->getHubLink();
}
- if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
+ if (!$huburi && !common_config('feedsub', 'fallback_hub') && !common_config('feedsub', 'nohub')) {
// We can only deal with folks with a PuSH hub
throw new FeedSubNoHubException();
}
// @todo tags from categories
if ($profile->id) {
- common_log(LOG_DEBUG, "Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
+ common_debug("Updating OStatus profile $profile->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
$profile->update($orig);
}
}
$group->homepage = self::getActivityObjectHomepage($object, $hints);
if ($group->id) { // If no id, we haven't called insert() yet, so don't run update()
- common_log(LOG_DEBUG, "Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
+ common_debug("Updating OStatus group $group->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
$group->update($orig);
}
}
$tag->tagger = $tagger->profile_id;
if ($tag->id) {
- common_log(LOG_DEBUG, "Updating OStatus peopletag $tag->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
+ common_debug("Updating OStatus peopletag $tag->id from remote info $object->id: " . var_export($object, true) . var_export($hints, true));
$tag->update($orig);
}
}
- protected static function getActivityObjectHomepage($object, $hints=array())
+ protected static function getActivityObjectHomepage(ActivityObject $object, array $hints=array())
{
$homepage = null;
$poco = $object->poco;
return $homepage;
}
- protected static function getActivityObjectLocation($object, $hints=array())
+ protected static function getActivityObjectLocation(ActivityObject $object, array $hints=array())
{
$location = null;
}
if (!empty($location)) {
- if (mb_strlen($location) > 255) {
- $location = mb_substr($note, 0, 255 - 3) . ' … ';
+ if (mb_strlen($location) > 191) { // not 255 because utf8mb4 takes more space
+ $location = mb_substr($note, 0, 191 - 3) . ' … ';
}
}
return $location;
}
- protected static function getActivityObjectBio($object, $hints=array())
+ protected static function getActivityObjectBio(ActivityObject $object, array $hints=array())
{
$bio = null;
return $bio;
}
- public static function getActivityObjectNickname($object, $hints=array())
+ public static function getActivityObjectNickname(ActivityObject $object, array $hints=array())
{
if ($object->poco) {
if (!empty($object->poco->preferredUsername)) {
}
// Try looking it up
- $oprofile = Ostatus_profile::getKV('uri', 'acct:'.$addr);
+ $oprofile = Ostatus_profile::getKV('uri', Discovery::normalize($addr));
if ($oprofile instanceof Ostatus_profile) {
self::cacheSet(sprintf('ostatus_profile:webfinger:%s', $addr), $oprofile->getUri());
throw new Exception(_m('Not a valid webfinger address.'));
}
- $hints = array('webfinger' => $addr);
-
- $dhints = DiscoveryHints::fromXRD($xrd);
-
- $hints = array_merge($hints, $dhints);
+ $hints = array_merge(array('webfinger' => $addr),
+ DiscoveryHints::fromXRD($xrd));
// If there's an Hcard, let's grab its info
if (array_key_exists('hcard', $hints)) {
'text/html');
$filepath = File::path($filename);
+ $fileurl = File::url($filename);
file_put_contents($filepath, $final);
$file = new File;
$file->filename = $filename;
- $file->url = File::url($filename);
+ $file->urlhash = File::hashurl($fileurl);
+ $file->url = $fileurl;
$file->size = filesize($filepath);
$file->date = time();
$file->mimetype = 'text/html';
throw new ServerException(sprintf(_m('Unrecognized URI protocol for profile: %1$s (%2$s).'),
$protocol,
$uri));
- break;
}
} else {
// TRANS: Server exception. %s is a URI.
common_log(LOG_WARNING,
"OStatus: skipping post with group listed ".
"as author: " . $oprofile->getUri() . " in feed from " . $this->getUri());
- return false;
+ throw new ServerException('Activity author is a non-actor');
}
} else {
$actor = $activity->actor;
return $oprofile->localProfile();
}
+
+ public function updateUriKeys($profile_uri, array $hints=array())
+ {
+ $orig = clone($this);
+
+ common_debug('URIFIX These identities both say they are each other: "'.$orig->uri.'" and "'.$profile_uri.'"');
+ $this->uri = $profile_uri;
+
+ if (array_key_exists('feedurl', $hints)) {
+ if (!empty($this->feeduri)) {
+ common_debug('URIFIX Changing FeedSub ['.$feedsub->id.'] feeduri "'.$feedsub->uri.'" to "'.$hints['feedurl']);
+ $feedsub = FeedSub::getKV('uri', $this->feeduri);
+ $feedorig = clone($feedsub);
+ $feedsub->uri = $hints['feedurl'];
+ $feedsub->updateWithKeys($feedorig);
+ } else {
+ common_debug('URIFIX Old Ostatus_profile did not have feedurl set, ensuring feed: '.$hints['feedurl']);
+ FeedSub::ensureFeed($hints['feedurl']);
+ }
+ $this->feeduri = $hints['feedurl'];
+ }
+ if (array_key_exists('salmon', $hints)) {
+ common_debug('URIFIX Changing Ostatus_profile salmonuri from "'.$this->salmonuri.'" to "'.$hints['salmon'].'"');
+ $this->salmonuri = $hints['salmon'];
+ }
+
+ common_debug('URIFIX Updating Ostatus_profile URI for '.$orig->uri.' to '.$this->uri);
+ $this->updateWithKeys($orig, 'uri'); // 'uri' is the primary key column
+
+ common_debug('URIFIX Subscribing/renewing feedsub for Ostatus_profile '.$this->uri);
+ $this->subscribe();
+ }
}
/**