PageRenderTime 61ms CodeModel.GetById 17ms RepoModel.GetById 0ms app.codeStats 1ms

/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
  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. *
  1192. * @param User $cur Current user
  1193. *
  1194. * @return Activity activity object representing this Notice.
  1195. */
  1196. function asActivity($cur)
  1197. {
  1198. $act = self::cacheGet(Cache::codeKey('notice:as-activity:'.$this->id));
  1199. if (!empty($act)) {
  1200. return $act;
  1201. }
  1202. $act = new Activity();
  1203. if (Event::handle('StartNoticeAsActivity', array($this, &$act))) {
  1204. $act->id = $this->uri;
  1205. $act->time = strtotime($this->created);
  1206. $act->link = $this->bestUrl();
  1207. $act->content = common_xml_safe_str($this->rendered);
  1208. $act->title = common_xml_safe_str($this->content);
  1209. $profile = $this->getProfile();
  1210. $act->actor = ActivityObject::fromProfile($profile);
  1211. $act->actor->extra[] = $profile->profileInfo($cur);
  1212. $act->verb = $this->verb;
  1213. if ($this->repeat_of) {
  1214. $repeated = Notice::staticGet('id', $this->repeat_of);
  1215. if (!empty($repeated)) {
  1216. $act->objects[] = $repeated->asActivity($cur);
  1217. }
  1218. } else {
  1219. $act->objects[] = ActivityObject::fromNotice($this);
  1220. }
  1221. // XXX: should this be handled by default processing for object entry?
  1222. // Categories
  1223. $tags = $this->getTags();
  1224. foreach ($tags as $tag) {
  1225. $cat = new AtomCategory();
  1226. $cat->term = $tag;
  1227. $act->categories[] = $cat;
  1228. }
  1229. // Enclosures
  1230. // XXX: use Atom Media and/or File activity objects instead
  1231. $attachments = $this->attachments();
  1232. foreach ($attachments as $attachment) {
  1233. $enclosure = $attachment->getEnclosure();
  1234. if ($enclosure) {
  1235. $act->enclosures[] = $enclosure;
  1236. }
  1237. }
  1238. $ctx = new ActivityContext();
  1239. if (!empty($this->reply_to)) {
  1240. $reply = Notice::staticGet('id', $this->reply_to);
  1241. if (!empty($reply)) {
  1242. $ctx->replyToID = $reply->uri;
  1243. $ctx->replyToUrl = $reply->bestUrl();
  1244. }
  1245. }
  1246. $ctx->location = $this->getLocation();
  1247. $conv = null;
  1248. if (!empty($this->conversation)) {
  1249. $conv = Conversation::staticGet('id', $this->conversation);
  1250. if (!empty($conv)) {
  1251. $ctx->conversation = $conv->uri;
  1252. }
  1253. }
  1254. $reply_ids = $this->getReplies();
  1255. foreach ($reply_ids as $id) {
  1256. $rprofile = Profile::staticGet('id', $id);
  1257. if (!empty($rprofile)) {
  1258. $ctx->attention[] = $rprofile->getUri();
  1259. }
  1260. }
  1261. $groups = $this->getGroups();
  1262. foreach ($groups as $group) {
  1263. $ctx->attention[] = $group->getUri();
  1264. }
  1265. // XXX: deprecated; use ActivityVerb::SHARE instead
  1266. $repeat = null;
  1267. if (!empty($this->repeat_of)) {
  1268. $repeat = Notice::staticGet('id', $this->repeat_of);
  1269. if (!empty($repeat)) {
  1270. $ctx->forwardID = $repeat->uri;
  1271. $ctx->forwardUrl = $repeat->bestUrl();
  1272. }
  1273. }
  1274. $act->context = $ctx;
  1275. // Source
  1276. $atom_feed = $profile->getAtomFeed();
  1277. if (!empty($atom_feed)) {
  1278. $act->source = new ActivitySource();
  1279. // XXX: we should store the actual feed ID
  1280. $act->source->id = $atom_feed;
  1281. // XXX: we should store the actual feed title
  1282. $act->source->title = $profile->getBestName();
  1283. $act->source->links['alternate'] = $profile->profileurl;
  1284. $act->source->links['self'] = $atom_feed;
  1285. $act->source->icon = $profile->avatarUrl(AVATAR_PROFILE_SIZE);
  1286. $notice = $profile->getCurrentNotice();
  1287. if (!empty($notice)) {
  1288. $act->source->updated = self::utcDate($notice->created);
  1289. }
  1290. $user = User::staticGet('id', $profile->id);
  1291. if (!empty($user)) {
  1292. $act->source->links['license'] = common_config('license', 'url');
  1293. }
  1294. }
  1295. if ($this->isLocal()) {
  1296. $act->selfLink = common_local_url('ApiStatusesShow', array('id' => $this->id,
  1297. 'format' => 'atom'));
  1298. $act->editLink = $act->selfLink;
  1299. }
  1300. Event::handle('EndNoticeAsActivity', array($this, &$act));
  1301. }
  1302. self::cacheSet(Cache::codeKey('notice:as-activity:'.$this->id), $act);
  1303. return $act;
  1304. }
  1305. // This has gotten way too long. Needs to be sliced up into functional bits
  1306. // or ideally exported to a utility class.
  1307. function asAtomEntry($namespace=false,
  1308. $source=false,
  1309. $author=true,
  1310. $cur=null)
  1311. {
  1312. $act = $this->asActivity($cur);
  1313. $act->extra[] = $this->noticeInfo($cur);
  1314. return $act->asString($namespace, $author, $source);
  1315. }
  1316. /**
  1317. * Extra notice info for atom entries
  1318. *
  1319. * Clients use some extra notice info in the atom stream.
  1320. * This gives it to them.
  1321. *
  1322. * @param User $cur Current user
  1323. *
  1324. * @return array representation of <statusnet:notice_info> element
  1325. */
  1326. function noticeInfo($cur)
  1327. {
  1328. // local notice ID (useful to clients for ordering)
  1329. $noticeInfoAttr = array('local_id' => $this->id);
  1330. // notice source
  1331. $ns = $this->getSource();
  1332. if (!empty($ns)) {
  1333. $noticeInfoAttr['source'] = $ns->code;
  1334. if (!empty($ns->url)) {
  1335. $noticeInfoAttr['source_link'] = $ns->url;
  1336. if (!empty($ns->name)) {
  1337. $noticeInfoAttr['source'] = '<a href="'
  1338. . htmlspecialchars($ns->url)
  1339. . '" rel="nofollow">'
  1340. . htmlspecialchars($ns->name)
  1341. . '</a>';
  1342. }
  1343. }
  1344. }
  1345. // favorite and repeated
  1346. if (!empty($cur)) {
  1347. $noticeInfoAttr['favorite'] = ($cur->hasFave($this)) ? "true" : "false";
  1348. $cp = $cur->getProfile();
  1349. $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this->id)) ? "true" : "false";
  1350. }
  1351. if (!empty($this->repeat_of)) {
  1352. $noticeInfoAttr['repeat_of'] = $this->repeat_of;
  1353. }
  1354. return array('statusnet:notice_info', $noticeInfoAttr, null);
  1355. }
  1356. /**
  1357. * Returns an XML string fragment with a reference to a notice as an
  1358. * Activity Streams noun object with the given element type.
  1359. *
  1360. * Assumes that 'activity' namespace has been previously defined.
  1361. *
  1362. * @param string $element one of 'subject', 'object', 'target'
  1363. * @return string
  1364. */
  1365. function asActivityNoun($element)
  1366. {
  1367. $noun = ActivityObject::fromNotice($this);
  1368. return $noun->asString('activity:' . $element);
  1369. }
  1370. function bestUrl()
  1371. {
  1372. if (!empty($this->url)) {
  1373. return $this->url;
  1374. } else if (!empty($this->uri) && preg_match('/^https?:/', $this->uri)) {
  1375. return $this->uri;
  1376. } else {
  1377. return common_local_url('shownotice',
  1378. array('notice' => $this->id));
  1379. }
  1380. }
  1381. /**
  1382. * Determine which notice, if any, a new notice is in reply to.
  1383. *
  1384. * For conversation tracking, we try to see where this notice fits
  1385. * in the tree. Rough algorithm is:
  1386. *
  1387. * if (reply_to is set and valid) {
  1388. * return reply_to;
  1389. * } else if ((source not API or Web) and (content starts with "T NAME" or "@name ")) {
  1390. * return ID of last notice by initial @name in content;
  1391. * }
  1392. *
  1393. * Note that all @nickname instances will still be used to save "reply" records,
  1394. * so the notice shows up in the mentioned users' "replies" tab.
  1395. *
  1396. * @param integer $reply_to ID passed in by Web or API
  1397. * @param integer $profile_id ID of author
  1398. * @param string $source Source tag, like 'web' or 'gwibber'
  1399. * @param string $content Final notice content
  1400. *
  1401. * @return integer ID of replied-to notice, or null for not a reply.
  1402. */
  1403. static function getReplyTo($reply_to, $profile_id, $source, $content)
  1404. {
  1405. static $lb = array('xmpp', 'mail', 'sms', 'omb');
  1406. // If $reply_to is specified, we check that it exists, and then
  1407. // return it if it does
  1408. if (!empty($reply_to)) {
  1409. $reply_notice = Notice::staticGet('id', $reply_to);
  1410. if (!empty($reply_notice)) {
  1411. return $reply_notice;
  1412. }
  1413. }
  1414. // If it's not a "low bandwidth" source (one where you can't set
  1415. // a reply_to argument), we return. This is mostly web and API
  1416. // clients.
  1417. if (!in_array($source, $lb)) {
  1418. return null;
  1419. }
  1420. // Is there an initial @ or T?
  1421. if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match) ||
  1422. preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
  1423. $nickname = common_canonical_nickname($match[1]);
  1424. } else {
  1425. return null;
  1426. }
  1427. // Figure out who that is.
  1428. $sender = Profile::staticGet('id', $profile_id);
  1429. if (empty($sender)) {
  1430. return null;
  1431. }
  1432. $recipient = common_relative_profile($sender, $nickname, common_sql_now());
  1433. if (empty($recipient)) {
  1434. return null;
  1435. }
  1436. // Get their last notice
  1437. $last = $recipient->getCurrentNotice();
  1438. if (!empty($last)) {
  1439. return $last;
  1440. }
  1441. return null;
  1442. }
  1443. static function maxContent()
  1444. {
  1445. $contentlimit = common_config('notice', 'contentlimit');
  1446. // null => use global limit (distinct from 0!)
  1447. if (is_null($contentlimit)) {
  1448. $contentlimit = common_config('site', 'textlimit');
  1449. }
  1450. return $contentlimit;
  1451. }
  1452. static function contentTooLong($content)
  1453. {
  1454. $contentlimit = self::maxContent();
  1455. return ($contentlimit > 0 && !empty($content) && (mb_strlen($content) > $contentlimit));
  1456. }
  1457. function getLocation()
  1458. {
  1459. $location = null;
  1460. if (!empty($this->location_id) && !empty($this->location_ns)) {
  1461. $location = Location::fromId($this->location_id, $this->location_ns);
  1462. }
  1463. if (is_null($location)) { // no ID, or Location::fromId() failed
  1464. if (!empty($this->lat) && !empty($this->lon)) {
  1465. $location = Location::fromLatLon($this->lat, $this->lon);
  1466. }
  1467. }
  1468. return $location;
  1469. }
  1470. /**
  1471. * Convenience function for posting a repeat of an existing message.
  1472. *
  1473. * @param int $repeater_id: profile ID of user doing the repeat
  1474. * @param string $source: posting source key, eg 'web', 'api', etc
  1475. * @return Notice
  1476. *
  1477. * @throws Exception on failure or permission problems
  1478. */
  1479. function repeat($repeater_id, $source)
  1480. {
  1481. $author = Profile::staticGet('id', $this->profile_id);
  1482. // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
  1483. // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
  1484. $content = sprintf(_('RT @%1$s %2$s'),
  1485. $author->nickname,
  1486. $this->content);
  1487. $maxlen = common_config('site', 'textlimit');
  1488. if ($maxlen > 0 && mb_strlen($content) > $maxlen) {
  1489. // Web interface and current Twitter API clients will
  1490. // pull the original notice's text, but some older
  1491. // clients and RSS/Atom feeds will see this trimmed text.
  1492. //
  1493. // Unfortunately this is likely to lose tags or URLs
  1494. // at the end of long notices.
  1495. $content = mb_substr($content, 0, $maxlen - 4) . ' ...';
  1496. }
  1497. // Scope is same as this one's
  1498. return self::saveNew($repeater_id,
  1499. $content,
  1500. $source,
  1501. array('repeat_of' => $this->id,
  1502. 'scope' => $this->scope));
  1503. }
  1504. // These are supposed to be in chron order!
  1505. function repeatStream($limit=100)
  1506. {
  1507. $cache = Cache::instance();
  1508. if (empty($cache)) {
  1509. $ids = $this->_repeatStreamDirect($limit);
  1510. } else {
  1511. $idstr = $cache->get(Cache::key('notice:repeats:'.$this->id));
  1512. if ($idstr !== false) {
  1513. if (empty($idstr)) {
  1514. $ids = array();
  1515. } else {
  1516. $ids = explode(',', $idstr);
  1517. }
  1518. } else {
  1519. $ids = $this->_repeatStreamDirect(100);
  1520. $cache->set(Cache::key('notice:repeats:'.$this->id), implode(',', $ids));
  1521. }
  1522. if ($limit < 100) {
  1523. // We do a max of 100, so slice down to limit
  1524. $ids = array_slice($ids, 0, $limit);
  1525. }
  1526. }
  1527. return NoticeStream::getStreamByIds($ids);
  1528. }
  1529. function _repeatStreamDirect($limit)
  1530. {
  1531. $notice = new Notice();
  1532. $notice->selectAdd(); // clears it
  1533. $notice->selectAdd('id');
  1534. $notice->repeat_of = $this->id;
  1535. $notice->orderBy('created, id'); // NB: asc!
  1536. if (!is_null($limit)) {
  1537. $notice->limit(0, $limit);
  1538. }
  1539. return $notice->fetchAll('id');
  1540. }
  1541. function locationOptions($lat, $lon, $location_id, $location_ns, $profile = null)
  1542. {
  1543. $options = array();
  1544. if (!empty($location_id) && !empty($location_ns)) {
  1545. $options['location_id'] = $location_id;
  1546. $options['location_ns'] = $location_ns;
  1547. $location = Location::fromId($location_id, $location_ns);
  1548. if (!empty($location)) {
  1549. $options['lat'] = $location->lat;
  1550. $options['lon'] = $location->lon;
  1551. }
  1552. } else if (!empty($lat) && !empty($lon)) {
  1553. $options['lat'] = $lat;
  1554. $options['lon'] = $lon;
  1555. $location = Location::fromLatLon($lat, $lon);
  1556. if (!empty($location)) {
  1557. $options['location_id'] = $location->location_id;
  1558. $options['location_ns'] = $location->location_ns;
  1559. }
  1560. } else if (!empty($profile)) {
  1561. if (isset($profile->lat) && isset($profile->lon)) {
  1562. $options['lat'] = $profile->lat;
  1563. $options['lon'] = $profile->lon;
  1564. }
  1565. if (isset($profile->location_id) && isset($profile->location_ns)) {
  1566. $options['location_id'] = $profile->location_id;
  1567. $options['location_ns'] = $profile->location_ns;
  1568. }
  1569. }
  1570. return $options;
  1571. }
  1572. function clearReplies()
  1573. {
  1574. $replyNotice = new Notice();
  1575. $replyNotice->reply_to = $this->id;
  1576. //Null any notices that are replies to this notice
  1577. if ($replyNotice->find()) {
  1578. while ($replyNotice->fetch()) {
  1579. $orig = clone($replyNotice);
  1580. $replyNotice->reply_to = null;
  1581. $replyNotice->update($orig);
  1582. }
  1583. }
  1584. // Reply records
  1585. $reply = new Reply();
  1586. $reply->notice_id = $this->id;
  1587. if ($reply->find()) {
  1588. while($reply->fetch()) {
  1589. self::blow('reply:stream:%d', $reply->profile_id);
  1590. $reply->delete();
  1591. }
  1592. }
  1593. $reply->free();
  1594. }
  1595. function clearFiles()
  1596. {
  1597. $f2p = new File_to_post();
  1598. $f2p->post_id = $this->id;
  1599. if ($f2p->find()) {
  1600. while ($f2p->fetch()) {
  1601. $f2p->delete();
  1602. }
  1603. }
  1604. // FIXME: decide whether to delete File objects
  1605. // ...and related (actual) files
  1606. }
  1607. function clearRepeats()
  1608. {
  1609. $repeatNotice = new Notice();
  1610. $repeatNotice->repeat_of = $this->id;
  1611. //Null any notices that are repeats of this notice
  1612. if ($repeatNotice->find()) {
  1613. while ($repeatNotice->fetch()) {
  1614. $orig = clone($repeatNotice);
  1615. $repeatNotice->repeat_of = null;
  1616. $repeatNotice->update($orig);
  1617. }
  1618. }
  1619. }
  1620. function clearFaves()
  1621. {
  1622. $fave = new Fave();
  1623. $fave->notice_id = $this->id;
  1624. if ($fave->find()) {
  1625. while ($fave->fetch()) {
  1626. self::blow('fave:ids_by_user_own:%d', $fave->user_id);
  1627. self::blow('fave:ids_by_user_own:%d;last', $fave->user_id);
  1628. self::blow('fave:ids_by_user:%d', $fave->user_id);
  1629. self::blow('fave:ids_by_user:%d;last', $fave->user_id);
  1630. $fave->delete();
  1631. }
  1632. }
  1633. $fave->free();
  1634. }
  1635. function clearTags()
  1636. {
  1637. $tag = new Notice_tag();
  1638. $tag->notice_id = $this->id;
  1639. if ($tag->find()) {
  1640. while ($tag->fetch()) {
  1641. self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, Cache::keyize($tag->tag));
  1642. self::blow('profile:notice_ids_tagged:%d:%s;last', $this->profile_id, Cache::keyize($tag->tag));
  1643. self::blow('notice_tag:notice_ids:%s', Cache::keyize($tag->tag));
  1644. self::blow('notice_tag:notice_ids:%s;last', Cache::keyize($tag->tag));
  1645. $tag->delete();
  1646. }
  1647. }
  1648. $tag->free();
  1649. }
  1650. function clearGroupInboxes()
  1651. {
  1652. $gi = new Group_inbox();
  1653. $gi->notice_id = $this->id;
  1654. if ($gi->find()) {
  1655. while ($gi->fetch()) {
  1656. self::blow('user_group:notice_ids:%d', $gi->group_id);
  1657. $gi->delete();
  1658. }
  1659. }
  1660. $gi->free();
  1661. }
  1662. function distribute()
  1663. {
  1664. // We always insert for the author so they don't
  1665. // have to wait
  1666. Event::handle('StartNoticeDistribute', array($this));
  1667. $user = User::staticGet('id', $this->profile_id);
  1668. if (!empty($user)) {
  1669. Inbox::insertNotice($user->id, $this->id);
  1670. }
  1671. if (common_config('queue', 'inboxes')) {
  1672. // If there's a failure, we want to _force_
  1673. // distribution at this point.
  1674. try {
  1675. $qm = QueueManager::get();
  1676. $qm->enqueue($this, 'distrib');
  1677. } catch (Exception $e) {
  1678. // If the exception isn't transient, this
  1679. // may throw more exceptions as DQH does
  1680. // its own enqueueing. So, we ignore them!
  1681. try {
  1682. $handler = new DistribQueueHandler();
  1683. $handler->handle($this);
  1684. } catch (Exception $e) {
  1685. common_log(LOG_ERR, "emergency redistribution resulted in " . $e->getMessage());
  1686. }
  1687. // Re-throw so somebody smarter can handle it.
  1688. throw $e;
  1689. }
  1690. } else {
  1691. $handler = new DistribQueueHandler();
  1692. $handler->handle($this);
  1693. }
  1694. }
  1695. function insert()
  1696. {
  1697. $result = parent::insert();
  1698. if ($result) {
  1699. // Profile::hasRepeated() abuses pkeyGet(), so we
  1700. // have to clear manually
  1701. if (!empty($this->repeat_of)) {
  1702. $c = self::memcache();
  1703. if (!empty($c)) {
  1704. $ck = self::multicacheKey('Notice',
  1705. array('profile_id' => $this->profile_id,
  1706. 'repeat_of' => $this->repeat_of));
  1707. $c->delete($ck);
  1708. }
  1709. }
  1710. }
  1711. return $result;
  1712. }
  1713. /**
  1714. * Get the source of the notice
  1715. *
  1716. * @return Notice_source $ns A notice source object. 'code' is the only attribute
  1717. * guaranteed to be populated.
  1718. */
  1719. function getSource()
  1720. {
  1721. $ns = new Notice_source();
  1722. if (!empty($this->source)) {
  1723. switch ($this->source) {
  1724. case 'web':
  1725. case 'xmpp':
  1726. case 'mail':
  1727. case 'omb':
  1728. case 'system':
  1729. case 'api':
  1730. $ns->code = $this->source;
  1731. break;
  1732. default:
  1733. $ns = Notice_source::staticGet($this->source);
  1734. if (!$ns) {
  1735. $ns = new Notice_source();
  1736. $ns->code = $this->source;
  1737. $app = Oauth_application::staticGet('name', $this->source);
  1738. if ($app) {
  1739. $ns->name = $app->name;
  1740. $ns->url = $app->source_url;
  1741. }
  1742. }
  1743. break;
  1744. }
  1745. }
  1746. return $ns;
  1747. }
  1748. /**
  1749. * Determine whether the notice was locally created
  1750. *
  1751. * @return boolean locality
  1752. */
  1753. public function isLocal()
  1754. {
  1755. return ($this->is_local == Notice::LOCAL_PUBLIC ||
  1756. $this->is_local == Notice::LOCAL_NONPUBLIC);
  1757. }
  1758. /**
  1759. * Get the list of hash tags saved with this notice.
  1760. *
  1761. * @return array of strings
  1762. */
  1763. public function getTags()
  1764. {
  1765. $tags = array();
  1766. $keypart = sprintf('notice:tags:%d', $this->id);
  1767. $tagstr = self::cacheGet($keypart);
  1768. if ($tagstr !== false) {
  1769. $tags = explode(',', $tagstr);
  1770. } else {
  1771. $tag = new Notice_tag();
  1772. $tag->notice_id = $this->id;
  1773. if ($tag->find()) {
  1774. while ($tag->fetch()) {
  1775. $tags[] = $tag->tag;
  1776. }
  1777. }
  1778. self::cacheSet($keypart, implode(',', $tags));
  1779. }
  1780. return $tags;
  1781. }
  1782. static private function utcDate($dt)
  1783. {
  1784. $dateStr = date('d F Y H:i:s', strtotime($dt));
  1785. $d = new DateTime($dateStr, new DateTimeZone('UTC'));
  1786. return $d->format(DATE_W3C);
  1787. }
  1788. /**
  1789. * Look up the creation timestamp for a given notice ID, even
  1790. * if it's been deleted.
  1791. *
  1792. * @param int $id
  1793. * @return mixed string recorded creation timestamp, or false if can't be found
  1794. */
  1795. public static function getAsTimestamp($id)
  1796. {
  1797. if (!$id) {
  1798. return false;
  1799. }
  1800. $notice = Notice::staticGet('id', $id);
  1801. if ($notice) {
  1802. return $notice->created;
  1803. }
  1804. $deleted = Deleted_notice::staticGet('id', $id);
  1805. if ($deleted) {
  1806. return $deleted->created;
  1807. }
  1808. return false;
  1809. }
  1810. /**
  1811. * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
  1812. * parameter, matching notices posted after the given one (exclusive).
  1813. *
  1814. * If the referenced notice can't be found, will return false.
  1815. *
  1816. * @param int $id
  1817. * @param string $idField
  1818. * @param string $createdField
  1819. * @return mixed string or false if no match
  1820. */
  1821. public static function whereSinceId($id, $idField='id', $createdField='created')
  1822. {
  1823. $since = Notice::getAsTimestamp($id);
  1824. if ($since) {
  1825. return sprintf("($createdField = '%s' and $idField > %d) or ($createdField > '%s')", $since, $id, $since);
  1826. }
  1827. return false;
  1828. }
  1829. /**
  1830. * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
  1831. * parameter, matching notices posted after the given one (exclusive), and
  1832. * if necessary add it to the data object's query.
  1833. *
  1834. * @param DB_DataObject $obj
  1835. * @param int $id
  1836. * @param string $idField
  1837. * @param string $createdField
  1838. * @return mixed string or false if no match
  1839. */
  1840. public static function addWhereSinceId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
  1841. {
  1842. $since = self::whereSinceId($id, $idField, $createdField);
  1843. if ($since) {
  1844. $obj->whereAdd($since);
  1845. }
  1846. }
  1847. /**
  1848. * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
  1849. * parameter, matching notices posted before the given one (inclusive).
  1850. *
  1851. * If the referenced notice can't be found, will return false.
  1852. *
  1853. * @param int $id
  1854. * @param string $idField
  1855. * @param string $createdField
  1856. * @return mixed string or false if no match
  1857. */
  1858. public static function whereMaxId($id, $idField='id', $createdField='created')
  1859. {
  1860. $max = Notice::getAsTimestamp($id);
  1861. if ($max) {
  1862. return sprintf("($createdField < '%s') or ($createdField = '%s' and $idField <= %d)", $max, $max, $id);
  1863. }
  1864. return false;
  1865. }
  1866. /**
  1867. * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
  1868. * parameter, matching notices posted before the given one (inclusive), and
  1869. * if necessary add it to the data object's query.
  1870. *
  1871. * @param DB_DataObject $obj
  1872. * @param int $id
  1873. * @param string $idField
  1874. * @param string $createdField
  1875. * @return mixed string or false if no match
  1876. */
  1877. public static function addWhereMaxId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
  1878. {
  1879. $max = self::whereMaxId($id, $idField, $createdField);
  1880. if ($max) {
  1881. $obj->whereAdd($max);
  1882. }
  1883. }
  1884. function isPublic()
  1885. {
  1886. if (common_config('public', 'localonly')) {
  1887. return ($this->is_local == Notice::LOCAL_PUBLIC);
  1888. } else {
  1889. return (($this->is_local != Notice::LOCAL_NONPUBLIC) &&
  1890. ($this->is_local != Notice::GATEWAY));
  1891. }
  1892. }
  1893. /**
  1894. * Check that the given profile is allowed to read, respond to, or otherwise
  1895. * act on this notice.
  1896. *
  1897. * The $scope member is a bitmask of scopes, representing a logical AND of the
  1898. * scope requirement. So, 0x03 (Notice::ADDRESSEE_SCOPE | Notice::SITE_SCOPE) means
  1899. * "only visible to people who are mentioned in the notice AND are users on this site."
  1900. * Users on the site who are not mentioned in the notice will not be able to see the
  1901. * notice.
  1902. *
  1903. * @param Profile $profile The profile to check; pass null to check for public/unauthenticated users.
  1904. *
  1905. * @return boolean whether the profile is in the notice's scope
  1906. */
  1907. function inScope($profile)
  1908. {
  1909. if (is_null($profile)) {
  1910. $keypart = sprintf('notice:in-scope-for:%d:null', $this->id);
  1911. } else {
  1912. $keypart = sprintf('notice:in-scope-for:%d:%d', $this->id, $profile->id);
  1913. }
  1914. $result = self::cacheGet($keypart);
  1915. if ($result === false) {
  1916. $bResult = false;
  1917. if (Event::handle('StartNoticeInScope', array($this, $profile, &$bResult))) {
  1918. $bResult = $this->_inScope($profile);
  1919. Event::handle('EndNoticeInScope', array($this, $profile, &$bResult));
  1920. }
  1921. $result = ($bResult) ? 1 : 0;
  1922. self::cacheSet($keypart, $result, 0, 300);
  1923. }
  1924. return ($result == 1) ? true : false;
  1925. }
  1926. protected function _inScope($profile)
  1927. {
  1928. if (!is_null($this->scope)) {
  1929. $scope = $this->scope;
  1930. } else {
  1931. $scope = self::defaultScope();
  1932. }
  1933. // If there's no scope, anyone (even anon) is in scope.
  1934. if ($scope == 0) { // Not private
  1935. return !$this->isHiddenSpam($profile);
  1936. } else { // Private, somehow
  1937. // If there's scope, anon cannot be in scope
  1938. if (empty($profile)) {
  1939. return false;
  1940. }
  1941. // Author is always in scope
  1942. if ($this->profile_id == $profile->id) {
  1943. return true;
  1944. }
  1945. // Only for users on this site
  1946. if ($scope & Notice::SITE_SCOPE) {
  1947. $user = $profile->getUser();
  1948. if (empty($user)) {
  1949. return false;
  1950. }
  1951. }
  1952. // Only for users mentioned in the notice
  1953. if ($scope & Notice::ADDRESSEE_SCOPE) {
  1954. $repl = Reply::pkeyGet(array('notice_id' => $this->id,
  1955. 'profile_id' => $profile->id));
  1956. if (empty($repl)) {
  1957. return false;
  1958. }
  1959. }
  1960. // Only for members of the given group
  1961. if ($scope & Notice::GROUP_SCOPE) {
  1962. // XXX: just query for the single membership
  1963. $groups = $this->getGroups();
  1964. $foundOne = false;
  1965. foreach ($groups as $group) {
  1966. if ($profile->isMember($group)) {
  1967. $foundOne = true;
  1968. break;
  1969. }
  1970. }
  1971. if (!$foundOne) {
  1972. return false;
  1973. }
  1974. }
  1975. // Only for followers of the author
  1976. $author = null;
  1977. if ($scope & Notice::FOLLOWER_SCOPE) {
  1978. try {
  1979. $author = $this->getProfile();
  1980. } catch (Exception $e) {
  1981. return false;
  1982. }
  1983. if (!Subscription::exists($profile, $author)) {
  1984. return false;
  1985. }
  1986. }
  1987. return !$this->isHiddenSpam($profile);
  1988. }
  1989. }
  1990. function isHiddenSpam($profile) {
  1991. // Hide posts by silenced users from everyone but moderators.
  1992. if (common_config('notice', 'hidespam')) {
  1993. try {
  1994. $author = $this->getProfile();
  1995. } catch(Exception $e) {
  1996. // If we can't get an author, keep it hidden.
  1997. // XXX: technically not spam, but, whatever.
  1998. return true;
  1999. }
  2000. if ($author->hasRole(Profile_role::SILENCED)) {
  2001. if (empty($profile) || (($profile->id !== $author->id) && (!$profile->hasRight(Right::REVIEWSPAM)))) {
  2002. return true;
  2003. }
  2004. }
  2005. }
  2006. return false;
  2007. }
  2008. static function groupsFromText($text, $profile)
  2009. {
  2010. $groups = array();
  2011. /* extract all !group */
  2012. $count = preg_match_all('/(?:^|\s)!(' . Nickname::DISPLAY_FMT . ')/',
  2013. strtolower($text),
  2014. $match);
  2015. if (!$count) {
  2016. return $groups;
  2017. }
  2018. foreach (array_unique($match[1]) as $nickname) {
  2019. $group = User_group::getForNickname($nickname, $profile);
  2020. if (!empty($group) && $profile->isMember($group)) {
  2021. $groups[] = $group->id;
  2022. }
  2023. }
  2024. return $groups;
  2025. }
  2026. protected $_original = -1;
  2027. function getOriginal()
  2028. {
  2029. if (is_int($this->_original) && $this->_original == -1) {
  2030. if (empty($this->reply_to)) {
  2031. $this->_original = null;
  2032. } else {
  2033. $this->_original = Notice::staticGet('id', $this->reply_to);
  2034. }
  2035. }
  2036. return $this->_original;
  2037. }
  2038. /**
  2039. * Magic function called at serialize() time.
  2040. *
  2041. * We use this to drop a couple process-specific references
  2042. * from DB_DataObject which can cause trouble in future
  2043. * processes.
  2044. *
  2045. * @return array of variable names to include in serialization.
  2046. */
  2047. function __sleep()
  2048. {
  2049. $vars = parent::__sleep();
  2050. $skip = array('_original', '_profile', '_groups', '_attachments', '_faves', '_replies', '_repeats');
  2051. return array_diff($vars, $skip);
  2052. }
  2053. static function defaultScope()
  2054. {
  2055. $scope = common_config('notice', 'defaultscope');
  2056. if (is_null($scope)) {
  2057. if (common_config('site', 'private')) {
  2058. $scope = 1;
  2059. } else {
  2060. $scope = 0;
  2061. }
  2062. }
  2063. return $scope;
  2064. }
  2065. static function fillProfiles($notices)
  2066. {
  2067. $map = self::getProfiles($notices);
  2068. foreach ($notices as $notice) {
  2069. if (array_key_exists($notice->profile_id, $map)) {
  2070. $notice->_setProfile($map[$notice->profile_id]);
  2071. }
  2072. }
  2073. return array_values($map);
  2074. }
  2075. static function getProfiles(&$notices)
  2076. {
  2077. $ids = array();
  2078. foreach ($notices as $notice) {
  2079. $ids[] = $notice->profile_id;
  2080. }
  2081. $ids = array_unique($ids);
  2082. return Memcached_DataObject::pivotGet('Profile', 'id', $ids);
  2083. }
  2084. static function fillGroups(&$notices)
  2085. {
  2086. $ids = self::_idsOf($notices);
  2087. $gis = Memcached_DataObject::listGet('Group_inbox', 'notice_id', $ids);
  2088. $gids = array();
  2089. foreach ($gis as $id => $gi)
  2090. {
  2091. foreach ($gi as $g)
  2092. {
  2093. $gids[] = $g->group_id;
  2094. }
  2095. }
  2096. $gids = array_unique($gids);
  2097. $group = Memcached_DataObject::pivotGet('User_group', 'id', $gids);
  2098. foreach ($notices as $notice)
  2099. {
  2100. $grps = array();
  2101. $gi = $gis[$notice->id];
  2102. foreach ($gi as $g) {
  2103. $grps[] = $group[$g->group_id];
  2104. }
  2105. $notice->_setGroups($grps);
  2106. }
  2107. }
  2108. static function _idsOf(&$notices)
  2109. {
  2110. $ids = array();
  2111. foreach ($notices as $notice) {
  2112. $ids[] = $notice->id;
  2113. }
  2114. $ids = array_unique($ids);
  2115. return $ids;
  2116. }
  2117. static function fillAttachments(&$notices)
  2118. {
  2119. $ids = self::_idsOf($notices);
  2120. $f2pMap = Memcached_DataObject::listGet('File_to_post', 'post_id', $ids);
  2121. $fileIds = array();
  2122. foreach ($f2pMap as $noticeId => $f2ps) {
  2123. foreach ($f2ps as $f2p) {
  2124. $fileIds[] = $f2p->file_id;
  2125. }
  2126. }
  2127. $fileIds = array_unique($fileIds);
  2128. $fileMap = Memcached_DataObject::pivotGet('File', 'id', $fileIds);
  2129. foreach ($notices as $notice)
  2130. {
  2131. $files = array();
  2132. $f2ps = $f2pMap[$notice->id];
  2133. foreach ($f2ps as $f2p) {
  2134. $files[] = $fileMap[$f2p->file_id];
  2135. }
  2136. $notice->_setAttachments($files);
  2137. }
  2138. }
  2139. protected $_faves;
  2140. /**
  2141. * All faves of this notice
  2142. *
  2143. * @return array Array of Fave objects
  2144. */
  2145. function getFaves()
  2146. {
  2147. if (isset($this->_faves) && is_array($this->_faves)) {
  2148. return $this->_faves;
  2149. }
  2150. $faveMap = Memcached_DataObject::listGet('Fave', 'notice_id', array($this->id));
  2151. $this->_faves = $faveMap[$this->id];
  2152. return $this->_faves;
  2153. }
  2154. function _setFaves($faves)
  2155. {
  2156. $this->_faves = $faves;
  2157. }
  2158. static function fillFaves(&$notices)
  2159. {
  2160. $ids = self::_idsOf($notices);
  2161. $faveMap = Memcached_DataObject::listGet('Fave', 'notice_id', $ids);
  2162. $cnt = 0;
  2163. $faved = array();
  2164. foreach ($faveMap as $id => $faves) {
  2165. $cnt += count($faves);
  2166. if (count($faves) > 0) {
  2167. $faved[] = $id;
  2168. }
  2169. }
  2170. foreach ($notices as $notice) {
  2171. $faves = $faveMap[$notice->id];
  2172. $notice->_setFaves($faves);
  2173. }
  2174. }
  2175. static function fillReplies(&$notices)
  2176. {
  2177. $ids = self::_idsOf($notices);
  2178. $replyMap = Memcached_DataObject::listGet('Reply', 'notice_id', $ids);
  2179. foreach ($notices as $notice) {
  2180. $replies = $replyMap[$notice->id];
  2181. $ids = array();
  2182. foreach ($replies as $reply) {
  2183. $ids[] = $reply->profile_id;
  2184. }
  2185. $notice->_setReplies($ids);
  2186. }
  2187. }
  2188. protected $_repeats;
  2189. function getRepeats()
  2190. {
  2191. if (isset($this->_repeats) && is_array($this->_repeats)) {
  2192. return $this->_repeats;
  2193. }
  2194. $repeatMap = Memcached_DataObject::listGet('Notice', 'repeat_of', array($this->id));
  2195. $this->_repeats = $repeatMap[$this->id];
  2196. return $this->_repeats;
  2197. }
  2198. function _setRepeats($repeats)
  2199. {
  2200. $this->_repeats = $repeats;
  2201. }
  2202. static function fillRepeats(&$notices)
  2203. {
  2204. $ids = self::_idsOf($notices);
  2205. $repeatMap = Memcached_DataObject::listGet('Notice', 'repeat_of', $ids);
  2206. foreach ($notices as $notice) {
  2207. $repeats = $repeatMap[$notice->id];
  2208. $notice->_setRepeats($repeats);
  2209. }
  2210. }
  2211. }