PageRenderTime 67ms CodeModel.GetById 34ms RepoModel.GetById 0ms app.codeStats 0ms

/classes/Notice.php

https://bitbucket.org/petris/statusnet
PHP | 2739 lines | 1696 code | 526 blank | 517 comment | 328 complexity | 9e5dd8800cc86b00c2c9c557e340713d MD5 | raw file
Possible License(s): MIT, MPL-2.0-no-copyleft-exception, BSD-3-Clause, AGPL-3.0, GPL-2.0, LGPL-2.1

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-2011 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', CachingNoticeStream::CACHE_WINDOW);
  45. define('MAX_BOXCARS', 128);
  46. class Notice extends Managed_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. public $verb; // varchar(255)
  69. public $object_type; // varchar(255)
  70. public $scope; // int(4)
  71. /* Static get */
  72. function staticGet($k,$v=NULL)
  73. {
  74. return Memcached_DataObject::staticGet('Notice',$k,$v);
  75. }
  76. /* the code above is auto generated do not remove the tag below */
  77. ###END_AUTOCODE
  78. public static function schemaDef()
  79. {
  80. $def = array(
  81. 'fields' => array(
  82. 'id' => array('type' => 'serial', 'not null' => true, 'description' => 'unique identifier'),
  83. 'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'who made the update'),
  84. 'uri' => array('type' => 'varchar', 'length' => 255, 'description' => 'universally unique identifier, usually a tag URI'),
  85. 'content' => array('type' => 'text', 'description' => 'update content', 'collate' => 'utf8_general_ci'),
  86. 'rendered' => array('type' => 'text', 'description' => 'HTML version of the content'),
  87. 'url' => array('type' => 'varchar', 'length' => 255, 'description' => 'URL of any attachment (image, video, bookmark, whatever)'),
  88. 'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
  89. 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
  90. 'reply_to' => array('type' => 'int', 'description' => 'notice replied to (usually a guess)'),
  91. 'is_local' => array('type' => 'int', 'size' => 'tiny', 'default' => 0, 'description' => 'notice was generated by a user'),
  92. 'source' => array('type' => 'varchar', 'length' => 32, 'description' => 'source of comment, like "web", "im", or "clientname"'),
  93. 'conversation' => array('type' => 'int', 'description' => 'id of root notice in this conversation'),
  94. 'lat' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'latitude'),
  95. 'lon' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'longitude'),
  96. 'location_id' => array('type' => 'int', 'description' => 'location id if possible'),
  97. 'location_ns' => array('type' => 'int', 'description' => 'namespace for location'),
  98. 'repeat_of' => array('type' => 'int', 'description' => 'notice this is a repeat of'),
  99. 'object_type' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams object type', 'default' => 'http://activitystrea.ms/schema/1.0/note'),
  100. 'verb' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams verb', 'default' => 'http://activitystrea.ms/schema/1.0/post'),
  101. 'scope' => array('type' => 'int',
  102. 'description' => 'bit map for distribution scope; 0 = everywhere; 1 = this server only; 2 = addressees; 4 = followers; null = default'),
  103. ),
  104. 'primary key' => array('id'),
  105. 'unique keys' => array(
  106. 'notice_uri_key' => array('uri'),
  107. ),
  108. 'foreign keys' => array(
  109. 'notice_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
  110. 'notice_reply_to_fkey' => array('notice', array('reply_to' => 'id')),
  111. 'notice_conversation_fkey' => array('conversation', array('conversation' => 'id')), # note... used to refer to notice.id
  112. 'notice_repeat_of_fkey' => array('notice', array('repeat_of' => 'id')), # @fixme: what about repeats of deleted notices?
  113. ),
  114. 'indexes' => array(
  115. 'notice_created_id_is_local_idx' => array('created', 'id', 'is_local'),
  116. 'notice_profile_id_idx' => array('profile_id', 'created', 'id'),
  117. 'notice_repeat_of_created_id_idx' => array('repeat_of', 'created', 'id'),
  118. 'notice_conversation_created_id_idx' => array('conversation', 'created', 'id'),
  119. 'notice_replyto_idx' => array('reply_to')
  120. )
  121. );
  122. if (common_config('search', 'type') == 'fulltext') {
  123. $def['fulltext indexes'] = array('content' => array('content'));
  124. }
  125. return $def;
  126. }
  127. function multiGet($kc, $kvs, $skipNulls=true)
  128. {
  129. return Memcached_DataObject::multiGet('Notice', $kc, $kvs, $skipNulls);
  130. }
  131. /* Notice types */
  132. const LOCAL_PUBLIC = 1;
  133. const REMOTE = 0;
  134. const LOCAL_NONPUBLIC = -1;
  135. const GATEWAY = -2;
  136. const PUBLIC_SCOPE = 0; // Useful fake constant
  137. const SITE_SCOPE = 1;
  138. const ADDRESSEE_SCOPE = 2;
  139. const GROUP_SCOPE = 4;
  140. const FOLLOWER_SCOPE = 8;
  141. protected $_profile = -1;
  142. function getProfile()
  143. {
  144. if (is_int($this->_profile) && $this->_profile == -1) {
  145. $this->_setProfile(Profile::staticGet('id', $this->profile_id));
  146. if (empty($this->_profile)) {
  147. // TRANS: Server exception thrown when a user profile for a notice cannot be found.
  148. // TRANS: %1$d is a profile ID (number), %2$d is a notice ID (number).
  149. throw new ServerException(sprintf(_('No such profile (%1$d) for notice (%2$d).'), $this->profile_id, $this->id));
  150. }
  151. }
  152. return $this->_profile;
  153. }
  154. function _setProfile($profile)
  155. {
  156. $this->_profile = $profile;
  157. }
  158. function delete()
  159. {
  160. // For auditing purposes, save a record that the notice
  161. // was deleted.
  162. // @fixme we have some cases where things get re-run and so the
  163. // insert fails.
  164. $deleted = Deleted_notice::staticGet('id', $this->id);
  165. if (!$deleted) {
  166. $deleted = Deleted_notice::staticGet('uri', $this->uri);
  167. }
  168. if (!$deleted) {
  169. $deleted = new Deleted_notice();
  170. $deleted->id = $this->id;
  171. $deleted->profile_id = $this->profile_id;
  172. $deleted->uri = $this->uri;
  173. $deleted->created = $this->created;
  174. $deleted->deleted = common_sql_now();
  175. $deleted->insert();
  176. }
  177. if (Event::handle('NoticeDeleteRelated', array($this))) {
  178. // Clear related records
  179. $this->clearReplies();
  180. $this->clearRepeats();
  181. $this->clearFaves();
  182. $this->clearTags();
  183. $this->clearGroupInboxes();
  184. $this->clearFiles();
  185. // NOTE: we don't clear inboxes
  186. // NOTE: we don't clear queue items
  187. }
  188. $result = parent::delete();
  189. $this->blowOnDelete();
  190. return $result;
  191. }
  192. /**
  193. * Extract #hashtags from this notice's content and save them to the database.
  194. */
  195. function saveTags()
  196. {
  197. /* extract all #hastags */
  198. $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/u', strtolower($this->content), $match);
  199. if (!$count) {
  200. return true;
  201. }
  202. /* Add them to the database */
  203. return $this->saveKnownTags($match[1]);
  204. }
  205. /**
  206. * Record the given set of hash tags in the db for this notice.
  207. * Given tag strings will be normalized and checked for dupes.
  208. */
  209. function saveKnownTags($hashtags)
  210. {
  211. //turn each into their canonical tag
  212. //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
  213. for($i=0; $i<count($hashtags); $i++) {
  214. /* elide characters we don't want in the tag */
  215. $hashtags[$i] = common_canonical_tag($hashtags[$i]);
  216. }
  217. foreach(array_unique($hashtags) as $hashtag) {
  218. $this->saveTag($hashtag);
  219. self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
  220. }
  221. return true;
  222. }
  223. /**
  224. * Record a single hash tag as associated with this notice.
  225. * Tag format and uniqueness must be validated by caller.
  226. */
  227. function saveTag($hashtag)
  228. {
  229. $tag = new Notice_tag();
  230. $tag->notice_id = $this->id;
  231. $tag->tag = $hashtag;
  232. $tag->created = $this->created;
  233. $id = $tag->insert();
  234. if (!$id) {
  235. // TRANS: Server exception. %s are the error details.
  236. throw new ServerException(sprintf(_('Database error inserting hashtag: %s.'),
  237. $last_error->message));
  238. return;
  239. }
  240. // if it's saved, blow its cache
  241. $tag->blowCache(false);
  242. }
  243. /**
  244. * Save a new notice and push it out to subscribers' inboxes.
  245. * Poster's permissions are checked before sending.
  246. *
  247. * @param int $profile_id Profile ID of the poster
  248. * @param string $content source message text; links may be shortened
  249. * per current user's preference
  250. * @param string $source source key ('web', 'api', etc)
  251. * @param array $options Associative array of optional properties:
  252. * string 'created' timestamp of notice; defaults to now
  253. * int 'is_local' source/gateway ID, one of:
  254. * Notice::LOCAL_PUBLIC - Local, ok to appear in public timeline
  255. * Notice::REMOTE - Sent from a remote service;
  256. * hide from public timeline but show in
  257. * local "and friends" timelines
  258. * Notice::LOCAL_NONPUBLIC - Local, but hide from public timeline
  259. * Notice::GATEWAY - From another non-OStatus service;
  260. * will not appear in public views
  261. * float 'lat' decimal latitude for geolocation
  262. * float 'lon' decimal longitude for geolocation
  263. * int 'location_id' geoname identifier
  264. * int 'location_ns' geoname namespace to interpret location_id
  265. * int 'reply_to'; notice ID this is a reply to
  266. * int 'repeat_of'; notice ID this is a repeat of
  267. * string 'uri' unique ID for notice; defaults to local notice URL
  268. * string 'url' permalink to notice; defaults to local notice URL
  269. * string 'rendered' rendered HTML version of content
  270. * array 'replies' list of profile URIs for reply delivery in
  271. * place of extracting @-replies from content.
  272. * array 'groups' list of group IDs to deliver to, in place of
  273. * extracting ! tags from content
  274. * array 'tags' list of hashtag strings to save with the notice
  275. * in place of extracting # tags from content
  276. * array 'urls' list of attached/referred URLs to save with the
  277. * notice in place of extracting links from content
  278. * boolean 'distribute' whether to distribute the notice, default true
  279. * string 'object_type' URL of the associated object type (default ActivityObject::NOTE)
  280. * string 'verb' URL of the associated verb (default ActivityVerb::POST)
  281. * int 'scope' Scope bitmask; default to SITE_SCOPE on private sites, 0 otherwise
  282. *
  283. * @fixme tag override
  284. *
  285. * @return Notice
  286. * @throws ClientException
  287. */
  288. static function saveNew($profile_id, $content, $source, $options=null) {
  289. $defaults = array('uri' => null,
  290. 'url' => null,
  291. 'reply_to' => null,
  292. 'repeat_of' => null,
  293. 'scope' => null,
  294. 'distribute' => true,
  295. 'object_type' => null,
  296. 'verb' => null);
  297. if (!empty($options) && is_array($options)) {
  298. $options = array_merge($defaults, $options);
  299. extract($options);
  300. } else {
  301. extract($defaults);
  302. }
  303. if (!isset($is_local)) {
  304. $is_local = Notice::LOCAL_PUBLIC;
  305. }
  306. $profile = Profile::staticGet('id', $profile_id);
  307. $user = User::staticGet('id', $profile_id);
  308. if ($user) {
  309. // Use the local user's shortening preferences, if applicable.
  310. $final = $user->shortenLinks($content);
  311. } else {
  312. $final = common_shorten_links($content);
  313. }
  314. if (Notice::contentTooLong($final)) {
  315. // TRANS: Client exception thrown if a notice contains too many characters.
  316. throw new ClientException(_('Problem saving notice. Too long.'));
  317. }
  318. if (empty($profile)) {
  319. // TRANS: Client exception thrown when trying to save a notice for an unknown user.
  320. throw new ClientException(_('Problem saving notice. Unknown user.'));
  321. }
  322. if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
  323. common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
  324. // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
  325. throw new ClientException(_('Too many notices too fast; take a breather '.
  326. 'and post again in a few minutes.'));
  327. }
  328. if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
  329. common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
  330. // TRANS: Client exception thrown when a user tries to post too many duplicate notices in a given time frame.
  331. throw new ClientException(_('Too many duplicate messages too quickly;'.
  332. ' take a breather and post again in a few minutes.'));
  333. }
  334. if (!$profile->hasRight(Right::NEWNOTICE)) {
  335. common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $profile->nickname);
  336. // TRANS: Client exception thrown when a user tries to post while being banned.
  337. throw new ClientException(_('You are banned from posting notices on this site.'), 403);
  338. }
  339. $notice = new Notice();
  340. $notice->profile_id = $profile_id;
  341. $autosource = common_config('public', 'autosource');
  342. // Sandboxed are non-false, but not 1, either
  343. if (!$profile->hasRight(Right::PUBLICNOTICE) ||
  344. ($source && $autosource && in_array($source, $autosource))) {
  345. $notice->is_local = Notice::LOCAL_NONPUBLIC;
  346. } else {
  347. $notice->is_local = $is_local;
  348. }
  349. if (!empty($created)) {
  350. $notice->created = $created;
  351. } else {
  352. $notice->created = common_sql_now();
  353. }
  354. $notice->content = $final;
  355. $notice->source = $source;
  356. $notice->uri = $uri;
  357. $notice->url = $url;
  358. // Get the groups here so we can figure out replies and such
  359. if (!isset($groups)) {
  360. $groups = self::groupsFromText($notice->content, $profile);
  361. }
  362. $reply = null;
  363. // Handle repeat case
  364. if (isset($repeat_of)) {
  365. // Check for a private one
  366. $repeat = Notice::staticGet('id', $repeat_of);
  367. if (empty($repeat)) {
  368. // TRANS: Client exception thrown in notice when trying to repeat a missing or deleted notice.
  369. throw new ClientException(_('Cannot repeat; original notice is missing or deleted.'));
  370. }
  371. if ($profile->id == $repeat->profile_id) {
  372. // TRANS: Client error displayed when trying to repeat an own notice.
  373. throw new ClientException(_('You cannot repeat your own notice.'));
  374. }
  375. if ($repeat->scope != Notice::SITE_SCOPE &&
  376. $repeat->scope != Notice::PUBLIC_SCOPE) {
  377. // TRANS: Client error displayed when trying to repeat a non-public notice.
  378. throw new ClientException(_('Cannot repeat a private notice.'), 403);
  379. }
  380. if (!$repeat->inScope($profile)) {
  381. // The generic checks above should cover this, but let's be sure!
  382. // TRANS: Client error displayed when trying to repeat a notice you cannot access.
  383. throw new ClientException(_('Cannot repeat a notice you cannot read.'), 403);
  384. }
  385. if ($profile->hasRepeated($repeat->id)) {
  386. // TRANS: Client error displayed when trying to repeat an already repeated notice.
  387. throw new ClientException(_('You already repeated that notice.'));
  388. }
  389. $notice->repeat_of = $repeat_of;
  390. } else {
  391. $reply = self::getReplyTo($reply_to, $profile_id, $source, $final);
  392. if (!empty($reply)) {
  393. if (!$reply->inScope($profile)) {
  394. // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
  395. // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
  396. throw new ClientException(sprintf(_('%1$s has no access to notice %2$d.'),
  397. $profile->nickname, $reply->id), 403);
  398. }
  399. $notice->reply_to = $reply->id;
  400. $notice->conversation = $reply->conversation;
  401. // If the original is private to a group, and notice has no group specified,
  402. // make it to the same group(s)
  403. if (empty($groups) && ($reply->scope | Notice::GROUP_SCOPE)) {
  404. $groups = array();
  405. $replyGroups = $reply->getGroups();
  406. foreach ($replyGroups as $group) {
  407. if ($profile->isMember($group)) {
  408. $groups[] = $group->id;
  409. }
  410. }
  411. }
  412. // Scope set below
  413. }
  414. }
  415. if (!empty($lat) && !empty($lon)) {
  416. $notice->lat = $lat;
  417. $notice->lon = $lon;
  418. }
  419. if (!empty($location_ns) && !empty($location_id)) {
  420. $notice->location_id = $location_id;
  421. $notice->location_ns = $location_ns;
  422. }
  423. if (!empty($rendered)) {
  424. $notice->rendered = $rendered;
  425. } else {
  426. $notice->rendered = common_render_content($final, $notice);
  427. }
  428. if (empty($verb)) {
  429. if (!empty($notice->repeat_of)) {
  430. $notice->verb = ActivityVerb::SHARE;
  431. $notice->object_type = ActivityObject::ACTIVITY;
  432. } else {
  433. $notice->verb = ActivityVerb::POST;
  434. }
  435. } else {
  436. $notice->verb = $verb;
  437. }
  438. if (empty($object_type)) {
  439. $notice->object_type = (empty($notice->reply_to)) ? ActivityObject::NOTE : ActivityObject::COMMENT;
  440. } else {
  441. $notice->object_type = $object_type;
  442. }
  443. if (is_null($scope)) { // 0 is a valid value
  444. if (!empty($reply)) {
  445. $notice->scope = $reply->scope;
  446. } else {
  447. $notice->scope = self::defaultScope();
  448. }
  449. } else {
  450. $notice->scope = $scope;
  451. }
  452. // For private streams
  453. $user = $profile->getUser();
  454. if (!empty($user)) {
  455. if ($user->private_stream &&
  456. ($notice->scope == Notice::PUBLIC_SCOPE ||
  457. $notice->scope == Notice::SITE_SCOPE)) {
  458. $notice->scope |= Notice::FOLLOWER_SCOPE;
  459. }
  460. }
  461. // Force the scope for private groups
  462. foreach ($groups as $groupId) {
  463. $group = User_group::staticGet('id', $groupId);
  464. if (!empty($group)) {
  465. if ($group->force_scope) {
  466. $notice->scope |= Notice::GROUP_SCOPE;
  467. break;
  468. }
  469. }
  470. }
  471. if (Event::handle('StartNoticeSave', array(&$notice))) {
  472. // XXX: some of these functions write to the DB
  473. $id = $notice->insert();
  474. if (!$id) {
  475. common_log_db_error($notice, 'INSERT', __FILE__);
  476. // TRANS: Server exception thrown when a notice cannot be saved.
  477. throw new ServerException(_('Problem saving notice.'));
  478. }
  479. // Update ID-dependent columns: URI, conversation
  480. $orig = clone($notice);
  481. $changed = false;
  482. if (empty($uri)) {
  483. $notice->uri = common_notice_uri($notice);
  484. $changed = true;
  485. }
  486. // If it's not part of a conversation, it's
  487. // the beginning of a new conversation.
  488. if (empty($notice->conversation)) {
  489. $conv = Conversation::create();
  490. $notice->conversation = $conv->id;
  491. $changed = true;
  492. }
  493. if ($changed) {
  494. if (!$notice->update($orig)) {
  495. common_log_db_error($notice, 'UPDATE', __FILE__);
  496. // TRANS: Server exception thrown when a notice cannot be updated.
  497. throw new ServerException(_('Problem saving notice.'));
  498. }
  499. }
  500. }
  501. // Clear the cache for subscribed users, so they'll update at next request
  502. // XXX: someone clever could prepend instead of clearing the cache
  503. $notice->blowOnInsert();
  504. // Save per-notice metadata...
  505. if (isset($replies)) {
  506. $notice->saveKnownReplies($replies);
  507. } else {
  508. $notice->saveReplies();
  509. }
  510. if (isset($tags)) {
  511. $notice->saveKnownTags($tags);
  512. } else {
  513. $notice->saveTags();
  514. }
  515. // Note: groups may save tags, so must be run after tags are saved
  516. // to avoid errors on duplicates.
  517. // Note: groups should always be set.
  518. $notice->saveKnownGroups($groups);
  519. if (isset($urls)) {
  520. $notice->saveKnownUrls($urls);
  521. } else {
  522. $notice->saveUrls();
  523. }
  524. if ($distribute) {
  525. // Prepare inbox delivery, may be queued to background.
  526. $notice->distribute();
  527. }
  528. return $notice;
  529. }
  530. function blowOnInsert($conversation = false)
  531. {
  532. $this->blowStream('profile:notice_ids:%d', $this->profile_id);
  533. if ($this->isPublic()) {
  534. $this->blowStream('public');
  535. }
  536. self::blow('notice:list-ids:conversation:%s', $this->conversation);
  537. self::blow('conversation:notice_count:%d', $this->conversation);
  538. if (!empty($this->repeat_of)) {
  539. // XXX: we should probably only use one of these
  540. $this->blowStream('notice:repeats:%d', $this->repeat_of);
  541. self::blow('notice:list-ids:repeat_of:%d', $this->repeat_of);
  542. }
  543. $original = Notice::staticGet('id', $this->repeat_of);
  544. if (!empty($original)) {
  545. $originalUser = User::staticGet('id', $original->profile_id);
  546. if (!empty($originalUser)) {
  547. $this->blowStream('user:repeats_of_me:%d', $originalUser->id);
  548. }
  549. }
  550. $profile = Profile::staticGet($this->profile_id);
  551. if (!empty($profile)) {
  552. $profile->blowNoticeCount();
  553. }
  554. $ptags = $this->getProfileTags();
  555. foreach ($ptags as $ptag) {
  556. $ptag->blowNoticeStreamCache();
  557. }
  558. }
  559. /**
  560. * Clear cache entries related to this notice at delete time.
  561. * Necessary to avoid breaking paging on public, profile timelines.
  562. */
  563. function blowOnDelete()
  564. {
  565. $this->blowOnInsert();
  566. self::blow('profile:notice_ids:%d;last', $this->profile_id);
  567. if ($this->isPublic()) {
  568. self::blow('public;last');
  569. }
  570. self::blow('fave:by_notice', $this->id);
  571. if ($this->conversation) {
  572. // In case we're the first, will need to calc a new root.
  573. self::blow('notice:conversation_root:%d', $this->conversation);
  574. }
  575. $ptags = $this->getProfileTags();
  576. foreach ($ptags as $ptag) {
  577. $ptag->blowNoticeStreamCache(true);
  578. }
  579. }
  580. function blowStream()
  581. {
  582. $c = self::memcache();
  583. if (empty($c)) {
  584. return false;
  585. }
  586. $args = func_get_args();
  587. $format = array_shift($args);
  588. $keyPart = vsprintf($format, $args);
  589. $cacheKey = Cache::key($keyPart);
  590. $c->delete($cacheKey);
  591. // delete the "last" stream, too, if this notice is
  592. // older than the top of that stream
  593. $lastKey = $cacheKey.';last';
  594. $lastStr = $c->get($lastKey);
  595. if ($lastStr !== false) {
  596. $window = explode(',', $lastStr);
  597. $lastID = $window[0];
  598. $lastNotice = Notice::staticGet('id', $lastID);
  599. if (empty($lastNotice) // just weird
  600. || strtotime($lastNotice->created) >= strtotime($this->created)) {
  601. $c->delete($lastKey);
  602. }
  603. }
  604. }
  605. /** save all urls in the notice to the db
  606. *
  607. * follow redirects and save all available file information
  608. * (mimetype, date, size, oembed, etc.)
  609. *
  610. * @return void
  611. */
  612. function saveUrls() {
  613. if (common_config('attachments', 'process_links')) {
  614. common_replace_urls_callback($this->content, array($this, 'saveUrl'), $this->id);
  615. }
  616. }
  617. /**
  618. * Save the given URLs as related links/attachments to the db
  619. *
  620. * follow redirects and save all available file information
  621. * (mimetype, date, size, oembed, etc.)
  622. *
  623. * @return void
  624. */
  625. function saveKnownUrls($urls)
  626. {
  627. if (common_config('attachments', 'process_links')) {
  628. // @fixme validation?
  629. foreach (array_unique($urls) as $url) {
  630. File::processNew($url, $this->id);
  631. }
  632. }
  633. }
  634. /**
  635. * @private callback
  636. */
  637. function saveUrl($url, $notice_id) {
  638. File::processNew($url, $notice_id);
  639. }
  640. static function checkDupes($profile_id, $content) {
  641. $profile = Profile::staticGet($profile_id);
  642. if (empty($profile)) {
  643. return false;
  644. }
  645. $notice = $profile->getNotices(0, CachingNoticeStream::CACHE_WINDOW);
  646. if (!empty($notice)) {
  647. $last = 0;
  648. while ($notice->fetch()) {
  649. if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
  650. return true;
  651. } else if ($notice->content == $content) {
  652. return false;
  653. }
  654. }
  655. }
  656. // If we get here, oldest item in cache window is not
  657. // old enough for dupe limit; do direct check against DB
  658. $notice = new Notice();
  659. $notice->profile_id = $profile_id;
  660. $notice->content = $content;
  661. $threshold = common_sql_date(time() - common_config('site', 'dupelimit'));
  662. $notice->whereAdd(sprintf("created > '%s'", $notice->escape($threshold)));
  663. $cnt = $notice->count();
  664. return ($cnt == 0);
  665. }
  666. static function checkEditThrottle($profile_id) {
  667. $profile = Profile::staticGet($profile_id);
  668. if (empty($profile)) {
  669. return false;
  670. }
  671. // Get the Nth notice
  672. $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
  673. if ($notice && $notice->fetch()) {
  674. // If the Nth notice was posted less than timespan seconds ago
  675. if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
  676. // Then we throttle
  677. return false;
  678. }
  679. }
  680. // Either not N notices in the stream, OR the Nth was not posted within timespan seconds
  681. return true;
  682. }
  683. protected $_attachments = -1;
  684. function attachments() {
  685. if ($this->_attachments != -1) {
  686. return $this->_attachments;
  687. }
  688. $f2ps = Memcached_DataObject::listGet('File_to_post', 'post_id', array($this->id));
  689. $ids = array();
  690. foreach ($f2ps[$this->id] as $f2p) {
  691. $ids[] = $f2p->file_id;
  692. }
  693. $files = Memcached_DataObject::multiGet('File', 'id', $ids);
  694. $this->_attachments = $files->fetchAll();
  695. return $this->_attachments;
  696. }
  697. function _setAttachments($attachments)
  698. {
  699. $this->_attachments = $attachments;
  700. }
  701. function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0)
  702. {
  703. $stream = new PublicNoticeStream();
  704. return $stream->getNotices($offset, $limit, $since_id, $max_id);
  705. }
  706. function conversationStream($id, $offset=0, $limit=20, $since_id=0, $max_id=0)
  707. {
  708. $stream = new ConversationNoticeStream($id);
  709. return $stream->getNotices($offset, $limit, $since_id, $max_id);
  710. }
  711. /**
  712. * Is this notice part of an active conversation?
  713. *
  714. * @return boolean true if other messages exist in the same
  715. * conversation, false if this is the only one
  716. */
  717. function hasConversation()
  718. {
  719. if (!empty($this->conversation)) {
  720. $conversation = Notice::conversationStream(
  721. $this->conversation,
  722. 1,
  723. 1
  724. );
  725. if ($conversation->N > 0) {
  726. return true;
  727. }
  728. }
  729. return false;
  730. }
  731. /**
  732. * Grab the earliest notice from this conversation.
  733. *
  734. * @return Notice or null
  735. */
  736. function conversationRoot($profile=-1)
  737. {
  738. // XXX: can this happen?
  739. if (empty($this->conversation)) {
  740. return null;
  741. }
  742. // Get the current profile if not specified
  743. if (is_int($profile) && $profile == -1) {
  744. $profile = Profile::current();
  745. }
  746. // If this notice is out of scope, no root for you!
  747. if (!$this->inScope($profile)) {
  748. return null;
  749. }
  750. // If this isn't a reply to anything, then it's its own
  751. // root.
  752. if (empty($this->reply_to)) {
  753. return $this;
  754. }
  755. if (is_null($profile)) {
  756. $keypart = sprintf('notice:conversation_root:%d:null', $this->id);
  757. } else {
  758. $keypart = sprintf('notice:conversation_root:%d:%d',
  759. $this->id,
  760. $profile->id);
  761. }
  762. $root = self::cacheGet($keypart);
  763. if ($root !== false && $root->inScope($profile)) {
  764. return $root;
  765. } else {
  766. $last = $this;
  767. do {
  768. $parent = $last->getOriginal();
  769. if (!empty($parent) && $parent->inScope($profile)) {
  770. $last = $parent;
  771. continue;
  772. } else {
  773. $root = $last;
  774. break;
  775. }
  776. } while (!empty($parent));
  777. self::cacheSet($keypart, $root);
  778. }
  779. return $root;
  780. }
  781. /**
  782. * Pull up a full list of local recipients who will be getting
  783. * this notice in their inbox. Results will be cached, so don't
  784. * change the input data wily-nilly!
  785. *
  786. * @param array $groups optional list of Group objects;
  787. * if left empty, will be loaded from group_inbox records
  788. * @param array $recipient optional list of reply profile ids
  789. * if left empty, will be loaded from reply records
  790. * @return array associating recipient user IDs with an inbox source constant
  791. */
  792. function whoGets($groups=null, $recipients=null)
  793. {
  794. $c = self::memcache();
  795. if (!empty($c)) {
  796. $ni = $c->get(Cache::key('notice:who_gets:'.$this->id));
  797. if ($ni !== false) {
  798. return $ni;
  799. }
  800. }
  801. if (is_null($groups)) {
  802. $groups = $this->getGroups();
  803. }
  804. if (is_null($recipients)) {
  805. $recipients = $this->getReplies();
  806. }
  807. $users = $this->getSubscribedUsers();
  808. $ptags = $this->getProfileTags();
  809. // FIXME: kind of ignoring 'transitional'...
  810. // we'll probably stop supporting inboxless mode
  811. // in 0.9.x
  812. $ni = array();
  813. // Give plugins a chance to add folks in at start...
  814. if (Event::handle('StartNoticeWhoGets', array($this, &$ni))) {
  815. foreach ($users as $id) {
  816. $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
  817. }
  818. foreach ($groups as $group) {
  819. $users = $group->getUserMembers();
  820. foreach ($users as $id) {
  821. if (!array_key_exists($id, $ni)) {
  822. $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
  823. }
  824. }
  825. }
  826. foreach ($ptags as $ptag) {
  827. $users = $ptag->getUserSubscribers();
  828. foreach ($users as $id) {
  829. if (!array_key_exists($id, $ni)) {
  830. $ni[$id] = NOTICE_INBOX_SOURCE_PROFILE_TAG;
  831. }
  832. }
  833. }
  834. foreach ($recipients as $recipient) {
  835. if (!array_key_exists($recipient, $ni)) {
  836. $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
  837. }
  838. }
  839. // Exclude any deleted, non-local, or blocking recipients.
  840. $profile = $this->getProfile();
  841. $originalProfile = null;
  842. if ($this->repeat_of) {
  843. // Check blocks against the original notice's poster as well.
  844. $original = Notice::staticGet('id', $this->repeat_of);
  845. if ($original) {
  846. $originalProfile = $original->getProfile();
  847. }
  848. }
  849. foreach ($ni as $id => $source) {
  850. $user = User::staticGet('id', $id);
  851. if (empty($user) || $user->hasBlocked($profile) ||
  852. ($originalProfile && $user->hasBlocked($originalProfile))) {
  853. unset($ni[$id]);
  854. }
  855. }
  856. // Give plugins a chance to filter out...
  857. Event::handle('EndNoticeWhoGets', array($this, &$ni));
  858. }
  859. if (!empty($c)) {
  860. // XXX: pack this data better
  861. $c->set(Cache::key('notice:who_gets:'.$this->id), $ni);
  862. }
  863. return $ni;
  864. }
  865. /**
  866. * Adds this notice to the inboxes of each local user who should receive
  867. * it, based on author subscriptions, group memberships, and @-replies.
  868. *
  869. * Warning: running a second time currently will make items appear
  870. * multiple times in users' inboxes.
  871. *
  872. * @fixme make more robust against errors
  873. * @fixme break up massive deliveries to smaller background tasks
  874. *
  875. * @param array $groups optional list of Group objects;
  876. * if left empty, will be loaded from group_inbox records
  877. * @param array $recipient optional list of reply profile ids
  878. * if left empty, will be loaded from reply records
  879. */
  880. function addToInboxes($groups=null, $recipients=null)
  881. {
  882. $ni = $this->whoGets($groups, $recipients);
  883. $ids = array_keys($ni);
  884. // We remove the author (if they're a local user),
  885. // since we'll have already done this in distribute()
  886. $i = array_search($this->profile_id, $ids);
  887. if ($i !== false) {
  888. unset($ids[$i]);
  889. }
  890. // Bulk insert
  891. Inbox::bulkInsert($this->id, $ids);
  892. return;
  893. }
  894. function getSubscribedUsers()
  895. {
  896. $user = new User();
  897. if(common_config('db','quote_identifiers'))
  898. $user_table = '"user"';
  899. else $user_table = 'user';
  900. $qry =
  901. 'SELECT id ' .
  902. 'FROM '. $user_table .' JOIN subscription '.
  903. 'ON '. $user_table .'.id = subscription.subscriber ' .
  904. 'WHERE subscription.subscribed = %d ';
  905. $user->query(sprintf($qry, $this->profile_id));
  906. $ids = array();
  907. while ($user->fetch()) {
  908. $ids[] = $user->id;
  909. }
  910. $user->free();
  911. return $ids;
  912. }
  913. function getProfileTags()
  914. {
  915. $profile = $this->getProfile();
  916. $list = $profile->getOtherTags($profile);
  917. $ptags = array();
  918. while($list->fetch()) {
  919. $ptags[] = clone($list);
  920. }
  921. return $ptags;
  922. }
  923. /**
  924. * Record this notice to the given group inboxes for delivery.
  925. * Overrides the regular parsing of !group markup.
  926. *
  927. * @param string $group_ids
  928. * @fixme might prefer URIs as identifiers, as for replies?
  929. * best with generalizations on user_group to support
  930. * remote groups better.
  931. */
  932. function saveKnownGroups($group_ids)
  933. {
  934. if (!is_array($group_ids)) {
  935. // TRANS: Server exception thrown when no array is provided to the method saveKnownGroups().
  936. throw new ServerException(_('Bad type provided to saveKnownGroups.'));
  937. }
  938. $groups = array();
  939. foreach (array_unique($group_ids) as $id) {
  940. $group = User_group::staticGet('id', $id);
  941. if ($group) {
  942. common_log(LOG_ERR, "Local delivery to group id $id, $group->nickname");
  943. $result = $this->addToGroupInbox($group);
  944. if (!$result) {
  945. common_log_db_error($gi, 'INSERT', __FILE__);
  946. }
  947. if (common_config('group', 'addtag')) {
  948. // we automatically add a tag for every group name, too
  949. $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($group->nickname),
  950. 'notice_id' => $this->id));
  951. if (is_null($tag)) {
  952. $this->saveTag($group->nickname);
  953. }
  954. }
  955. $groups[] = clone($group);
  956. } else {
  957. common_log(LOG_ERR, "Local delivery to group id $id skipped, doesn't exist");
  958. }
  959. }
  960. return $groups;
  961. }
  962. /**
  963. * Parse !group delivery and record targets into group_inbox.
  964. * @return array of Group objects
  965. */
  966. function saveGroups()
  967. {
  968. // Don't save groups for repeats
  969. if (!empty($this->repeat_of)) {
  970. return array();
  971. }
  972. $profile = $this->getProfile();
  973. $groups = self::groupsFromText($this->content, $profile);
  974. /* Add them to the database */
  975. foreach ($groups as $group) {
  976. /* XXX: remote groups. */
  977. if (empty($group)) {
  978. continue;
  979. }
  980. if ($profile->isMember($group)) {
  981. $result = $this->addToGroupInbox($group);
  982. if (!$result) {
  983. common_log_db_error($gi, 'INSERT', __FILE__);
  984. }
  985. $groups[] = clone($group);
  986. }
  987. }
  988. return $groups;
  989. }
  990. function addToGroupInbox($group)
  991. {
  992. $gi = Group_inbox::pkeyGet(array('group_id' => $group->id,
  993. 'notice_id' => $this->id));
  994. if (empty($gi)) {
  995. $gi = new Group_inbox();
  996. $gi->group_id = $group->id;
  997. $gi->notice_id = $this->id;
  998. $gi->created = $this->created;
  999. $result = $gi->insert();
  1000. if (!$result) {
  1001. common_log_db_error($gi, 'INSERT', __FILE__);
  1002. // TRANS: Server exception thrown when an update for a group inbox fails.
  1003. throw new ServerException(_('Problem saving group inbox.'));
  1004. }
  1005. self::blow('user_group:notice_ids:%d', $gi->group_id);
  1006. }
  1007. return true;
  1008. }
  1009. /**
  1010. * Save reply records indicating that this notice needs to be
  1011. * delivered to the local users with the given URIs.
  1012. *
  1013. * Since this is expected to be used when saving foreign-sourced
  1014. * messages, we won't deliver to any remote targets as that's the
  1015. * source service's responsibility.
  1016. *
  1017. * Mail notifications etc will be handled later.
  1018. *
  1019. * @param array of unique identifier URIs for recipients
  1020. */
  1021. function saveKnownReplies($uris)
  1022. {
  1023. if (empty($uris)) {
  1024. return;
  1025. }
  1026. $sender = Profile::staticGet($this->profile_id);
  1027. foreach (array_unique($uris) as $uri) {
  1028. $profile = Profile::fromURI($uri);
  1029. if (empty($profile)) {
  1030. common_log(LOG_WARNING, "Unable to determine profile for URI '$uri'");
  1031. continue;
  1032. }
  1033. if ($profile->hasBlocked($sender)) {
  1034. common_log(LOG_INFO, "Not saving reply to profile {$profile->id} ($uri) from sender {$sender->id} because of a block.");
  1035. continue;
  1036. }
  1037. $this->saveReply($profile->id);
  1038. self::blow('reply:stream:%d', $profile->id);
  1039. }
  1040. return;
  1041. }
  1042. /**
  1043. * Pull @-replies from this message's content in StatusNet markup format
  1044. * and save reply records indicating that this message needs to be
  1045. * delivered to those users.
  1046. *
  1047. * Mail notifications to local profiles will be sent later.
  1048. *
  1049. * @return array of integer profile IDs
  1050. */
  1051. function saveReplies()
  1052. {
  1053. // Don't save reply data for repeats
  1054. if (!empty($this->repeat_of)) {
  1055. return array();
  1056. }
  1057. $sender = Profile::staticGet($this->profile_id);
  1058. $replied = array();
  1059. // If it's a reply, save for the replied-to author
  1060. if (!empty($this->reply_to)) {
  1061. $original = $this->getOriginal();
  1062. if (!empty($original)) { // that'd be weird
  1063. $author = $original->getProfile();
  1064. if (!empty($author)) {
  1065. $this->saveReply($author->id);
  1066. $replied[$author->id] = 1;
  1067. self::blow('reply:stream:%d', $author->id);
  1068. }
  1069. }
  1070. }
  1071. // @todo ideally this parser information would only
  1072. // be calculated once.
  1073. $mentions = common_find_mentions($this->content, $this);
  1074. // store replied only for first @ (what user/notice what the reply directed,
  1075. // we assume first @ is it)
  1076. foreach ($mentions as $mention) {
  1077. foreach ($mention['mentioned'] as $mentioned) {
  1078. // skip if they're already covered
  1079. if (!empty($replied[$mentioned->id])) {
  1080. continue;
  1081. }
  1082. // Don't save replies from blocked profile to local user
  1083. $mentioned_user = User::staticGet('id', $mentioned->id);
  1084. if (!empty($mentioned_user) && $mentioned_user->hasBlocked($sender)) {
  1085. continue;
  1086. }
  1087. $this->saveReply($mentioned->id);
  1088. $replied[$mentioned->id] = 1;
  1089. self::blow('reply:stream:%d', $mentioned->id);
  1090. }
  1091. }
  1092. $recipientIds = array_keys($replied);
  1093. return $recipientIds;
  1094. }
  1095. function saveReply($profileId)
  1096. {
  1097. $reply = new Reply();
  1098. $reply->notice_id = $this->id;
  1099. $reply->profile_id = $profileId;
  1100. $reply->modified = $this->created;
  1101. $reply->insert();
  1102. return $reply;
  1103. }
  1104. protected $_replies = -1;
  1105. /**
  1106. * Pull the complete list of @-reply targets for this notice.
  1107. *
  1108. * @return array of integer profile ids
  1109. */
  1110. function getReplies()
  1111. {
  1112. if ($this->_replies != -1) {
  1113. return $this->_replies;
  1114. }
  1115. $replyMap = Memcached_DataObject::listGet('Reply', 'notice_id', array($this->id));
  1116. $ids = array();
  1117. foreach ($replyMap[$this->id] as $reply) {
  1118. $ids[] = $reply->profile_id;
  1119. }
  1120. $this->_replies = $ids;
  1121. return $ids;
  1122. }
  1123. function _setReplies($replies)
  1124. {
  1125. $this->_replies = $replies;
  1126. }
  1127. /**
  1128. * Pull the complete list of @-reply targets for this notice.
  1129. *
  1130. * @return array of Profiles
  1131. */
  1132. function getReplyProfiles()
  1133. {
  1134. $ids = $this->getReplies();
  1135. $profiles = Profile::multiGet('id', $ids);
  1136. return $profiles->fetchAll();
  1137. }
  1138. /**
  1139. * Send e-mail notifications to local @-reply targets.
  1140. *
  1141. * Replies must already have been saved; this is expected to be run
  1142. * from the distrib queue handler.
  1143. */
  1144. function sendReplyNotifications()
  1145. {
  1146. // Don't send reply notifications for repeats
  1147. if (!empty($this->repeat_of)) {
  1148. return array();
  1149. }
  1150. $recipientIds = $this->getReplies();
  1151. foreach ($recipientIds as $recipientId) {
  1152. $user = User::staticGet('id', $recipientId);
  1153. if (!empty($user)) {
  1154. mail_notify_attn($user, $this);
  1155. }
  1156. }
  1157. }
  1158. /**
  1159. * Pull list of groups this notice needs to be delivered to,
  1160. * as previously recorded by saveGroups() or saveKnownGroups().
  1161. *
  1162. * @return array of Group objects
  1163. */
  1164. protected $_groups = -1;
  1165. function getGroups()
  1166. {
  1167. // Don't save groups for repeats
  1168. if (!empty($this->repeat_of)) {
  1169. return array();
  1170. }
  1171. if ($this->_groups != -1)
  1172. {
  1173. return $this->_groups;
  1174. }
  1175. $gis = Memcached_DataObject::listGet('Group_inbox', 'notice_id', array($this->id));
  1176. $ids = array();
  1177. foreach ($gis[$this->id] as $gi)
  1178. {
  1179. $ids[] = $gi->group_id;
  1180. }
  1181. $groups = User_group::multiGet('id', $ids);
  1182. $this->_groups = $groups->fetchAll();
  1183. return $this->_groups;
  1184. }
  1185. function _setGroups($groups)
  1186. {
  1187. $this->_groups = $groups;
  1188. }
  1189. /**
  1190. * Convert a notice into an activity for export.
  1191. *…

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