PageRenderTime 89ms CodeModel.GetById 25ms RepoModel.GetById 1ms app.codeStats 0ms

/classes/Notice.php

https://github.com/ronhuang/statusnet
PHP | 2158 lines | 1372 code | 428 blank | 358 comment | 267 complexity | e56d6f4c7a503ad9b5b1cf961f3f8dae MD5 | raw file
Possible License(s): AGPL-3.0, GPL-2.0, LGPL-2.1, MIT, MPL-2.0-no-copyleft-exception, BSD-3-Clause

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) 2008, 2009, 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. * @category Notices
  20. * @package StatusNet
  21. * @author Brenda Wallace <shiny@cpan.org>
  22. * @author Christopher Vollick <psycotica0@gmail.com>
  23. * @author CiaranG <ciaran@ciarang.com>
  24. * @author Craig Andrews <candrews@integralblue.com>
  25. * @author Evan Prodromou <evan@controlezvous.ca>
  26. * @author Gina Haeussge <osd@foosel.net>
  27. * @author Jeffery To <jeffery.to@gmail.com>
  28. * @author Mike Cochrane <mikec@mikenz.geek.nz>
  29. * @author Robin Millette <millette@controlyourself.ca>
  30. * @author Sarven Capadisli <csarven@controlyourself.ca>
  31. * @author Tom Adams <tom@holizz.com>
  32. * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
  33. * @license GNU Affero General Public License http://www.gnu.org/licenses/
  34. */
  35. if (!defined('STATUSNET') && !defined('LACONICA')) {
  36. exit(1);
  37. }
  38. /**
  39. * Table Definition for notice
  40. */
  41. require_once INSTALLDIR.'/classes/Memcached_DataObject.php';
  42. /* We keep 200 notices, the max number of notices available per API request,
  43. * in the memcached cache. */
  44. define('NOTICE_CACHE_WINDOW', 200);
  45. define('MAX_BOXCARS', 128);
  46. class Notice extends Memcached_DataObject
  47. {
  48. ###START_AUTOCODE
  49. /* the code below is auto generated do not remove the above tag */
  50. public $__table = 'notice'; // table name
  51. public $id; // int(4) primary_key not_null
  52. public $profile_id; // int(4) multiple_key not_null
  53. public $uri; // varchar(255) unique_key
  54. public $content; // text
  55. public $rendered; // text
  56. public $url; // varchar(255)
  57. public $created; // datetime multiple_key not_null default_0000-00-00%2000%3A00%3A00
  58. public $modified; // timestamp not_null default_CURRENT_TIMESTAMP
  59. public $reply_to; // int(4)
  60. public $is_local; // int(4)
  61. public $source; // varchar(32)
  62. public $conversation; // int(4)
  63. public $lat; // decimal(10,7)
  64. public $lon; // decimal(10,7)
  65. public $location_id; // int(4)
  66. public $location_ns; // int(4)
  67. public $repeat_of; // int(4)
  68. /* Static get */
  69. function staticGet($k,$v=NULL)
  70. {
  71. return Memcached_DataObject::staticGet('Notice',$k,$v);
  72. }
  73. /* the code above is auto generated do not remove the tag below */
  74. ###END_AUTOCODE
  75. /* Notice types */
  76. const LOCAL_PUBLIC = 1;
  77. const REMOTE_OMB = 0;
  78. const LOCAL_NONPUBLIC = -1;
  79. const GATEWAY = -2;
  80. function getProfile()
  81. {
  82. $profile = Profile::staticGet('id', $this->profile_id);
  83. if (empty($profile)) {
  84. // TRANS: Server exception thrown when a user profile for a notice cannot be found.
  85. // TRANS: %1$d is a profile ID (number), %2$d is a notice ID (number).
  86. throw new ServerException(sprintf(_('No such profile (%1$d) for notice (%2$d).'), $this->profile_id, $this->id));
  87. }
  88. return $profile;
  89. }
  90. function delete()
  91. {
  92. // For auditing purposes, save a record that the notice
  93. // was deleted.
  94. // @fixme we have some cases where things get re-run and so the
  95. // insert fails.
  96. $deleted = Deleted_notice::staticGet('id', $this->id);
  97. if (!$deleted) {
  98. $deleted = new Deleted_notice();
  99. $deleted->id = $this->id;
  100. $deleted->profile_id = $this->profile_id;
  101. $deleted->uri = $this->uri;
  102. $deleted->created = $this->created;
  103. $deleted->deleted = common_sql_now();
  104. $deleted->insert();
  105. }
  106. if (Event::handle('NoticeDeleteRelated', array($this))) {
  107. // Clear related records
  108. $this->clearReplies();
  109. $this->clearRepeats();
  110. $this->clearFaves();
  111. $this->clearTags();
  112. $this->clearGroupInboxes();
  113. // NOTE: we don't clear inboxes
  114. // NOTE: we don't clear queue items
  115. }
  116. $result = parent::delete();
  117. $this->blowOnDelete();
  118. return $result;
  119. }
  120. /**
  121. * Extract #hashtags from this notice's content and save them to the database.
  122. */
  123. function saveTags()
  124. {
  125. /* extract all #hastags */
  126. $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/', strtolower($this->content), $match);
  127. if (!$count) {
  128. return true;
  129. }
  130. /* Add them to the database */
  131. return $this->saveKnownTags($match[1]);
  132. }
  133. /**
  134. * Record the given set of hash tags in the db for this notice.
  135. * Given tag strings will be normalized and checked for dupes.
  136. */
  137. function saveKnownTags($hashtags)
  138. {
  139. //turn each into their canonical tag
  140. //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
  141. for($i=0; $i<count($hashtags); $i++) {
  142. /* elide characters we don't want in the tag */
  143. $hashtags[$i] = common_canonical_tag($hashtags[$i]);
  144. }
  145. foreach(array_unique($hashtags) as $hashtag) {
  146. $this->saveTag($hashtag);
  147. self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
  148. }
  149. return true;
  150. }
  151. /**
  152. * Record a single hash tag as associated with this notice.
  153. * Tag format and uniqueness must be validated by caller.
  154. */
  155. function saveTag($hashtag)
  156. {
  157. $tag = new Notice_tag();
  158. $tag->notice_id = $this->id;
  159. $tag->tag = $hashtag;
  160. $tag->created = $this->created;
  161. $id = $tag->insert();
  162. if (!$id) {
  163. // TRANS: Server exception. %s are the error details.
  164. throw new ServerException(sprintf(_('Database error inserting hashtag: %s'),
  165. $last_error->message));
  166. return;
  167. }
  168. // if it's saved, blow its cache
  169. $tag->blowCache(false);
  170. }
  171. /**
  172. * Save a new notice and push it out to subscribers' inboxes.
  173. * Poster's permissions are checked before sending.
  174. *
  175. * @param int $profile_id Profile ID of the poster
  176. * @param string $content source message text; links may be shortened
  177. * per current user's preference
  178. * @param string $source source key ('web', 'api', etc)
  179. * @param array $options Associative array of optional properties:
  180. * string 'created' timestamp of notice; defaults to now
  181. * int 'is_local' source/gateway ID, one of:
  182. * Notice::LOCAL_PUBLIC - Local, ok to appear in public timeline
  183. * Notice::REMOTE_OMB - Sent from a remote OMB service;
  184. * hide from public timeline but show in
  185. * local "and friends" timelines
  186. * Notice::LOCAL_NONPUBLIC - Local, but hide from public timeline
  187. * Notice::GATEWAY - From another non-OMB service;
  188. * will not appear in public views
  189. * float 'lat' decimal latitude for geolocation
  190. * float 'lon' decimal longitude for geolocation
  191. * int 'location_id' geoname identifier
  192. * int 'location_ns' geoname namespace to interpret location_id
  193. * int 'reply_to'; notice ID this is a reply to
  194. * int 'repeat_of'; notice ID this is a repeat of
  195. * string 'uri' unique ID for notice; defaults to local notice URL
  196. * string 'url' permalink to notice; defaults to local notice URL
  197. * string 'rendered' rendered HTML version of content
  198. * array 'replies' list of profile URIs for reply delivery in
  199. * place of extracting @-replies from content.
  200. * array 'groups' list of group IDs to deliver to, in place of
  201. * extracting ! tags from content
  202. * array 'tags' list of hashtag strings to save with the notice
  203. * in place of extracting # tags from content
  204. * array 'urls' list of attached/referred URLs to save with the
  205. * notice in place of extracting links from content
  206. * @fixme tag override
  207. *
  208. * @return Notice
  209. * @throws ClientException
  210. */
  211. static function saveNew($profile_id, $content, $source, $options=null) {
  212. $defaults = array('uri' => null,
  213. 'url' => null,
  214. 'reply_to' => null,
  215. 'repeat_of' => null);
  216. if (!empty($options)) {
  217. $options = $options + $defaults;
  218. extract($options);
  219. } else {
  220. extract($defaults);
  221. }
  222. if (!isset($is_local)) {
  223. $is_local = Notice::LOCAL_PUBLIC;
  224. }
  225. $profile = Profile::staticGet($profile_id);
  226. $final = common_shorten_links($content);
  227. if (Notice::contentTooLong($final)) {
  228. // TRANS: Client exception thrown if a notice contains too many characters.
  229. throw new ClientException(_('Problem saving notice. Too long.'));
  230. }
  231. if (empty($profile)) {
  232. // TRANS: Client exception thrown when trying to save a notice for an unknown user.
  233. throw new ClientException(_('Problem saving notice. Unknown user.'));
  234. }
  235. if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
  236. common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
  237. // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
  238. throw new ClientException(_('Too many notices too fast; take a breather '.
  239. 'and post again in a few minutes.'));
  240. }
  241. if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
  242. common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
  243. // TRANS: Client exception thrown when a user tries to post too many duplicate notices in a given time frame.
  244. throw new ClientException(_('Too many duplicate messages too quickly;'.
  245. ' take a breather and post again in a few minutes.'));
  246. }
  247. if (!$profile->hasRight(Right::NEWNOTICE)) {
  248. common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $profile->nickname);
  249. // TRANS: Client exception thrown when a user tries to post while being banned.
  250. throw new ClientException(_('You are banned from posting notices on this site.'), 403);
  251. }
  252. $notice = new Notice();
  253. $notice->profile_id = $profile_id;
  254. $autosource = common_config('public', 'autosource');
  255. # Sandboxed are non-false, but not 1, either
  256. if (!$profile->hasRight(Right::PUBLICNOTICE) ||
  257. ($source && $autosource && in_array($source, $autosource))) {
  258. $notice->is_local = Notice::LOCAL_NONPUBLIC;
  259. } else {
  260. $notice->is_local = $is_local;
  261. }
  262. if (!empty($created)) {
  263. $notice->created = $created;
  264. } else {
  265. $notice->created = common_sql_now();
  266. }
  267. $notice->content = $final;
  268. $notice->source = $source;
  269. $notice->uri = $uri;
  270. $notice->url = $url;
  271. // Handle repeat case
  272. if (isset($repeat_of)) {
  273. $notice->repeat_of = $repeat_of;
  274. } else {
  275. $notice->reply_to = self::getReplyTo($reply_to, $profile_id, $source, $final);
  276. }
  277. if (!empty($notice->reply_to)) {
  278. $reply = Notice::staticGet('id', $notice->reply_to);
  279. $notice->conversation = $reply->conversation;
  280. }
  281. if (!empty($lat) && !empty($lon)) {
  282. $notice->lat = $lat;
  283. $notice->lon = $lon;
  284. }
  285. if (!empty($location_ns) && !empty($location_id)) {
  286. $notice->location_id = $location_id;
  287. $notice->location_ns = $location_ns;
  288. }
  289. if (!empty($rendered)) {
  290. $notice->rendered = $rendered;
  291. } else {
  292. $notice->rendered = common_render_content($final, $notice);
  293. }
  294. if (Event::handle('StartNoticeSave', array(&$notice))) {
  295. // XXX: some of these functions write to the DB
  296. $id = $notice->insert();
  297. if (!$id) {
  298. common_log_db_error($notice, 'INSERT', __FILE__);
  299. // TRANS: Server exception thrown when a notice cannot be saved.
  300. throw new ServerException(_('Problem saving notice.'));
  301. }
  302. // Update ID-dependent columns: URI, conversation
  303. $orig = clone($notice);
  304. $changed = false;
  305. if (empty($uri)) {
  306. $notice->uri = common_notice_uri($notice);
  307. $changed = true;
  308. }
  309. // If it's not part of a conversation, it's
  310. // the beginning of a new conversation.
  311. if (empty($notice->conversation)) {
  312. $conv = Conversation::create();
  313. $notice->conversation = $conv->id;
  314. $changed = true;
  315. }
  316. if ($changed) {
  317. if (!$notice->update($orig)) {
  318. common_log_db_error($notice, 'UPDATE', __FILE__);
  319. // TRANS: Server exception thrown when a notice cannot be updated.
  320. throw new ServerException(_('Problem saving notice.'));
  321. }
  322. }
  323. }
  324. # Clear the cache for subscribed users, so they'll update at next request
  325. # XXX: someone clever could prepend instead of clearing the cache
  326. $notice->blowOnInsert();
  327. // Save per-notice metadata...
  328. if (isset($replies)) {
  329. $notice->saveKnownReplies($replies);
  330. } else {
  331. $notice->saveReplies();
  332. }
  333. if (isset($tags)) {
  334. $notice->saveKnownTags($tags);
  335. } else {
  336. $notice->saveTags();
  337. }
  338. // Note: groups may save tags, so must be run after tags are saved
  339. // to avoid errors on duplicates.
  340. if (isset($groups)) {
  341. $notice->saveKnownGroups($groups);
  342. } else {
  343. $notice->saveGroups();
  344. }
  345. if (isset($urls)) {
  346. $notice->saveKnownUrls($urls);
  347. } else {
  348. $notice->saveUrls();
  349. }
  350. // Prepare inbox delivery, may be queued to background.
  351. $notice->distribute();
  352. return $notice;
  353. }
  354. function blowOnInsert($conversation = false)
  355. {
  356. self::blow('profile:notice_ids:%d', $this->profile_id);
  357. self::blow('public');
  358. // XXX: Before we were blowing the casche only if the notice id
  359. // was not the root of the conversation. What to do now?
  360. self::blow('notice:conversation_ids:%d', $this->conversation);
  361. if (!empty($this->repeat_of)) {
  362. self::blow('notice:repeats:%d', $this->repeat_of);
  363. }
  364. $original = Notice::staticGet('id', $this->repeat_of);
  365. if (!empty($original)) {
  366. $originalUser = User::staticGet('id', $original->profile_id);
  367. if (!empty($originalUser)) {
  368. self::blow('user:repeats_of_me:%d', $originalUser->id);
  369. }
  370. }
  371. $profile = Profile::staticGet($this->profile_id);
  372. if (!empty($profile)) {
  373. $profile->blowNoticeCount();
  374. }
  375. }
  376. /**
  377. * Clear cache entries related to this notice at delete time.
  378. * Necessary to avoid breaking paging on public, profile timelines.
  379. */
  380. function blowOnDelete()
  381. {
  382. $this->blowOnInsert();
  383. self::blow('profile:notice_ids:%d;last', $this->profile_id);
  384. self::blow('public;last');
  385. }
  386. /** save all urls in the notice to the db
  387. *
  388. * follow redirects and save all available file information
  389. * (mimetype, date, size, oembed, etc.)
  390. *
  391. * @return void
  392. */
  393. function saveUrls() {
  394. common_replace_urls_callback($this->content, array($this, 'saveUrl'), $this->id);
  395. }
  396. /**
  397. * Save the given URLs as related links/attachments to the db
  398. *
  399. * follow redirects and save all available file information
  400. * (mimetype, date, size, oembed, etc.)
  401. *
  402. * @return void
  403. */
  404. function saveKnownUrls($urls)
  405. {
  406. // @fixme validation?
  407. foreach (array_unique($urls) as $url) {
  408. File::processNew($url, $this->id);
  409. }
  410. }
  411. /**
  412. * @private callback
  413. */
  414. function saveUrl($data) {
  415. list($url, $notice_id) = $data;
  416. File::processNew($url, $notice_id);
  417. }
  418. static function checkDupes($profile_id, $content) {
  419. $profile = Profile::staticGet($profile_id);
  420. if (empty($profile)) {
  421. return false;
  422. }
  423. $notice = $profile->getNotices(0, NOTICE_CACHE_WINDOW);
  424. if (!empty($notice)) {
  425. $last = 0;
  426. while ($notice->fetch()) {
  427. if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
  428. return true;
  429. } else if ($notice->content == $content) {
  430. return false;
  431. }
  432. }
  433. }
  434. # If we get here, oldest item in cache window is not
  435. # old enough for dupe limit; do direct check against DB
  436. $notice = new Notice();
  437. $notice->profile_id = $profile_id;
  438. $notice->content = $content;
  439. if (common_config('db','type') == 'pgsql')
  440. $notice->whereAdd('extract(epoch from now() - created) < ' . common_config('site', 'dupelimit'));
  441. else
  442. $notice->whereAdd('now() - created < ' . common_config('site', 'dupelimit'));
  443. $cnt = $notice->count();
  444. return ($cnt == 0);
  445. }
  446. static function checkEditThrottle($profile_id) {
  447. $profile = Profile::staticGet($profile_id);
  448. if (empty($profile)) {
  449. return false;
  450. }
  451. # Get the Nth notice
  452. $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
  453. if ($notice && $notice->fetch()) {
  454. # If the Nth notice was posted less than timespan seconds ago
  455. if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
  456. # Then we throttle
  457. return false;
  458. }
  459. }
  460. # Either not N notices in the stream, OR the Nth was not posted within timespan seconds
  461. return true;
  462. }
  463. function getUploadedAttachment() {
  464. $post = clone $this;
  465. $query = 'select file.url as up, file.id as i from file join file_to_post on file.id = file_id where post_id=' . $post->escape($post->id) . ' and url like "%/notice/%/file"';
  466. $post->query($query);
  467. $post->fetch();
  468. if (empty($post->up) || empty($post->i)) {
  469. $ret = false;
  470. } else {
  471. $ret = array($post->up, $post->i);
  472. }
  473. $post->free();
  474. return $ret;
  475. }
  476. function hasAttachments() {
  477. $post = clone $this;
  478. $query = "select count(file_id) as n_attachments from file join file_to_post on (file_id = file.id) join notice on (post_id = notice.id) where post_id = " . $post->escape($post->id);
  479. $post->query($query);
  480. $post->fetch();
  481. $n_attachments = intval($post->n_attachments);
  482. $post->free();
  483. return $n_attachments;
  484. }
  485. function attachments() {
  486. // XXX: cache this
  487. $att = array();
  488. $f2p = new File_to_post;
  489. $f2p->post_id = $this->id;
  490. if ($f2p->find()) {
  491. while ($f2p->fetch()) {
  492. $f = File::staticGet($f2p->file_id);
  493. if ($f) {
  494. $att[] = clone($f);
  495. }
  496. }
  497. }
  498. return $att;
  499. }
  500. function getStreamByIds($ids)
  501. {
  502. $cache = common_memcache();
  503. if (!empty($cache)) {
  504. $notices = array();
  505. foreach ($ids as $id) {
  506. $n = Notice::staticGet('id', $id);
  507. if (!empty($n)) {
  508. $notices[] = $n;
  509. }
  510. }
  511. return new ArrayWrapper($notices);
  512. } else {
  513. $notice = new Notice();
  514. if (empty($ids)) {
  515. //if no IDs requested, just return the notice object
  516. return $notice;
  517. }
  518. $notice->whereAdd('id in (' . implode(', ', $ids) . ')');
  519. $notice->find();
  520. $temp = array();
  521. while ($notice->fetch()) {
  522. $temp[$notice->id] = clone($notice);
  523. }
  524. $wrapped = array();
  525. foreach ($ids as $id) {
  526. if (array_key_exists($id, $temp)) {
  527. $wrapped[] = $temp[$id];
  528. }
  529. }
  530. return new ArrayWrapper($wrapped);
  531. }
  532. }
  533. function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0)
  534. {
  535. $ids = Notice::stream(array('Notice', '_publicStreamDirect'),
  536. array(),
  537. 'public',
  538. $offset, $limit, $since_id, $max_id);
  539. return Notice::getStreamByIds($ids);
  540. }
  541. function _publicStreamDirect($offset=0, $limit=20, $since_id=0, $max_id=0)
  542. {
  543. $notice = new Notice();
  544. $notice->selectAdd(); // clears it
  545. $notice->selectAdd('id');
  546. $notice->orderBy('id DESC');
  547. if (!is_null($offset)) {
  548. $notice->limit($offset, $limit);
  549. }
  550. if (common_config('public', 'localonly')) {
  551. $notice->whereAdd('is_local = ' . Notice::LOCAL_PUBLIC);
  552. } else {
  553. # -1 == blacklisted, -2 == gateway (i.e. Twitter)
  554. $notice->whereAdd('is_local !='. Notice::LOCAL_NONPUBLIC);
  555. $notice->whereAdd('is_local !='. Notice::GATEWAY);
  556. }
  557. if ($since_id != 0) {
  558. $notice->whereAdd('id > ' . $since_id);
  559. }
  560. if ($max_id != 0) {
  561. $notice->whereAdd('id <= ' . $max_id);
  562. }
  563. $ids = array();
  564. if ($notice->find()) {
  565. while ($notice->fetch()) {
  566. $ids[] = $notice->id;
  567. }
  568. }
  569. $notice->free();
  570. $notice = NULL;
  571. return $ids;
  572. }
  573. function conversationStream($id, $offset=0, $limit=20, $since_id=0, $max_id=0)
  574. {
  575. $ids = Notice::stream(array('Notice', '_conversationStreamDirect'),
  576. array($id),
  577. 'notice:conversation_ids:'.$id,
  578. $offset, $limit, $since_id, $max_id);
  579. return Notice::getStreamByIds($ids);
  580. }
  581. function _conversationStreamDirect($id, $offset=0, $limit=20, $since_id=0, $max_id=0)
  582. {
  583. $notice = new Notice();
  584. $notice->selectAdd(); // clears it
  585. $notice->selectAdd('id');
  586. $notice->conversation = $id;
  587. $notice->orderBy('id DESC');
  588. if (!is_null($offset)) {
  589. $notice->limit($offset, $limit);
  590. }
  591. if ($since_id != 0) {
  592. $notice->whereAdd('id > ' . $since_id);
  593. }
  594. if ($max_id != 0) {
  595. $notice->whereAdd('id <= ' . $max_id);
  596. }
  597. $ids = array();
  598. if ($notice->find()) {
  599. while ($notice->fetch()) {
  600. $ids[] = $notice->id;
  601. }
  602. }
  603. $notice->free();
  604. $notice = NULL;
  605. return $ids;
  606. }
  607. /**
  608. * Is this notice part of an active conversation?
  609. *
  610. * @return boolean true if other messages exist in the same
  611. * conversation, false if this is the only one
  612. */
  613. function hasConversation()
  614. {
  615. if (!empty($this->conversation)) {
  616. $conversation = Notice::conversationStream(
  617. $this->conversation,
  618. 1,
  619. 1
  620. );
  621. if ($conversation->N > 0) {
  622. return true;
  623. }
  624. }
  625. return false;
  626. }
  627. /**
  628. * Pull up a full list of local recipients who will be getting
  629. * this notice in their inbox. Results will be cached, so don't
  630. * change the input data wily-nilly!
  631. *
  632. * @param array $groups optional list of Group objects;
  633. * if left empty, will be loaded from group_inbox records
  634. * @param array $recipient optional list of reply profile ids
  635. * if left empty, will be loaded from reply records
  636. * @return array associating recipient user IDs with an inbox source constant
  637. */
  638. function whoGets($groups=null, $recipients=null)
  639. {
  640. $c = self::memcache();
  641. if (!empty($c)) {
  642. $ni = $c->get(common_cache_key('notice:who_gets:'.$this->id));
  643. if ($ni !== false) {
  644. return $ni;
  645. }
  646. }
  647. if (is_null($groups)) {
  648. $groups = $this->getGroups();
  649. }
  650. if (is_null($recipients)) {
  651. $recipients = $this->getReplies();
  652. }
  653. $users = $this->getSubscribedUsers();
  654. // FIXME: kind of ignoring 'transitional'...
  655. // we'll probably stop supporting inboxless mode
  656. // in 0.9.x
  657. $ni = array();
  658. foreach ($users as $id) {
  659. $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
  660. }
  661. foreach ($groups as $group) {
  662. $users = $group->getUserMembers();
  663. foreach ($users as $id) {
  664. if (!array_key_exists($id, $ni)) {
  665. $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
  666. }
  667. }
  668. }
  669. foreach ($recipients as $recipient) {
  670. if (!array_key_exists($recipient, $ni)) {
  671. $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
  672. }
  673. }
  674. // Exclude any deleted, non-local, or blocking recipients.
  675. $profile = $this->getProfile();
  676. foreach ($ni as $id => $source) {
  677. $user = User::staticGet('id', $id);
  678. if (empty($user) || $user->hasBlocked($profile)) {
  679. unset($ni[$id]);
  680. }
  681. }
  682. if (!empty($c)) {
  683. // XXX: pack this data better
  684. $c->set(common_cache_key('notice:who_gets:'.$this->id), $ni);
  685. }
  686. return $ni;
  687. }
  688. /**
  689. * Adds this notice to the inboxes of each local user who should receive
  690. * it, based on author subscriptions, group memberships, and @-replies.
  691. *
  692. * Warning: running a second time currently will make items appear
  693. * multiple times in users' inboxes.
  694. *
  695. * @fixme make more robust against errors
  696. * @fixme break up massive deliveries to smaller background tasks
  697. *
  698. * @param array $groups optional list of Group objects;
  699. * if left empty, will be loaded from group_inbox records
  700. * @param array $recipient optional list of reply profile ids
  701. * if left empty, will be loaded from reply records
  702. */
  703. function addToInboxes($groups=null, $recipients=null)
  704. {
  705. $ni = $this->whoGets($groups, $recipients);
  706. $ids = array_keys($ni);
  707. // We remove the author (if they're a local user),
  708. // since we'll have already done this in distribute()
  709. $i = array_search($this->profile_id, $ids);
  710. if ($i !== false) {
  711. unset($ids[$i]);
  712. }
  713. // Bulk insert
  714. Inbox::bulkInsert($this->id, $ids);
  715. return;
  716. }
  717. function getSubscribedUsers()
  718. {
  719. $user = new User();
  720. if(common_config('db','quote_identifiers'))
  721. $user_table = '"user"';
  722. else $user_table = 'user';
  723. $qry =
  724. 'SELECT id ' .
  725. 'FROM '. $user_table .' JOIN subscription '.
  726. 'ON '. $user_table .'.id = subscription.subscriber ' .
  727. 'WHERE subscription.subscribed = %d ';
  728. $user->query(sprintf($qry, $this->profile_id));
  729. $ids = array();
  730. while ($user->fetch()) {
  731. $ids[] = $user->id;
  732. }
  733. $user->free();
  734. return $ids;
  735. }
  736. /**
  737. * Record this notice to the given group inboxes for delivery.
  738. * Overrides the regular parsing of !group markup.
  739. *
  740. * @param string $group_ids
  741. * @fixme might prefer URIs as identifiers, as for replies?
  742. * best with generalizations on user_group to support
  743. * remote groups better.
  744. */
  745. function saveKnownGroups($group_ids)
  746. {
  747. if (!is_array($group_ids)) {
  748. // TRANS: Server exception thrown when no array is provided to the method saveKnownGroups().
  749. throw new ServerException(_("Bad type provided to saveKnownGroups"));
  750. }
  751. $groups = array();
  752. foreach (array_unique($group_ids) as $id) {
  753. $group = User_group::staticGet('id', $id);
  754. if ($group) {
  755. common_log(LOG_ERR, "Local delivery to group id $id, $group->nickname");
  756. $result = $this->addToGroupInbox($group);
  757. if (!$result) {
  758. common_log_db_error($gi, 'INSERT', __FILE__);
  759. }
  760. // @fixme should we save the tags here or not?
  761. $groups[] = clone($group);
  762. } else {
  763. common_log(LOG_ERR, "Local delivery to group id $id skipped, doesn't exist");
  764. }
  765. }
  766. return $groups;
  767. }
  768. /**
  769. * Parse !group delivery and record targets into group_inbox.
  770. * @return array of Group objects
  771. */
  772. function saveGroups()
  773. {
  774. // Don't save groups for repeats
  775. if (!empty($this->repeat_of)) {
  776. return array();
  777. }
  778. $groups = array();
  779. /* extract all !group */
  780. $count = preg_match_all('/(?:^|\s)!([A-Za-z0-9]{1,64})/',
  781. strtolower($this->content),
  782. $match);
  783. if (!$count) {
  784. return $groups;
  785. }
  786. $profile = $this->getProfile();
  787. /* Add them to the database */
  788. foreach (array_unique($match[1]) as $nickname) {
  789. /* XXX: remote groups. */
  790. $group = User_group::getForNickname($nickname, $profile);
  791. if (empty($group)) {
  792. continue;
  793. }
  794. // we automatically add a tag for every group name, too
  795. $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($nickname),
  796. 'notice_id' => $this->id));
  797. if (is_null($tag)) {
  798. $this->saveTag($nickname);
  799. }
  800. if ($profile->isMember($group)) {
  801. $result = $this->addToGroupInbox($group);
  802. if (!$result) {
  803. common_log_db_error($gi, 'INSERT', __FILE__);
  804. }
  805. $groups[] = clone($group);
  806. }
  807. }
  808. return $groups;
  809. }
  810. function addToGroupInbox($group)
  811. {
  812. $gi = Group_inbox::pkeyGet(array('group_id' => $group->id,
  813. 'notice_id' => $this->id));
  814. if (empty($gi)) {
  815. $gi = new Group_inbox();
  816. $gi->group_id = $group->id;
  817. $gi->notice_id = $this->id;
  818. $gi->created = $this->created;
  819. $result = $gi->insert();
  820. if (!$result) {
  821. common_log_db_error($gi, 'INSERT', __FILE__);
  822. // TRANS: Server exception thrown when an update for a group inbox fails.
  823. throw new ServerException(_('Problem saving group inbox.'));
  824. }
  825. self::blow('user_group:notice_ids:%d', $gi->group_id);
  826. }
  827. return true;
  828. }
  829. /**
  830. * Save reply records indicating that this notice needs to be
  831. * delivered to the local users with the given URIs.
  832. *
  833. * Since this is expected to be used when saving foreign-sourced
  834. * messages, we won't deliver to any remote targets as that's the
  835. * source service's responsibility.
  836. *
  837. * Mail notifications etc will be handled later.
  838. *
  839. * @param array of unique identifier URIs for recipients
  840. */
  841. function saveKnownReplies($uris)
  842. {
  843. if (empty($uris)) {
  844. return;
  845. }
  846. $sender = Profile::staticGet($this->profile_id);
  847. foreach (array_unique($uris) as $uri) {
  848. $profile = Profile::fromURI($uri);
  849. if (empty($profile)) {
  850. common_log(LOG_WARNING, "Unable to determine profile for URI '$uri'");
  851. continue;
  852. }
  853. if ($profile->hasBlocked($sender)) {
  854. common_log(LOG_INFO, "Not saving reply to profile {$profile->id} ($uri) from sender {$sender->id} because of a block.");
  855. continue;
  856. }
  857. $reply = new Reply();
  858. $reply->notice_id = $this->id;
  859. $reply->profile_id = $profile->id;
  860. common_log(LOG_INFO, __METHOD__ . ": saving reply: notice $this->id to profile $profile->id");
  861. $id = $reply->insert();
  862. }
  863. return;
  864. }
  865. /**
  866. * Pull @-replies from this message's content in StatusNet markup format
  867. * and save reply records indicating that this message needs to be
  868. * delivered to those users.
  869. *
  870. * Mail notifications to local profiles will be sent later.
  871. *
  872. * @return array of integer profile IDs
  873. */
  874. function saveReplies()
  875. {
  876. // Don't save reply data for repeats
  877. if (!empty($this->repeat_of)) {
  878. return array();
  879. }
  880. $sender = Profile::staticGet($this->profile_id);
  881. // @todo ideally this parser information would only
  882. // be calculated once.
  883. $mentions = common_find_mentions($this->content, $this);
  884. $replied = array();
  885. // store replied only for first @ (what user/notice what the reply directed,
  886. // we assume first @ is it)
  887. foreach ($mentions as $mention) {
  888. foreach ($mention['mentioned'] as $mentioned) {
  889. // skip if they're already covered
  890. if (!empty($replied[$mentioned->id])) {
  891. continue;
  892. }
  893. // Don't save replies from blocked profile to local user
  894. $mentioned_user = User::staticGet('id', $mentioned->id);
  895. if (!empty($mentioned_user) && $mentioned_user->hasBlocked($sender)) {
  896. continue;
  897. }
  898. $reply = new Reply();
  899. $reply->notice_id = $this->id;
  900. $reply->profile_id = $mentioned->id;
  901. $id = $reply->insert();
  902. if (!$id) {
  903. common_log_db_error($reply, 'INSERT', __FILE__);
  904. // TRANS: Server exception thrown when a reply cannot be saved.
  905. // TRANS: %1$d is a notice ID, %2$d is the ID of the mentioned user.
  906. throw new ServerException(sprintf(_('Could not save reply for %1$d, %2$d.'), $this->id, $mentioned->id));
  907. } else {
  908. $replied[$mentioned->id] = 1;
  909. self::blow('reply:stream:%d', $mentioned->id);
  910. }
  911. }
  912. }
  913. $recipientIds = array_keys($replied);
  914. return $recipientIds;
  915. }
  916. /**
  917. * Pull the complete list of @-reply targets for this notice.
  918. *
  919. * @return array of integer profile ids
  920. */
  921. function getReplies()
  922. {
  923. // XXX: cache me
  924. $ids = array();
  925. $reply = new Reply();
  926. $reply->selectAdd();
  927. $reply->selectAdd('profile_id');
  928. $reply->notice_id = $this->id;
  929. if ($reply->find()) {
  930. while($reply->fetch()) {
  931. $ids[] = $reply->profile_id;
  932. }
  933. }
  934. $reply->free();
  935. return $ids;
  936. }
  937. /**
  938. * Send e-mail notifications to local @-reply targets.
  939. *
  940. * Replies must already have been saved; this is expected to be run
  941. * from the distrib queue handler.
  942. */
  943. function sendReplyNotifications()
  944. {
  945. // Don't send reply notifications for repeats
  946. if (!empty($this->repeat_of)) {
  947. return array();
  948. }
  949. $recipientIds = $this->getReplies();
  950. foreach ($recipientIds as $recipientId) {
  951. $user = User::staticGet('id', $recipientId);
  952. if (!empty($user)) {
  953. mail_notify_attn($user, $this);
  954. }
  955. }
  956. }
  957. /**
  958. * Pull list of groups this notice needs to be delivered to,
  959. * as previously recorded by saveGroups() or saveKnownGroups().
  960. *
  961. * @return array of Group objects
  962. */
  963. function getGroups()
  964. {
  965. // Don't save groups for repeats
  966. if (!empty($this->repeat_of)) {
  967. return array();
  968. }
  969. // XXX: cache me
  970. $groups = array();
  971. $gi = new Group_inbox();
  972. $gi->selectAdd();
  973. $gi->selectAdd('group_id');
  974. $gi->notice_id = $this->id;
  975. if ($gi->find()) {
  976. while ($gi->fetch()) {
  977. $group = User_group::staticGet('id', $gi->group_id);
  978. if ($group) {
  979. $groups[] = $group;
  980. }
  981. }
  982. }
  983. $gi->free();
  984. return $groups;
  985. }
  986. function asActivity()
  987. {
  988. $profile = $this->getProfile();
  989. $act = new Activity();
  990. $act->actor = ActivityObject::fromProfile($profile);
  991. $act->verb = ActivityVerb::POST;
  992. $act->objects[] = ActivityObject::fromNotice($this);
  993. $act->time = strtotime($this->created);
  994. $act->link = $this->bestUrl();
  995. $act->content = common_xml_safe_str($this->rendered);
  996. $act->id = $this->uri;
  997. $act->title = common_xml_safe_str($this->content);
  998. $ctx = new ActivityContext();
  999. if (!empty($this->reply_to)) {
  1000. $reply = Notice::staticGet('id', $this->reply_to);
  1001. if (!empty($reply)) {
  1002. $ctx->replyToID = $reply->uri;
  1003. $ctx->replyToUrl = $reply->bestUrl();
  1004. }
  1005. }
  1006. $ctx->location = $this->getLocation();
  1007. $conv = null;
  1008. if (!empty($this->conversation)) {
  1009. $conv = Conversation::staticGet('id', $this->conversation);
  1010. if (!empty($conv)) {
  1011. $ctx->conversation = $conv->uri;
  1012. }
  1013. }
  1014. $reply_ids = $this->getReplies();
  1015. foreach ($reply_ids as $id) {
  1016. $profile = Profile::staticGet('id', $id);
  1017. if (!empty($profile)) {
  1018. $ctx->attention[] = $profile->getUri();
  1019. }
  1020. }
  1021. $groups = $this->getGroups();
  1022. foreach ($groups as $group) {
  1023. $ctx->attention[] = $group->uri;
  1024. }
  1025. $act->context = $ctx;
  1026. return $act;
  1027. }
  1028. // This has gotten way too long. Needs to be sliced up into functional bits
  1029. // or ideally exported to a utility class.
  1030. function asAtomEntry($namespace=false, $source=false, $author=true, $cur=null)
  1031. {
  1032. $profile = $this->getProfile();
  1033. $xs = new XMLStringer(true);
  1034. if ($namespace) {
  1035. $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
  1036. 'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
  1037. 'xmlns:georss' => 'http://www.georss.org/georss',
  1038. 'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
  1039. 'xmlns:media' => 'http://purl.org/syndication/atommedia',
  1040. 'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
  1041. 'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
  1042. 'xmlns:statusnet' => 'http://status.net/schema/api/1/');
  1043. } else {
  1044. $attrs = array();
  1045. }
  1046. if (Event::handle('StartActivityStart', array(&$this, &$xs, &$attrs))) {
  1047. $xs->elementStart('entry', $attrs);
  1048. Event::handle('EndActivityStart', array(&$this, &$xs, &$attrs));
  1049. }
  1050. if (Event::handle('StartActivitySource', array(&$this, &$xs))) {
  1051. if ($source) {
  1052. $atom_feed = $profile->getAtomFeed();
  1053. if (!empty($atom_feed)) {
  1054. $xs->elementStart('source');
  1055. // XXX: we should store the actual feed ID
  1056. $xs->element('id', null, $atom_feed);
  1057. // XXX: we should store the actual feed title
  1058. $xs->element('title', null, $profile->getBestName());
  1059. $xs->element('link', array('rel' => 'alternate',
  1060. 'type' => 'text/html',
  1061. 'href' => $profile->profileurl));
  1062. $xs->element('link', array('rel' => 'self',
  1063. 'type' => 'application/atom+xml',
  1064. 'href' => $atom_feed));
  1065. $xs->element('icon', null, $profile->avatarUrl(AVATAR_PROFILE_SIZE));
  1066. $notice = $profile->getCurrentNotice();
  1067. if (!empty($notice)) {
  1068. $xs->element('updated', null, self::utcDate($notice->created));
  1069. }
  1070. $user = User::staticGet('id', $profile->id);
  1071. if (!empty($user)) {
  1072. $xs->element('link', array('rel' => 'license',
  1073. 'href' => common_config('license', 'url')));
  1074. }
  1075. $xs->elementEnd('source');
  1076. }
  1077. }
  1078. Event::handle('EndActivitySource', array(&$this, &$xs));
  1079. }
  1080. $title = common_xml_safe_str($this->content);
  1081. if (Event::handle('StartActivityTitle', array(&$this, &$xs, &$title))) {
  1082. $xs->element('title', null, $title);
  1083. Event::handle('EndActivityTitle', array($this, &$xs, $title));
  1084. }
  1085. $atomAuthor = '';
  1086. if ($author) {
  1087. $atomAuthor = $profile->asAtomAuthor($cur);
  1088. }
  1089. if (Event::handle('StartActivityAuthor', array(&$this, &$xs, &$atomAuthor))) {
  1090. if (!empty($atomAuthor)) {
  1091. $xs->raw($atomAuthor);
  1092. Event::handle('EndActivityAuthor', array(&$this, &$xs, &$atomAuthor));
  1093. }
  1094. }
  1095. $actor = '';
  1096. if ($author) {
  1097. $actor = $profile->asActivityActor();
  1098. }
  1099. if (Event::handle('StartActivityActor', array(&$this, &$xs, &$actor))) {
  1100. if (!empty($actor)) {
  1101. $xs->raw($actor);
  1102. Event::handle('EndActivityActor', array(&$this, &$xs, &$actor));
  1103. }
  1104. }
  1105. $url = $this->bestUrl();
  1106. if (Event::handle('StartActivityLink', array(&$this, &$xs, &$url))) {
  1107. $xs->element('link', array('rel' => 'alternate',
  1108. 'type' => 'text/html',
  1109. 'href' => $url));
  1110. Event::handle('EndActivityLink', array(&$this, &$xs, $url));
  1111. }
  1112. $id = $this->uri;
  1113. if (Event::handle('StartActivityId', array(&$this, &$xs, &$id))) {
  1114. $xs->element('id', null, $id);
  1115. Event::handle('EndActivityId', array(&$this, &$xs, $id));
  1116. }
  1117. $published = self::utcDate($this->created);
  1118. if (Event::handle('StartActivityPublished', array(&$this, &$xs, &$published))) {
  1119. $xs->element('published', null, $published);
  1120. Event::handle('EndActivityPublished', array(&$this, &$xs, $published));
  1121. }
  1122. $updated = $published; // XXX: notices are usually immutable
  1123. if (Event::handle('StartActivityUpdated', array(&$this, &$xs, &$updated))) {
  1124. $xs->element('updated', null, $updated);
  1125. Event::handle('EndActivityUpdated', array(&$this, &$xs, $updated));
  1126. }
  1127. $content = common_xml_safe_str($this->rendered);
  1128. if (Event::handle('StartActivityContent', array(&$this, &$xs, &$content))) {
  1129. $xs->element('content', array('type' => 'html'), $content);
  1130. Event::handle('EndActivityContent', array(&$this, &$xs, $content));
  1131. }
  1132. // Most of our notices represent POSTing a NOTE. This is the default verb
  1133. // for activity streams, so we normally just leave it out.
  1134. $verb = ActivityVerb::POST;
  1135. if (Event::handle('StartActivityVerb', array(&$this, &$xs, &$verb))) {
  1136. $xs->element('activity:verb', null, $verb);
  1137. Event::handle('EndActivityVerb', array(&$this, &$xs, $verb));
  1138. }
  1139. // We use the default behavior for activity streams: if there's no activity:object,
  1140. // then treat the entry itself as the object. Here, you can set the type of that object,
  1141. // which is normally a NOTE.
  1142. $type = ActivityObject::NOTE;
  1143. if (Event::handle('StartActivityDefaultObjectType', array(&$this, &$xs, &$type))) {
  1144. $xs->element('activity:object-type', null, $type);
  1145. Event::handle('EndActivityDefaultObjectType', array(&$this, &$xs, $type));
  1146. }
  1147. // Since we usually use the entry itself as an object, we don't have an explicit
  1148. // object. Some extensions may want to add them (for photo, event, music, etc.).
  1149. $objects = array();
  1150. if (Event::handle('StartActivityObjects', array(&$this, &$xs, &$objects))) {
  1151. foreach ($objects as $object) {
  1152. $xs->raw($object->asString());
  1153. }
  1154. Event::handle('EndActivityObjects', array(&$this, &$xs, $objects));
  1155. }
  1156. $noticeInfoAttr = array('local_id' => $this->id); // local notice ID (useful to clients for ordering)
  1157. $ns = $this->getSource();
  1158. if (!empty($ns)) {
  1159. $noticeInfoAttr['source'] = $ns->code;
  1160. if (!empty($ns->url)) {
  1161. $noticeInfoAttr['source_link'] = $ns->url;
  1162. if (!empty($ns->name)) {
  1163. $noticeInfoAttr['source'] = '<a href="'
  1164. . htmlspecialchars($ns->url)
  1165. . '" rel="nofollow">'
  1166. . htmlspecialchars($ns->name)
  1167. . '</a>';
  1168. }
  1169. }
  1170. }
  1171. if (!empty($cur)) {
  1172. $noticeInfoAttr['favorite'] = ($cur->hasFave($this)) ? "true" : "false";
  1173. $profile = $cur->getProfile();
  1174. $noticeInfoAttr['repeated'] = ($profile->hasRepeated($this->id)) ? "true" : "false";
  1175. }
  1176. if (!empty($this->repeat_of)) {
  1177. $noticeInfoAttr['repeat_of'] = $this->repeat_of;
  1178. }
  1179. if (Event::handle('StartActivityNoticeInfo', array(&$this, &$xs, &$noticeInfoAttr))) {
  1180. $xs->element('statusnet:notice_info', $noticeInfoAttr, null);
  1181. Event::handle('EndActivityNoticeInfo', array(&$this, &$xs, $noticeInfoAttr));
  1182. }
  1183. $replyNotice = null;
  1184. if ($this->reply_to) {
  1185. $replyNotice = Notice::staticGet('id', $this->reply_to);
  1186. }
  1187. if (Event::handle('StartActivityInReplyTo', array(&$this, &$xs, &$replyNotice))) {
  1188. if (!empty($replyNotice)) {
  1189. $xs->element('link', array('rel' => 'related',
  1190. 'href' => $replyNotice->bestUrl()));
  1191. $xs->element('thr:in-reply-to',
  1192. array('ref' => $replyNotice->uri,
  1193. 'href' => $replyNotice->bestUrl()));
  1194. Event::handle('EndActivityInReplyTo', array(&$this, &$xs, $replyNotice));
  1195. }
  1196. }
  1197. $conv = null;
  1198. if (!empty($this->conversation)) {
  1199. $conv = Conversation::staticGet('id', $this->conversation);
  1200. }
  1201. if (Event::handle('StartActivityConversation', array(&$this, &$xs, &$conv))) {
  1202. if (!empty($conv)) {
  1203. $xs->element('link', array('rel' => 'ostatus:conversation',
  1204. 'href' => $conv->uri));
  1205. }
  1206. Event::handle('EndActivityConversation', array(&$this, &$xs, $conv));
  1207. }
  1208. $replyProfiles = array();
  1209. $reply_ids = $this->getReplies();
  1210. foreach ($reply

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