PageRenderTime 77ms CodeModel.GetById 38ms RepoModel.GetById 1ms app.codeStats 0ms

/plugins/OStatus/classes/Ostatus_profile.php

https://gitlab.com/windigo-gs/windigos-gnu-social
PHP | 2202 lines | 1372 code | 309 blank | 521 comment | 339 complexity | 226538c898a3df12ba228e65132e398a MD5 | raw file
Possible License(s): AGPL-3.0, BSD-3-Clause, GPL-2.0

Large files files are truncated, but you can click here to view the full file

  1. <?php
  2. /*
  3. * StatusNet - the distributed open-source microblogging tool
  4. * Copyright (C) 2009-2010, StatusNet, Inc.
  5. *
  6. * This program is free software: you can redistribute it and/or modify
  7. * it under the terms of the GNU Affero General Public License as published by
  8. * the Free Software Foundation, either version 3 of the License, or
  9. * (at your option) any later version.
  10. *
  11. * This program is distributed in the hope that it will be useful,
  12. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. * GNU Affero General Public License for more details.
  15. *
  16. * You should have received a copy of the GNU Affero General Public License
  17. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. */
  19. if (!defined('STATUSNET')) {
  20. exit(1);
  21. }
  22. /**
  23. * @package OStatusPlugin
  24. * @maintainer Brion Vibber <brion@status.net>
  25. */
  26. class Ostatus_profile extends Managed_DataObject
  27. {
  28. public $__table = 'ostatus_profile';
  29. public $uri;
  30. public $profile_id;
  31. public $group_id;
  32. public $peopletag_id;
  33. public $feeduri;
  34. public $salmonuri;
  35. public $avatar; // remote URL of the last avatar we saved
  36. public $created;
  37. public $modified;
  38. /**
  39. * Return table definition for Schema setup and DB_DataObject usage.
  40. *
  41. * @return array array of column definitions
  42. */
  43. static function schemaDef()
  44. {
  45. return array(
  46. 'fields' => array(
  47. 'uri' => array('type' => 'varchar', 'length' => 255, 'not null' => true),
  48. 'profile_id' => array('type' => 'integer'),
  49. 'group_id' => array('type' => 'integer'),
  50. 'peopletag_id' => array('type' => 'integer'),
  51. 'feeduri' => array('type' => 'varchar', 'length' => 255),
  52. 'salmonuri' => array('type' => 'varchar', 'length' => 255),
  53. 'avatar' => array('type' => 'text'),
  54. 'created' => array('type' => 'datetime', 'not null' => true),
  55. 'modified' => array('type' => 'datetime', 'not null' => true),
  56. ),
  57. 'primary key' => array('uri'),
  58. 'unique keys' => array(
  59. 'ostatus_profile_profile_id_key' => array('profile_id'),
  60. 'ostatus_profile_group_id_key' => array('group_id'),
  61. 'ostatus_profile_peopletag_id_key' => array('peopletag_id'),
  62. 'ostatus_profile_feeduri_key' => array('feeduri'),
  63. ),
  64. 'foreign keys' => array(
  65. 'ostatus_profile_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
  66. 'ostatus_profile_group_id_fkey' => array('user_group', array('group_id' => 'id')),
  67. 'ostatus_profile_peopletag_id_fkey' => array('profile_list', array('peopletag_id' => 'id')),
  68. ),
  69. );
  70. }
  71. public function getUri()
  72. {
  73. return $this->uri;
  74. }
  75. /**
  76. * Fetch the locally stored profile for this feed
  77. * @return Profile
  78. * @throws NoProfileException if it was not found
  79. */
  80. public function localProfile()
  81. {
  82. $profile = Profile::getKV('id', $this->profile_id);
  83. if ($profile instanceof Profile) {
  84. return $profile;
  85. }
  86. throw new NoProfileException($this->profile_id);
  87. }
  88. /**
  89. * Fetch the StatusNet-side profile for this feed
  90. * @return Profile
  91. */
  92. public function localGroup()
  93. {
  94. if ($this->group_id) {
  95. return User_group::getKV('id', $this->group_id);
  96. }
  97. return null;
  98. }
  99. /**
  100. * Fetch the StatusNet-side peopletag for this feed
  101. * @return Profile
  102. */
  103. public function localPeopletag()
  104. {
  105. if ($this->peopletag_id) {
  106. return Profile_list::getKV('id', $this->peopletag_id);
  107. }
  108. return null;
  109. }
  110. /**
  111. * Returns an ActivityObject describing this remote user or group profile.
  112. * Can then be used to generate Atom chunks.
  113. *
  114. * @return ActivityObject
  115. */
  116. function asActivityObject()
  117. {
  118. if ($this->isGroup()) {
  119. return ActivityObject::fromGroup($this->localGroup());
  120. } else if ($this->isPeopletag()) {
  121. return ActivityObject::fromPeopletag($this->localPeopletag());
  122. } else {
  123. return ActivityObject::fromProfile($this->localProfile());
  124. }
  125. }
  126. /**
  127. * Returns an XML string fragment with profile information as an
  128. * Activity Streams noun object with the given element type.
  129. *
  130. * Assumes that 'activity' namespace has been previously defined.
  131. *
  132. * @todo FIXME: Replace with wrappers on asActivityObject when it's got everything.
  133. *
  134. * @param string $element one of 'actor', 'subject', 'object', 'target'
  135. * @return string
  136. */
  137. function asActivityNoun($element)
  138. {
  139. if ($this->isGroup()) {
  140. $noun = ActivityObject::fromGroup($this->localGroup());
  141. return $noun->asString('activity:' . $element);
  142. } else if ($this->isPeopletag()) {
  143. $noun = ActivityObject::fromPeopletag($this->localPeopletag());
  144. return $noun->asString('activity:' . $element);
  145. } else {
  146. $noun = ActivityObject::fromProfile($this->localProfile());
  147. return $noun->asString('activity:' . $element);
  148. }
  149. }
  150. /**
  151. * @return boolean true if this is a remote group
  152. */
  153. function isGroup()
  154. {
  155. if ($this->profile_id || $this->peopletag_id && !$this->group_id) {
  156. return false;
  157. } else if ($this->group_id && !$this->profile_id && !$this->peopletag_id) {
  158. return true;
  159. } else if ($this->group_id && ($this->profile_id || $this->peopletag_id)) {
  160. // TRANS: Server exception. %s is a URI
  161. throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->getUri()));
  162. } else {
  163. // TRANS: Server exception. %s is a URI
  164. throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->getUri()));
  165. }
  166. }
  167. /**
  168. * @return boolean true if this is a remote peopletag
  169. */
  170. function isPeopletag()
  171. {
  172. if ($this->profile_id || $this->group_id && !$this->peopletag_id) {
  173. return false;
  174. } else if ($this->peopletag_id && !$this->profile_id && !$this->group_id) {
  175. return true;
  176. } else if ($this->peopletag_id && ($this->profile_id || $this->group_id)) {
  177. // TRANS: Server exception. %s is a URI
  178. throw new ServerException(sprintf(_m('Invalid ostatus_profile state: Two or more IDs set for %s.'), $this->getUri()));
  179. } else {
  180. // TRANS: Server exception. %s is a URI
  181. throw new ServerException(sprintf(_m('Invalid ostatus_profile state: All IDs empty for %s.'), $this->getUri()));
  182. }
  183. }
  184. /**
  185. * Send a subscription request to the hub for this feed.
  186. * The hub will later send us a confirmation POST to /main/push/callback.
  187. *
  188. * @return void
  189. * @throws ServerException if feed state is not valid or subscription fails.
  190. */
  191. public function subscribe()
  192. {
  193. $feedsub = FeedSub::ensureFeed($this->feeduri);
  194. if ($feedsub->sub_state == 'active') {
  195. // Active subscription, we don't need to do anything.
  196. return;
  197. }
  198. // Inactive or we got left in an inconsistent state.
  199. // Run a subscription request to make sure we're current!
  200. return $feedsub->subscribe();
  201. }
  202. /**
  203. * Check if this remote profile has any active local subscriptions, and
  204. * if not drop the PuSH subscription feed.
  205. *
  206. * @return boolean true if subscription is removed, false if there are still subscribers to the feed
  207. * @throws Exception of various kinds on failure.
  208. */
  209. public function unsubscribe() {
  210. return $this->garbageCollect();
  211. }
  212. /**
  213. * Check if this remote profile has any active local subscriptions, and
  214. * if not drop the PuSH subscription feed.
  215. *
  216. * @return boolean true if subscription is removed, false if there are still subscribers to the feed
  217. * @throws Exception of various kinds on failure.
  218. */
  219. public function garbageCollect()
  220. {
  221. $feedsub = FeedSub::getKV('uri', $this->feeduri);
  222. return $feedsub->garbageCollect();
  223. }
  224. /**
  225. * Check if this remote profile has any active local subscriptions, so the
  226. * PuSH subscription layer can decide if it can drop the feed.
  227. *
  228. * This gets called via the FeedSubSubscriberCount event when running
  229. * FeedSub::garbageCollect().
  230. *
  231. * @return int
  232. * @throws NoProfileException if there is no local profile for the object
  233. */
  234. public function subscriberCount()
  235. {
  236. if ($this->isGroup()) {
  237. $members = $this->localGroup()->getMembers(0, 1);
  238. $count = $members->N;
  239. } else if ($this->isPeopletag()) {
  240. $subscribers = $this->localPeopletag()->getSubscribers(0, 1);
  241. $count = $subscribers->N;
  242. } else {
  243. $profile = $this->localProfile();
  244. if ($profile->hasLocalTags()) {
  245. $count = 1;
  246. } else {
  247. $count = $profile->subscriberCount();
  248. }
  249. }
  250. common_log(LOG_INFO, __METHOD__ . " SUB COUNT BEFORE: $count");
  251. // Other plugins may be piggybacking on OStatus without having
  252. // an active group or user-to-user subscription we know about.
  253. Event::handle('Ostatus_profileSubscriberCount', array($this, &$count));
  254. common_log(LOG_INFO, __METHOD__ . " SUB COUNT AFTER: $count");
  255. return $count;
  256. }
  257. /**
  258. * Send an Activity Streams notification to the remote Salmon endpoint,
  259. * if so configured.
  260. *
  261. * @param Profile $actor Actor who did the activity
  262. * @param string $verb Activity::SUBSCRIBE or Activity::JOIN
  263. * @param Object $object object of the action; must define asActivityNoun($tag)
  264. */
  265. public function notify($actor, $verb, $object=null, $target=null)
  266. {
  267. if (!($actor instanceof Profile)) {
  268. $type = gettype($actor);
  269. if ($type == 'object') {
  270. $type = get_class($actor);
  271. }
  272. // TRANS: Server exception.
  273. // TRANS: %1$s is the method name the exception occured in, %2$s is the actor type.
  274. throw new ServerException(sprintf(_m('Invalid actor passed to %1$s: %2$s.'),__METHOD__,$type));
  275. }
  276. if ($object == null) {
  277. $object = $this;
  278. }
  279. if ($this->salmonuri) {
  280. $text = 'update';
  281. $id = TagURI::mint('%s:%s:%s',
  282. $verb,
  283. $actor->getURI(),
  284. common_date_iso8601(time()));
  285. // @todo FIXME: Consolidate all these NS settings somewhere.
  286. $attributes = array('xmlns' => Activity::ATOM,
  287. 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
  288. 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
  289. 'xmlns:georss' => 'http://www.georss.org/georss',
  290. 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
  291. 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
  292. 'xmlns:media' => 'http://purl.org/syndication/atommedia');
  293. $entry = new XMLStringer();
  294. $entry->elementStart('entry', $attributes);
  295. $entry->element('id', null, $id);
  296. $entry->element('title', null, $text);
  297. $entry->element('summary', null, $text);
  298. $entry->element('published', null, common_date_w3dtf(common_sql_now()));
  299. $entry->element('activity:verb', null, $verb);
  300. $entry->raw($actor->asAtomAuthor());
  301. $entry->raw($actor->asActivityActor());
  302. $entry->raw($object->asActivityNoun('object'));
  303. if ($target != null) {
  304. $entry->raw($target->asActivityNoun('target'));
  305. }
  306. $entry->elementEnd('entry');
  307. $xml = $entry->getString();
  308. common_log(LOG_INFO, "Posting to Salmon endpoint $this->salmonuri: $xml");
  309. $salmon = new Salmon(); // ?
  310. return $salmon->post($this->salmonuri, $xml, $actor);
  311. }
  312. return false;
  313. }
  314. /**
  315. * Send a Salmon notification ping immediately, and confirm that we got
  316. * an acceptable response from the remote site.
  317. *
  318. * @param mixed $entry XML string, Notice, or Activity
  319. * @param Profile $actor
  320. * @return boolean success
  321. */
  322. public function notifyActivity($entry, Profile $actor)
  323. {
  324. if ($this->salmonuri) {
  325. $salmon = new Salmon();
  326. return $salmon->post($this->salmonuri, $this->notifyPrepXml($entry), $actor);
  327. }
  328. common_debug(__CLASS__.' error: No salmonuri for Ostatus_profile uri: '.$this->uri);
  329. return false;
  330. }
  331. /**
  332. * Queue a Salmon notification for later. If queues are disabled we'll
  333. * send immediately but won't get the return value.
  334. *
  335. * @param mixed $entry XML string, Notice, or Activity
  336. * @return boolean success
  337. */
  338. public function notifyDeferred($entry, $actor)
  339. {
  340. if ($this->salmonuri) {
  341. $data = array('salmonuri' => $this->salmonuri,
  342. 'entry' => $this->notifyPrepXml($entry),
  343. 'actor' => $actor->id);
  344. $qm = QueueManager::get();
  345. return $qm->enqueue($data, 'salmon');
  346. }
  347. return false;
  348. }
  349. protected function notifyPrepXml($entry)
  350. {
  351. $preamble = '<?xml version="1.0" encoding="UTF-8" ?' . '>';
  352. if (is_string($entry)) {
  353. return $entry;
  354. } else if ($entry instanceof Activity) {
  355. return $preamble . $entry->asString(true);
  356. } else if ($entry instanceof Notice) {
  357. return $preamble . $entry->asAtomEntry(true, true);
  358. } else {
  359. // TRANS: Server exception.
  360. throw new ServerException(_m('Invalid type passed to Ostatus_profile::notify. It must be XML string or Activity entry.'));
  361. }
  362. }
  363. function getBestName()
  364. {
  365. if ($this->isGroup()) {
  366. return $this->localGroup()->getBestName();
  367. } else if ($this->isPeopletag()) {
  368. return $this->localPeopletag()->getBestName();
  369. } else {
  370. return $this->localProfile()->getBestName();
  371. }
  372. }
  373. /**
  374. * Read and post notices for updates from the feed.
  375. * Currently assumes that all items in the feed are new,
  376. * coming from a PuSH hub.
  377. *
  378. * @param DOMDocument $doc
  379. * @param string $source identifier ("push")
  380. */
  381. public function processFeed(DOMDocument $doc, $source)
  382. {
  383. $feed = $doc->documentElement;
  384. if ($feed->localName == 'feed' && $feed->namespaceURI == Activity::ATOM) {
  385. $this->processAtomFeed($feed, $source);
  386. } else if ($feed->localName == 'rss') { // @todo FIXME: Check namespace.
  387. $this->processRssFeed($feed, $source);
  388. } else {
  389. // TRANS: Exception.
  390. throw new Exception(_m('Unknown feed format.'));
  391. }
  392. }
  393. public function processAtomFeed(DOMElement $feed, $source)
  394. {
  395. $entries = $feed->getElementsByTagNameNS(Activity::ATOM, 'entry');
  396. if ($entries->length == 0) {
  397. common_log(LOG_ERR, __METHOD__ . ": no entries in feed update, ignoring");
  398. return;
  399. }
  400. for ($i = 0; $i < $entries->length; $i++) {
  401. $entry = $entries->item($i);
  402. $this->processEntry($entry, $feed, $source);
  403. }
  404. }
  405. public function processRssFeed(DOMElement $rss, $source)
  406. {
  407. $channels = $rss->getElementsByTagName('channel');
  408. if ($channels->length == 0) {
  409. // TRANS: Exception.
  410. throw new Exception(_m('RSS feed without a channel.'));
  411. } else if ($channels->length > 1) {
  412. common_log(LOG_WARNING, __METHOD__ . ": more than one channel in an RSS feed");
  413. }
  414. $channel = $channels->item(0);
  415. $items = $channel->getElementsByTagName('item');
  416. for ($i = 0; $i < $items->length; $i++) {
  417. $item = $items->item($i);
  418. $this->processEntry($item, $channel, $source);
  419. }
  420. }
  421. /**
  422. * Process a posted entry from this feed source.
  423. *
  424. * @param DOMElement $entry
  425. * @param DOMElement $feed for context
  426. * @param string $source identifier ("push" or "salmon")
  427. *
  428. * @return Notice Notice representing the new (or existing) activity
  429. */
  430. public function processEntry($entry, $feed, $source)
  431. {
  432. $activity = new Activity($entry, $feed);
  433. return $this->processActivity($activity, $source);
  434. }
  435. // TODO: Make this throw an exception
  436. public function processActivity($activity, $source)
  437. {
  438. $notice = null;
  439. // The "WithProfile" events were added later.
  440. if (Event::handle('StartHandleFeedEntryWithProfile', array($activity, $this, &$notice)) &&
  441. Event::handle('StartHandleFeedEntry', array($activity))) {
  442. switch ($activity->verb) {
  443. case ActivityVerb::POST:
  444. // @todo process all activity objects
  445. switch ($activity->objects[0]->type) {
  446. case ActivityObject::ARTICLE:
  447. case ActivityObject::BLOGENTRY:
  448. case ActivityObject::NOTE:
  449. case ActivityObject::STATUS:
  450. case ActivityObject::COMMENT:
  451. case null:
  452. $notice = $this->processPost($activity, $source);
  453. break;
  454. default:
  455. // TRANS: Client exception.
  456. throw new ClientException(_m('Cannot handle that kind of post.'));
  457. }
  458. break;
  459. case ActivityVerb::SHARE:
  460. $notice = $this->processShare($activity, $source);
  461. break;
  462. default:
  463. common_log(LOG_INFO, "Ignoring activity with unrecognized verb $activity->verb");
  464. }
  465. Event::handle('EndHandleFeedEntry', array($activity));
  466. Event::handle('EndHandleFeedEntryWithProfile', array($activity, $this, $notice));
  467. }
  468. return $notice;
  469. }
  470. public function processShare($activity, $method)
  471. {
  472. $notice = null;
  473. $oprofile = $this->checkAuthorship($activity);
  474. if (!$oprofile instanceof Ostatus_profile) {
  475. common_log(LOG_INFO, "No author matched share activity");
  476. return null;
  477. }
  478. // The id URI will be used as a unique identifier for the notice,
  479. // protecting against duplicate saves. It isn't required to be a URL;
  480. // tag: URIs for instance are found in Google Buzz feeds.
  481. $dupe = Notice::getKV('uri', $activity->id);
  482. if ($dupe instanceof Notice) {
  483. common_log(LOG_INFO, "OStatus: ignoring duplicate post: {$activity->id}");
  484. return $dupe;
  485. }
  486. if (count($activity->objects) != 1) {
  487. // TRANS: Client exception thrown when trying to share multiple activities at once.
  488. throw new ClientException(_m('Can only handle share activities with exactly one object.'));
  489. }
  490. $shared = $activity->objects[0];
  491. if (!$shared instanceof Activity) {
  492. // TRANS: Client exception thrown when trying to share a non-activity object.
  493. throw new ClientException(_m('Can only handle shared activities.'));
  494. }
  495. $sharedId = $shared->id;
  496. if (!empty($shared->objects[0]->id)) {
  497. // Because StatusNet since commit 8cc4660 sets $shared->id to a TagURI which
  498. // fucks up federation, because the URI is no longer recognised by the origin.
  499. // So we set it to the object ID if it exists, otherwise we trust $shared->id
  500. $sharedId = $shared->objects[0]->id;
  501. }
  502. if (empty($sharedId)) {
  503. throw new ClientException(_m('Shared activity does not have an id'));
  504. }
  505. // First check if we have the shared activity. This has to be done first, because
  506. // we can't use these functions to "ensureActivityObjectProfile" of a local user,
  507. // who might be the creator of the shared activity in question.
  508. $sharedNotice = Notice::getKV('uri', $sharedId);
  509. if (!$sharedNotice instanceof Notice) {
  510. // If no locally stored notice is found, process it!
  511. // TODO: Remember to check Deleted_notice!
  512. // TODO: If a post is shared that we can't retrieve - what to do?
  513. try {
  514. $other = self::ensureActivityObjectProfile($shared->actor);
  515. $sharedNotice = $other->processActivity($shared, $method);
  516. if (!$sharedNotice instanceof Notice) {
  517. // And if we apparently can't get the shared notice, we'll abort the whole thing.
  518. // TRANS: Client exception thrown when saving an activity share fails.
  519. // TRANS: %s is a share ID.
  520. throw new ClientException(sprintf(_m('Failed to save activity %s.'), $sharedId));
  521. }
  522. } catch (FeedSubException $e) {
  523. // Remote feed could not be found or verified, should we
  524. // transform this into an "RT @user Blah, blah, blah..."?
  525. common_log(LOG_INFO, __METHOD__ . ' got a ' . get_class($e) . ': ' . $e->getMessage());
  526. return null;
  527. }
  528. }
  529. // We'll want to save a web link to the original notice, if provided.
  530. $sourceUrl = null;
  531. if ($activity->link) {
  532. $sourceUrl = $activity->link;
  533. } else if ($activity->link) {
  534. $sourceUrl = $activity->link;
  535. } else if (preg_match('!^https?://!', $activity->id)) {
  536. $sourceUrl = $activity->id;
  537. }
  538. // Use summary as fallback for content
  539. if (!empty($activity->content)) {
  540. $sourceContent = $activity->content;
  541. } else if (!empty($activity->summary)) {
  542. $sourceContent = $activity->summary;
  543. } else if (!empty($activity->title)) {
  544. $sourceContent = $activity->title;
  545. } else {
  546. // @todo FIXME: Fetch from $sourceUrl?
  547. // TRANS: Client exception. %s is a source URI.
  548. throw new ClientException(sprintf(_m('No content for notice %s.'), $activity->id));
  549. }
  550. // Get (safe!) HTML and text versions of the content
  551. $rendered = $this->purify($sourceContent);
  552. $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
  553. $shortened = common_shorten_links($content);
  554. // If it's too long, try using the summary, and make the
  555. // HTML an attachment.
  556. $attachment = null;
  557. if (Notice::contentTooLong($shortened)) {
  558. $attachment = $this->saveHTMLFile($activity->title, $rendered);
  559. $summary = html_entity_decode(strip_tags($activity->summary), ENT_QUOTES, 'UTF-8');
  560. if (empty($summary)) {
  561. $summary = $content;
  562. }
  563. $shortSummary = common_shorten_links($summary);
  564. if (Notice::contentTooLong($shortSummary)) {
  565. $url = common_shorten_url($sourceUrl);
  566. $shortSummary = substr($shortSummary,
  567. 0,
  568. Notice::maxContent() - (mb_strlen($url) + 2));
  569. $content = $shortSummary . ' ' . $url;
  570. // We mark up the attachment link specially for the HTML output
  571. // so we can fold-out the full version inline.
  572. // @todo FIXME i18n: This tooltip will be saved with the site's default language
  573. // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
  574. // TRANS: this will usually be replaced with localised text from StatusNet core messages.
  575. $showMoreText = _m('Show more');
  576. $attachUrl = common_local_url('attachment',
  577. array('attachment' => $attachment->id));
  578. $rendered = common_render_text($shortSummary) .
  579. '<a href="' . htmlspecialchars($attachUrl) .'"'.
  580. ' class="attachment more"' .
  581. ' title="'. htmlspecialchars($showMoreText) . '">' .
  582. '&#8230;' .
  583. '</a>';
  584. }
  585. }
  586. $options = array('is_local' => Notice::REMOTE,
  587. 'url' => $sourceUrl,
  588. 'uri' => $activity->id,
  589. 'rendered' => $rendered,
  590. 'replies' => array(),
  591. 'groups' => array(),
  592. 'peopletags' => array(),
  593. 'tags' => array(),
  594. 'urls' => array(),
  595. 'repeat_of' => $sharedNotice->id,
  596. 'scope' => $sharedNotice->scope);
  597. // Check for optional attributes...
  598. if (!empty($activity->time)) {
  599. $options['created'] = common_sql_date($activity->time);
  600. }
  601. if ($activity->context) {
  602. // TODO: context->attention
  603. list($options['groups'], $options['replies'])
  604. = $this->filterAttention($oprofile, $activity->context->attention);
  605. // Maintain direct reply associations
  606. // @todo FIXME: What about conversation ID?
  607. if (!empty($activity->context->replyToID)) {
  608. $orig = Notice::getKV('uri',
  609. $activity->context->replyToID);
  610. if ($orig instanceof Notice) {
  611. $options['reply_to'] = $orig->id;
  612. }
  613. }
  614. $location = $activity->context->location;
  615. if ($location) {
  616. $options['lat'] = $location->lat;
  617. $options['lon'] = $location->lon;
  618. if ($location->location_id) {
  619. $options['location_ns'] = $location->location_ns;
  620. $options['location_id'] = $location->location_id;
  621. }
  622. }
  623. }
  624. if ($this->isPeopletag()) {
  625. $options['peopletags'][] = $this->localPeopletag();
  626. }
  627. // Atom categories <-> hashtags
  628. foreach ($activity->categories as $cat) {
  629. if ($cat->term) {
  630. $term = common_canonical_tag($cat->term);
  631. if ($term) {
  632. $options['tags'][] = $term;
  633. }
  634. }
  635. }
  636. // Atom enclosures -> attachment URLs
  637. foreach ($activity->enclosures as $href) {
  638. // @todo FIXME: Save these locally or....?
  639. $options['urls'][] = $href;
  640. }
  641. $notice = Notice::saveNew($oprofile->profile_id,
  642. $content,
  643. 'ostatus',
  644. $options);
  645. return $notice;
  646. }
  647. /**
  648. * Process an incoming post activity from this remote feed.
  649. * @param Activity $activity
  650. * @param string $method 'push' or 'salmon'
  651. * @return mixed saved Notice or false
  652. * @todo FIXME: Break up this function, it's getting nasty long
  653. */
  654. public function processPost($activity, $method)
  655. {
  656. $notice = null;
  657. $oprofile = $this->checkAuthorship($activity);
  658. if (!$oprofile instanceof Ostatus_profile) {
  659. return null;
  660. }
  661. // It's not always an ActivityObject::NOTE, but... let's just say it is.
  662. $note = $activity->objects[0];
  663. // The id URI will be used as a unique identifier for the notice,
  664. // protecting against duplicate saves. It isn't required to be a URL;
  665. // tag: URIs for instance are found in Google Buzz feeds.
  666. $sourceUri = $note->id;
  667. $dupe = Notice::getKV('uri', $sourceUri);
  668. if ($dupe instanceof Notice) {
  669. common_log(LOG_INFO, "OStatus: ignoring duplicate post: $sourceUri");
  670. return $dupe;
  671. }
  672. // We'll also want to save a web link to the original notice, if provided.
  673. $sourceUrl = null;
  674. if ($note->link) {
  675. $sourceUrl = $note->link;
  676. } else if ($activity->link) {
  677. $sourceUrl = $activity->link;
  678. } else if (preg_match('!^https?://!', $note->id)) {
  679. $sourceUrl = $note->id;
  680. }
  681. // Use summary as fallback for content
  682. if (!empty($note->content)) {
  683. $sourceContent = $note->content;
  684. } else if (!empty($note->summary)) {
  685. $sourceContent = $note->summary;
  686. } else if (!empty($note->title)) {
  687. $sourceContent = $note->title;
  688. } else {
  689. // @todo FIXME: Fetch from $sourceUrl?
  690. // TRANS: Client exception. %s is a source URI.
  691. throw new ClientException(sprintf(_m('No content for notice %s.'),$sourceUri));
  692. }
  693. // Get (safe!) HTML and text versions of the content
  694. $rendered = $this->purify($sourceContent);
  695. $content = html_entity_decode(strip_tags($rendered), ENT_QUOTES, 'UTF-8');
  696. $shortened = common_shorten_links($content);
  697. // If it's too long, try using the summary, and make the
  698. // HTML an attachment.
  699. $attachment = null;
  700. if (Notice::contentTooLong($shortened)) {
  701. $attachment = $this->saveHTMLFile($note->title, $rendered);
  702. $summary = html_entity_decode(strip_tags($note->summary), ENT_QUOTES, 'UTF-8');
  703. if (empty($summary)) {
  704. $summary = $content;
  705. }
  706. $shortSummary = common_shorten_links($summary);
  707. if (Notice::contentTooLong($shortSummary)) {
  708. $url = common_shorten_url($sourceUrl);
  709. $shortSummary = substr($shortSummary,
  710. 0,
  711. Notice::maxContent() - (mb_strlen($url) + 2));
  712. $content = $shortSummary . ' ' . $url;
  713. // We mark up the attachment link specially for the HTML output
  714. // so we can fold-out the full version inline.
  715. // @todo FIXME i18n: This tooltip will be saved with the site's default language
  716. // TRANS: Shown when a notice is longer than supported and/or when attachments are present. At runtime
  717. // TRANS: this will usually be replaced with localised text from StatusNet core messages.
  718. $showMoreText = _m('Show more');
  719. $attachUrl = common_local_url('attachment',
  720. array('attachment' => $attachment->id));
  721. $rendered = common_render_text($shortSummary) .
  722. '<a href="' . htmlspecialchars($attachUrl) .'"'.
  723. ' class="attachment more"' .
  724. ' title="'. htmlspecialchars($showMoreText) . '">' .
  725. '&#8230;' .
  726. '</a>';
  727. }
  728. }
  729. $options = array('is_local' => Notice::REMOTE,
  730. 'url' => $sourceUrl,
  731. 'uri' => $sourceUri,
  732. 'rendered' => $rendered,
  733. 'replies' => array(),
  734. 'groups' => array(),
  735. 'peopletags' => array(),
  736. 'tags' => array(),
  737. 'urls' => array());
  738. // Check for optional attributes...
  739. if (!empty($activity->time)) {
  740. $options['created'] = common_sql_date($activity->time);
  741. }
  742. if ($activity->context) {
  743. // TODO: context->attention
  744. list($options['groups'], $options['replies'])
  745. = $this->filterAttention($oprofile, $activity->context->attention);
  746. // Maintain direct reply associations
  747. // @todo FIXME: What about conversation ID?
  748. if (!empty($activity->context->replyToID)) {
  749. $orig = Notice::getKV('uri', $activity->context->replyToID);
  750. if ($orig instanceof Notice) {
  751. $options['reply_to'] = $orig->id;
  752. }
  753. }
  754. $location = $activity->context->location;
  755. if ($location) {
  756. $options['lat'] = $location->lat;
  757. $options['lon'] = $location->lon;
  758. if ($location->location_id) {
  759. $options['location_ns'] = $location->location_ns;
  760. $options['location_id'] = $location->location_id;
  761. }
  762. }
  763. }
  764. if ($this->isPeopletag()) {
  765. $options['peopletags'][] = $this->localPeopletag();
  766. }
  767. // Atom categories <-> hashtags
  768. foreach ($activity->categories as $cat) {
  769. if ($cat->term) {
  770. $term = common_canonical_tag($cat->term);
  771. if ($term) {
  772. $options['tags'][] = $term;
  773. }
  774. }
  775. }
  776. // Atom enclosures -> attachment URLs
  777. foreach ($activity->enclosures as $href) {
  778. // @todo FIXME: Save these locally or....?
  779. $options['urls'][] = $href;
  780. }
  781. try {
  782. $saved = Notice::saveNew($oprofile->profile_id,
  783. $content,
  784. 'ostatus',
  785. $options);
  786. if ($saved instanceof Notice) {
  787. Ostatus_source::saveNew($saved, $this, $method);
  788. if (!empty($attachment)) {
  789. File_to_post::processNew($attachment->id, $saved->id);
  790. }
  791. }
  792. } catch (Exception $e) {
  793. common_log(LOG_ERR, "OStatus save of remote message $sourceUri failed: " . $e->getMessage());
  794. throw $e;
  795. }
  796. common_log(LOG_INFO, "OStatus saved remote message $sourceUri as notice id $saved->id");
  797. return $saved;
  798. }
  799. /**
  800. * Clean up HTML
  801. */
  802. protected function purify($html)
  803. {
  804. require_once INSTALLDIR.'/extlib/htmLawed/htmLawed.php';
  805. $config = array('safe' => 1,
  806. 'deny_attribute' => 'id,style,on*');
  807. return htmLawed($html, $config);
  808. }
  809. /**
  810. * Filters a list of recipient ID URIs to just those for local delivery.
  811. * @param Ostatus_profile local profile of sender
  812. * @param array in/out &$attention_uris set of URIs, will be pruned on output
  813. * @return array of group IDs
  814. */
  815. protected function filterAttention($sender, array $attention)
  816. {
  817. common_log(LOG_DEBUG, "Original reply recipients: " . implode(', ', array_keys($attention)));
  818. $groups = array();
  819. $replies = array();
  820. foreach ($attention as $recipient=>$type) {
  821. // Is the recipient a local user?
  822. $user = User::getKV('uri', $recipient);
  823. if ($user instanceof User) {
  824. // @todo FIXME: Sender verification, spam etc?
  825. $replies[] = $recipient;
  826. continue;
  827. }
  828. // Is the recipient a local group?
  829. // TODO: $group = User_group::getKV('uri', $recipient);
  830. $id = OStatusPlugin::localGroupFromUrl($recipient);
  831. if ($id) {
  832. $group = User_group::getKV('id', $id);
  833. if ($group instanceof User_group) {
  834. try {
  835. // Deliver to all members of this local group if allowed.
  836. $profile = $sender->localProfile();
  837. if ($profile->isMember($group)) {
  838. $groups[] = $group->id;
  839. } else {
  840. common_log(LOG_DEBUG, "Skipping reply to local group $group->nickname as sender $profile->id is not a member");
  841. }
  842. } catch (NoProfileException $e) {
  843. // Sender has no profile! Do some garbage collection, please.
  844. }
  845. continue;
  846. } else {
  847. common_log(LOG_DEBUG, "Skipping reply to bogus group $recipient");
  848. }
  849. }
  850. // Is the recipient a remote user or group?
  851. try {
  852. $oprofile = self::ensureProfileURI($recipient);
  853. if ($oprofile->isGroup()) {
  854. // Deliver to local members of this remote group.
  855. // @todo FIXME: Sender verification?
  856. $groups[] = $oprofile->group_id;
  857. } else {
  858. // may be canonicalized or something
  859. $replies[] = $oprofile->getUri();
  860. }
  861. continue;
  862. } catch (Exception $e) {
  863. // Neither a recognizable local nor remote user!
  864. common_log(LOG_DEBUG, "Skipping reply to unrecognized profile $recipient: " . $e->getMessage());
  865. }
  866. }
  867. common_log(LOG_DEBUG, "Local reply recipients: " . implode(', ', $replies));
  868. common_log(LOG_DEBUG, "Local group recipients: " . implode(', ', $groups));
  869. return array($groups, $replies);
  870. }
  871. /**
  872. * Look up and if necessary create an Ostatus_profile for the remote entity
  873. * with the given profile page URL. This should never return null -- you
  874. * will either get an object or an exception will be thrown.
  875. *
  876. * @param string $profile_url
  877. * @return Ostatus_profile
  878. * @throws Exception on various error conditions
  879. * @throws OStatusShadowException if this reference would obscure a local user/group
  880. */
  881. public static function ensureProfileURL($profile_url, $hints=array())
  882. {
  883. $oprofile = self::getFromProfileURL($profile_url);
  884. if ($oprofile instanceof Ostatus_profile) {
  885. return $oprofile;
  886. }
  887. $hints['profileurl'] = $profile_url;
  888. // Fetch the URL
  889. // XXX: HTTP caching
  890. $client = new HTTPClient();
  891. $client->setHeader('Accept', 'text/html,application/xhtml+xml');
  892. $response = $client->get($profile_url);
  893. if (!$response->isOk()) {
  894. // TRANS: Exception. %s is a profile URL.
  895. throw new Exception(sprintf(_m('Could not reach profile page %s.'),$profile_url));
  896. }
  897. // Check if we have a non-canonical URL
  898. $finalUrl = $response->getUrl();
  899. if ($finalUrl != $profile_url) {
  900. $hints['profileurl'] = $finalUrl;
  901. $oprofile = self::getFromProfileURL($finalUrl);
  902. if ($oprofile instanceof Ostatus_profile) {
  903. return $oprofile;
  904. }
  905. }
  906. // Try to get some hCard data
  907. $body = $response->getBody();
  908. $hcardHints = DiscoveryHints::hcardHints($body, $finalUrl);
  909. if (!empty($hcardHints)) {
  910. $hints = array_merge($hints, $hcardHints);
  911. }
  912. // Check if they've got an LRDD header
  913. $lrdd = LinkHeader::getLink($response, 'lrdd');
  914. try {
  915. $xrd = new XML_XRD();
  916. $xrd->loadFile($lrdd);
  917. $xrdHints = DiscoveryHints::fromXRD($xrd);
  918. $hints = array_merge($hints, $xrdHints);
  919. } catch (Exception $e) {
  920. // No hints available from XRD
  921. }
  922. // If discovery found a feedurl (probably from LRDD), use it.
  923. if (array_key_exists('feedurl', $hints)) {
  924. return self::ensureFeedURL($hints['feedurl'], $hints);
  925. }
  926. // Get the feed URL from HTML
  927. $discover = new FeedDiscovery();
  928. $feedurl = $discover->discoverFromHTML($finalUrl, $body);
  929. if (!empty($feedurl)) {
  930. $hints['feedurl'] = $feedurl;
  931. return self::ensureFeedURL($feedurl, $hints);
  932. }
  933. // TRANS: Exception. %s is a URL.
  934. throw new Exception(sprintf(_m('Could not find a feed URL for profile page %s.'),$finalUrl));
  935. }
  936. /**
  937. * Look up the Ostatus_profile, if present, for a remote entity with the
  938. * given profile page URL. Will return null for both unknown and invalid
  939. * remote profiles.
  940. *
  941. * @return mixed Ostatus_profile or null
  942. * @throws OStatusShadowException for local profiles
  943. */
  944. static function getFromProfileURL($profile_url)
  945. {
  946. $profile = Profile::getKV('profileurl', $profile_url);
  947. if (!$profile instanceof Profile) {
  948. return null;
  949. }
  950. // Is it a known Ostatus profile?
  951. $oprofile = Ostatus_profile::getKV('profile_id', $profile->id);
  952. if ($oprofile instanceof Ostatus_profile) {
  953. return $oprofile;
  954. }
  955. // Is it a local user?
  956. $user = User::getKV('id', $profile->id);
  957. if ($user instanceof User) {
  958. // @todo i18n FIXME: use sprintf and add i18n (?)
  959. throw new OStatusShadowException($profile, "'$profile_url' is the profile for local user '{$user->nickname}'.");
  960. }
  961. // Continue discovery; it's a remote profile
  962. // for OMB or some other protocol, may also
  963. // support OStatus
  964. return null;
  965. }
  966. /**
  967. * Look up and if necessary create an Ostatus_profile for remote entity
  968. * with the given update feed. This should never return null -- you will
  969. * either get an object or an exception will be thrown.
  970. *
  971. * @return Ostatus_profile
  972. * @throws Exception
  973. */
  974. public static function ensureFeedURL($feed_url, $hints=array())
  975. {
  976. $discover = new FeedDiscovery();
  977. $feeduri = $discover->discoverFromFeedURL($feed_url);
  978. $hints['feedurl'] = $feeduri;
  979. $huburi = $discover->getHubLink();
  980. $hints['hub'] = $huburi;
  981. // XXX: NS_REPLIES is deprecated anyway, so let's remove it in the future.
  982. $salmonuri = $discover->getAtomLink(Salmon::REL_SALMON)
  983. ?: $discover->getAtomLink(Salmon::NS_REPLIES);
  984. $hints['salmon'] = $salmonuri;
  985. if (!$huburi && !common_config('feedsub', 'fallback_hub')) {
  986. // We can only deal with folks with a PuSH hub
  987. throw new FeedSubNoHubException();
  988. }
  989. $feedEl = $discover->root;
  990. if ($feedEl->tagName == 'feed') {
  991. return self::ensureAtomFeed($feedEl, $hints);
  992. } else if ($feedEl->tagName == 'channel') {
  993. return self::ensureRssChannel($feedEl, $hints);
  994. } else {
  995. throw new FeedSubBadXmlException($feeduri);
  996. }
  997. }
  998. /**
  999. * Look up and, if necessary, create an Ostatus_profile for the remote
  1000. * profile with the given Atom feed - actually loaded from the feed.
  1001. * This should never return null -- you will either get an object or
  1002. * an exception will be thrown.
  1003. *
  1004. * @param DOMElement $feedEl root element of a loaded Atom feed
  1005. * @param array $hints additional discovery information passed from higher levels
  1006. * @todo FIXME: Should this be marked public?
  1007. * @return Ostatus_profile
  1008. * @throws Exception
  1009. */
  1010. public static function ensureAtomFeed($feedEl, $hints)
  1011. {
  1012. $author = ActivityUtils::getFeedAuthor($feedEl);
  1013. if (empty($author)) {
  1014. // XXX: make some educated guesses here
  1015. // TRANS: Feed sub exception.
  1016. throw new FeedSubException(_m('Cannot find enough profile '.
  1017. 'information to make a feed.'));
  1018. }
  1019. return self::ensureActivityObjectProfile($author, $hints);
  1020. }
  1021. /**
  1022. * Look up and, if necessary, create an Ostatus_profile for the remote
  1023. * profile with the given RSS feed - actually loaded from the feed.
  1024. * This should never return null -- you will either get an object or
  1025. * an exception will be thrown.
  1026. *
  1027. * @param DOMElement $feedEl root element of a loaded RSS feed
  1028. * @param array $hints additional discovery information passed from higher levels
  1029. * @todo FIXME: Should this be marked public?
  1030. * @return Ostatus_profile
  1031. * @throws Exception
  1032. */
  1033. public static function ensureRssChannel($feedEl, $hints)
  1034. {
  1035. // Special-case for Posterous. They have some nice metadata in their
  1036. // posterous:author elements. We should use them instead of the channel.
  1037. $items = $feedEl->getElementsByTagName('item');
  1038. if ($items->length > 0) {
  1039. $item = $items->item(0);
  1040. $authorEl = ActivityUtils::child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS);
  1041. if (!empty($authorEl)) {
  1042. $obj = ActivityObject::fromPosterousAuthor($authorEl);
  1043. // Posterous has multiple authors per feed, and multiple feeds
  1044. // per author. We check if this is the "main" feed for this author.
  1045. if (array_key_exists('profileurl', $hints) &&
  1046. !empty($obj->poco) &&
  1047. common_url_to_nickname($hints['profileurl']) == $obj->poco->preferredUsername) {
  1048. return self::ensureActivityObjectProfile($obj, $hints);
  1049. }
  1050. }
  1051. }
  1052. // @todo FIXME: We should check whether this feed has elements
  1053. // with different <author> or <dc:creator> elements, and... I dunno.
  1054. // Do something about that.
  1055. $obj = ActivityObject::fromRssChannel($feedEl);
  1056. return self::ensureActivityObjectProfile($obj, $hints);
  1057. }
  1058. /**
  1059. * Download and update given avatar image
  1060. *
  1061. * @param string $url
  1062. * @throws Exception in various failure cases
  1063. */
  1064. protected function updateAvatar($url)
  1065. {
  1066. if ($url == $this->avatar) {
  1067. // We've already got this one.
  1068. return;
  1069. }
  1070. if (!common_valid_http_url($url)) {
  1071. // TRANS: Server exception. %s is a URL.
  1072. throw new ServerException(sprintf(_m('Invalid avatar URL %s.'), $url));
  1073. }
  1074. if ($this->isGroup()) {
  1075. // FIXME: throw exception for localGroup
  1076. $self = $this->localGroup();
  1077. } else {
  1078. // this throws an exception already
  1079. $self = $this->localProfile();
  1080. }
  1081. if (!$self) {
  1082. throw new ServerException(sprintf(
  1083. // TRANS: Server exception. %s is a URI.
  1084. _m('Tried to update avatar for unsaved remote profile %s.'),
  1085. $this->getUri()));
  1086. }
  1087. // @todo FIXME: This should be better encapsulated
  1088. // ripped from oauthstore.php (for old OMB client)
  1089. $temp_filename = tempnam(sys_get_temp_dir(), 'listener_avatar');
  1090. try {
  1091. if (!copy($url, $temp_filename)) {
  1092. // TRANS: Server exception. %s is a URL.
  1093. throw new ServerException(sprintf(_m('Unable to fetch avatar from %s.'), $url));
  1094. }
  1095. if ($this->isGroup()) {
  1096. $id = $this->group_id;
  1097. } else {
  1098. $id = $this->profile_id;
  1099. }
  1100. // @todo FIXME: Should we be using different ids?
  1101. $imagefile = new ImageFile($id, $temp_filename);
  1102. $filename = Avatar::filename($id,
  1103. image_type_to_extension($imagefile->type),
  1104. null,
  1105. common_timestamp());
  1106. rename($temp_filename, Avatar::path($filename));
  1107. } catch (Exception $e) {
  1108. unlink($temp_filename);
  1109. throw $e;
  1110. }
  1111. // @todo FIXME: Hardcoded chmod is lame, but seems to be necessary to
  1112. // keep from accidentally saving images from command-line (queues)
  1113. // that can't be read from web server, which causes hard-to-notice
  1114. // problems later on:
  1115. //
  1116. // http://status.net/open-source/issues/2663
  1117. chmod(Avatar::path($filename), 0644);
  1118. $self->setOriginal($filename);
  1119. $orig = clone($this);
  1120. $this->avatar = $url;
  1121. $this->update($orig);
  1122. }
  1123. /**
  1124. * Pull avatar URL from ActivityObject or profile hints
  1125. *
  1126. * @param ActivityObject $object
  1127. * @param array $hints
  1128. * @return mixed URL string or false
  1129. */
  1130. public static function getActivityObjectAvatar($object, $hints=array())
  1131. {
  1132. if ($object->avatarLinks) {
  1133. $best = false;
  1134. // Take the exact-size avatar, or the largest avatar, or the first avatar if all sizeless
  1135. foreach ($object->avatarLinks as $avatar) {
  1136. if ($avatar->width == AVATAR_PROFILE_SIZE && $avatar->height = AVATAR_PROFILE_SIZE) {
  1137. // Exact match!
  1138. $best = $avatar;
  1139. break;
  1140. }
  1141. if (!$best || $avatar->width > $best->width) {
  1142. $best = $avatar;
  1143. }
  1144. }
  1145. return $best->url;
  1146. } else if (array_key_exists('avatar', $hints)) {
  1147. return $hints['avatar'];
  1148. }
  1149. return false;
  1150. }
  1151. /**
  1152. * Get an appropriate avatar image source URL, if available.
  1153. *
  1154. * @param ActivityObject $actor
  1155. * @param D…

Large files files are truncated, but you can click here to view the full file