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

/classes/Notice.php

https://gitlab.com/windigo-gs/windigos-gnu-social
PHP | 2694 lines | 1710 code | 487 blank | 497 comment | 299 complexity | c4af22a0db7aebf251d569943d0982b9 MD5 | raw file
Possible License(s): AGPL-3.0, BSD-3-Clause, GPL-2.0
  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. * @author Mikael Nordfeldth <mmn@hethane.se>
  33. * @copyright 2009 Free Software Foundation, Inc http://www.fsf.org
  34. * @license GNU Affero General Public License http://www.gnu.org/licenses/
  35. */
  36. if (!defined('GNUSOCIAL')) { exit(1); }
  37. /**
  38. * Table Definition for notice
  39. */
  40. /* We keep 200 notices, the max number of notices available per API request,
  41. * in the memcached cache. */
  42. define('NOTICE_CACHE_WINDOW', CachingNoticeStream::CACHE_WINDOW);
  43. define('MAX_BOXCARS', 128);
  44. class Notice extends Managed_DataObject
  45. {
  46. ###START_AUTOCODE
  47. /* the code below is auto generated do not remove the above tag */
  48. public $__table = 'notice'; // table name
  49. public $id; // int(4) primary_key not_null
  50. public $profile_id; // int(4) multiple_key not_null
  51. public $uri; // varchar(255) unique_key
  52. public $content; // text
  53. public $rendered; // text
  54. public $url; // varchar(255)
  55. public $created; // datetime multiple_key not_null default_0000-00-00%2000%3A00%3A00
  56. public $modified; // timestamp not_null default_CURRENT_TIMESTAMP
  57. public $reply_to; // int(4)
  58. public $is_local; // int(4)
  59. public $source; // varchar(32)
  60. public $conversation; // int(4)
  61. public $lat; // decimal(10,7)
  62. public $lon; // decimal(10,7)
  63. public $location_id; // int(4)
  64. public $location_ns; // int(4)
  65. public $repeat_of; // int(4)
  66. public $verb; // varchar(255)
  67. public $object_type; // varchar(255)
  68. public $scope; // int(4)
  69. /* the code above is auto generated do not remove the tag below */
  70. ###END_AUTOCODE
  71. public static function schemaDef()
  72. {
  73. $def = array(
  74. 'fields' => array(
  75. 'id' => array('type' => 'serial', 'not null' => true, 'description' => 'unique identifier'),
  76. 'profile_id' => array('type' => 'int', 'not null' => true, 'description' => 'who made the update'),
  77. 'uri' => array('type' => 'varchar', 'length' => 255, 'description' => 'universally unique identifier, usually a tag URI'),
  78. 'content' => array('type' => 'text', 'description' => 'update content', 'collate' => 'utf8_general_ci'),
  79. 'rendered' => array('type' => 'text', 'description' => 'HTML version of the content'),
  80. 'url' => array('type' => 'varchar', 'length' => 255, 'description' => 'URL of any attachment (image, video, bookmark, whatever)'),
  81. 'created' => array('type' => 'datetime', 'not null' => true, 'description' => 'date this record was created'),
  82. 'modified' => array('type' => 'timestamp', 'not null' => true, 'description' => 'date this record was modified'),
  83. 'reply_to' => array('type' => 'int', 'description' => 'notice replied to (usually a guess)'),
  84. 'is_local' => array('type' => 'int', 'size' => 'tiny', 'default' => 0, 'description' => 'notice was generated by a user'),
  85. 'source' => array('type' => 'varchar', 'length' => 32, 'description' => 'source of comment, like "web", "im", or "clientname"'),
  86. 'conversation' => array('type' => 'int', 'description' => 'id of root notice in this conversation'),
  87. 'lat' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'latitude'),
  88. 'lon' => array('type' => 'numeric', 'precision' => 10, 'scale' => 7, 'description' => 'longitude'),
  89. 'location_id' => array('type' => 'int', 'description' => 'location id if possible'),
  90. 'location_ns' => array('type' => 'int', 'description' => 'namespace for location'),
  91. 'repeat_of' => array('type' => 'int', 'description' => 'notice this is a repeat of'),
  92. 'object_type' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams object type', 'default' => 'http://activitystrea.ms/schema/1.0/note'),
  93. 'verb' => array('type' => 'varchar', 'length' => 255, 'description' => 'URI representing activity streams verb', 'default' => 'http://activitystrea.ms/schema/1.0/post'),
  94. 'scope' => array('type' => 'int',
  95. 'description' => 'bit map for distribution scope; 0 = everywhere; 1 = this server only; 2 = addressees; 4 = followers; null = default'),
  96. ),
  97. 'primary key' => array('id'),
  98. 'unique keys' => array(
  99. 'notice_uri_key' => array('uri'),
  100. ),
  101. 'foreign keys' => array(
  102. 'notice_profile_id_fkey' => array('profile', array('profile_id' => 'id')),
  103. 'notice_reply_to_fkey' => array('notice', array('reply_to' => 'id')),
  104. 'notice_conversation_fkey' => array('conversation', array('conversation' => 'id')), # note... used to refer to notice.id
  105. 'notice_repeat_of_fkey' => array('notice', array('repeat_of' => 'id')), # @fixme: what about repeats of deleted notices?
  106. ),
  107. 'indexes' => array(
  108. 'notice_created_id_is_local_idx' => array('created', 'id', 'is_local'),
  109. 'notice_profile_id_idx' => array('profile_id', 'created', 'id'),
  110. 'notice_repeat_of_created_id_idx' => array('repeat_of', 'created', 'id'),
  111. 'notice_conversation_created_id_idx' => array('conversation', 'created', 'id'),
  112. 'notice_replyto_idx' => array('reply_to')
  113. )
  114. );
  115. if (common_config('search', 'type') == 'fulltext') {
  116. $def['fulltext indexes'] = array('content' => array('content'));
  117. }
  118. return $def;
  119. }
  120. /* Notice types */
  121. const LOCAL_PUBLIC = 1;
  122. const REMOTE = 0;
  123. const LOCAL_NONPUBLIC = -1;
  124. const GATEWAY = -2;
  125. const PUBLIC_SCOPE = 0; // Useful fake constant
  126. const SITE_SCOPE = 1;
  127. const ADDRESSEE_SCOPE = 2;
  128. const GROUP_SCOPE = 4;
  129. const FOLLOWER_SCOPE = 8;
  130. protected $_profile = -1;
  131. public function getProfile()
  132. {
  133. if ($this->_profile === -1) {
  134. $this->_setProfile(Profile::getKV('id', $this->profile_id));
  135. }
  136. return $this->_profile;
  137. }
  138. public function _setProfile(Profile $profile=null)
  139. {
  140. if (!$profile instanceof Profile) {
  141. throw new NoProfileException($this->profile_id);
  142. }
  143. $this->_profile = $profile;
  144. }
  145. function delete($useWhere=false)
  146. {
  147. // For auditing purposes, save a record that the notice
  148. // was deleted.
  149. // @fixme we have some cases where things get re-run and so the
  150. // insert fails.
  151. $deleted = Deleted_notice::getKV('id', $this->id);
  152. if (!$deleted instanceof Deleted_notice) {
  153. $deleted = Deleted_notice::getKV('uri', $this->uri);
  154. }
  155. if (!$deleted instanceof Deleted_notice) {
  156. $deleted = new Deleted_notice();
  157. $deleted->id = $this->id;
  158. $deleted->profile_id = $this->profile_id;
  159. $deleted->uri = $this->uri;
  160. $deleted->created = $this->created;
  161. $deleted->deleted = common_sql_now();
  162. $deleted->insert();
  163. }
  164. if (Event::handle('NoticeDeleteRelated', array($this))) {
  165. // Clear related records
  166. $this->clearReplies();
  167. $this->clearRepeats();
  168. $this->clearFaves();
  169. $this->clearTags();
  170. $this->clearGroupInboxes();
  171. $this->clearFiles();
  172. $this->clearAttentions();
  173. // NOTE: we don't clear queue items
  174. }
  175. $result = parent::delete($useWhere);
  176. $this->blowOnDelete();
  177. return $result;
  178. }
  179. public function getUri()
  180. {
  181. return $this->uri;
  182. }
  183. /*
  184. * @param $root boolean If true, link to just the conversation root.
  185. *
  186. * @return URL to conversation
  187. */
  188. public function getConversationUrl($anchor=true)
  189. {
  190. return Conversation::getUrlFromNotice($this, $anchor);
  191. }
  192. /*
  193. * Get the local representation URL of this notice.
  194. */
  195. public function getLocalUrl()
  196. {
  197. return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
  198. }
  199. /*
  200. * Get the original representation URL of this notice.
  201. */
  202. public function getUrl()
  203. {
  204. // The risk is we start having empty urls and non-http uris...
  205. // and we can't really handle any other protocol right now.
  206. switch (true) {
  207. case common_valid_http_url($this->url): // should we allow non-http/https URLs?
  208. return $this->url;
  209. case $this->isLocal():
  210. // let's generate a valid link to our locally available notice on demand
  211. return common_local_url('shownotice', array('notice' => $this->id), null, null, false);
  212. case common_valid_http_url($this->uri):
  213. return $this->uri;
  214. default:
  215. common_debug('No URL available for notice: id='.$this->id);
  216. throw new InvalidUrlException($this->url);
  217. }
  218. }
  219. public function get_object_type($canonical=false) {
  220. return $canonical
  221. ? ActivityObject::canonicalType($this->object_type)
  222. : $this->object_type;
  223. }
  224. public static function getByUri($uri)
  225. {
  226. $notice = new Notice();
  227. $notice->uri = $uri;
  228. if (!$notice->find(true)) {
  229. throw new NoResultException($notice);
  230. }
  231. return $notice;
  232. }
  233. /**
  234. * Extract #hashtags from this notice's content and save them to the database.
  235. */
  236. function saveTags()
  237. {
  238. /* extract all #hastags */
  239. $count = preg_match_all('/(?:^|\s)#([\pL\pN_\-\.]{1,64})/u', strtolower($this->content), $match);
  240. if (!$count) {
  241. return true;
  242. }
  243. /* Add them to the database */
  244. return $this->saveKnownTags($match[1]);
  245. }
  246. /**
  247. * Record the given set of hash tags in the db for this notice.
  248. * Given tag strings will be normalized and checked for dupes.
  249. */
  250. function saveKnownTags($hashtags)
  251. {
  252. //turn each into their canonical tag
  253. //this is needed to remove dupes before saving e.g. #hash.tag = #hashtag
  254. for($i=0; $i<count($hashtags); $i++) {
  255. /* elide characters we don't want in the tag */
  256. $hashtags[$i] = common_canonical_tag($hashtags[$i]);
  257. }
  258. foreach(array_unique($hashtags) as $hashtag) {
  259. $this->saveTag($hashtag);
  260. self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, $hashtag);
  261. }
  262. return true;
  263. }
  264. /**
  265. * Record a single hash tag as associated with this notice.
  266. * Tag format and uniqueness must be validated by caller.
  267. */
  268. function saveTag($hashtag)
  269. {
  270. $tag = new Notice_tag();
  271. $tag->notice_id = $this->id;
  272. $tag->tag = $hashtag;
  273. $tag->created = $this->created;
  274. $id = $tag->insert();
  275. if (!$id) {
  276. // TRANS: Server exception. %s are the error details.
  277. throw new ServerException(sprintf(_('Database error inserting hashtag: %s.'),
  278. $last_error->message));
  279. return;
  280. }
  281. // if it's saved, blow its cache
  282. $tag->blowCache(false);
  283. }
  284. /**
  285. * Save a new notice and push it out to subscribers' inboxes.
  286. * Poster's permissions are checked before sending.
  287. *
  288. * @param int $profile_id Profile ID of the poster
  289. * @param string $content source message text; links may be shortened
  290. * per current user's preference
  291. * @param string $source source key ('web', 'api', etc)
  292. * @param array $options Associative array of optional properties:
  293. * string 'created' timestamp of notice; defaults to now
  294. * int 'is_local' source/gateway ID, one of:
  295. * Notice::LOCAL_PUBLIC - Local, ok to appear in public timeline
  296. * Notice::REMOTE - Sent from a remote service;
  297. * hide from public timeline but show in
  298. * local "and friends" timelines
  299. * Notice::LOCAL_NONPUBLIC - Local, but hide from public timeline
  300. * Notice::GATEWAY - From another non-OStatus service;
  301. * will not appear in public views
  302. * float 'lat' decimal latitude for geolocation
  303. * float 'lon' decimal longitude for geolocation
  304. * int 'location_id' geoname identifier
  305. * int 'location_ns' geoname namespace to interpret location_id
  306. * int 'reply_to'; notice ID this is a reply to
  307. * int 'repeat_of'; notice ID this is a repeat of
  308. * string 'uri' unique ID for notice; a unique tag uri (can be url or anything too)
  309. * string 'url' permalink to notice; defaults to local notice URL
  310. * string 'rendered' rendered HTML version of content
  311. * array 'replies' list of profile URIs for reply delivery in
  312. * place of extracting @-replies from content.
  313. * array 'groups' list of group IDs to deliver to, in place of
  314. * extracting ! tags from content
  315. * array 'tags' list of hashtag strings to save with the notice
  316. * in place of extracting # tags from content
  317. * array 'urls' list of attached/referred URLs to save with the
  318. * notice in place of extracting links from content
  319. * boolean 'distribute' whether to distribute the notice, default true
  320. * string 'object_type' URL of the associated object type (default ActivityObject::NOTE)
  321. * string 'verb' URL of the associated verb (default ActivityVerb::POST)
  322. * int 'scope' Scope bitmask; default to SITE_SCOPE on private sites, 0 otherwise
  323. *
  324. * @fixme tag override
  325. *
  326. * @return Notice
  327. * @throws ClientException
  328. */
  329. static function saveNew($profile_id, $content, $source, array $options=null) {
  330. $defaults = array('uri' => null,
  331. 'url' => null,
  332. 'reply_to' => null,
  333. 'repeat_of' => null,
  334. 'scope' => null,
  335. 'distribute' => true,
  336. 'object_type' => null,
  337. 'verb' => null);
  338. if (!empty($options) && is_array($options)) {
  339. $options = array_merge($defaults, $options);
  340. extract($options);
  341. } else {
  342. extract($defaults);
  343. }
  344. if (!isset($is_local)) {
  345. $is_local = Notice::LOCAL_PUBLIC;
  346. }
  347. $profile = Profile::getKV('id', $profile_id);
  348. if (!$profile instanceof Profile) {
  349. // TRANS: Client exception thrown when trying to save a notice for an unknown user.
  350. throw new ClientException(_('Problem saving notice. Unknown user.'));
  351. }
  352. $user = User::getKV('id', $profile_id);
  353. if ($user instanceof User) {
  354. // Use the local user's shortening preferences, if applicable.
  355. $final = $user->shortenLinks($content);
  356. } else {
  357. $final = common_shorten_links($content);
  358. }
  359. if (Notice::contentTooLong($final)) {
  360. // TRANS: Client exception thrown if a notice contains too many characters.
  361. throw new ClientException(_('Problem saving notice. Too long.'));
  362. }
  363. if (common_config('throttle', 'enabled') && !Notice::checkEditThrottle($profile_id)) {
  364. common_log(LOG_WARNING, 'Excessive posting by profile #' . $profile_id . '; throttled.');
  365. // TRANS: Client exception thrown when a user tries to post too many notices in a given time frame.
  366. throw new ClientException(_('Too many notices too fast; take a breather '.
  367. 'and post again in a few minutes.'));
  368. }
  369. if (common_config('site', 'dupelimit') > 0 && !Notice::checkDupes($profile_id, $final)) {
  370. common_log(LOG_WARNING, 'Dupe posting by profile #' . $profile_id . '; throttled.');
  371. // TRANS: Client exception thrown when a user tries to post too many duplicate notices in a given time frame.
  372. throw new ClientException(_('Too many duplicate messages too quickly;'.
  373. ' take a breather and post again in a few minutes.'));
  374. }
  375. if (!$profile->hasRight(Right::NEWNOTICE)) {
  376. common_log(LOG_WARNING, "Attempted post from user disallowed to post: " . $profile->nickname);
  377. // TRANS: Client exception thrown when a user tries to post while being banned.
  378. throw new ClientException(_('You are banned from posting notices on this site.'), 403);
  379. }
  380. $notice = new Notice();
  381. $notice->profile_id = $profile_id;
  382. $autosource = common_config('public', 'autosource');
  383. // Sandboxed are non-false, but not 1, either
  384. if (!$profile->hasRight(Right::PUBLICNOTICE) ||
  385. ($source && $autosource && in_array($source, $autosource))) {
  386. $notice->is_local = Notice::LOCAL_NONPUBLIC;
  387. } else {
  388. $notice->is_local = $is_local;
  389. }
  390. if (!empty($created)) {
  391. $notice->created = $created;
  392. } else {
  393. $notice->created = common_sql_now();
  394. }
  395. if (!$notice->isLocal()) {
  396. // Only do these checks for non-local notices. Local notices will generate these values later.
  397. if (!common_valid_http_url($url)) {
  398. common_debug('Bad notice URL: ['.$url.'], URI: ['.$uri.']. Cannot link back to original! This is normal for shared notices etc.');
  399. }
  400. if (empty($uri)) {
  401. throw new ServerException('No URI for remote notice. Cannot accept that.');
  402. }
  403. }
  404. $notice->content = $final;
  405. $notice->source = $source;
  406. $notice->uri = $uri;
  407. $notice->url = $url;
  408. // Get the groups here so we can figure out replies and such
  409. if (!isset($groups)) {
  410. $groups = User_group::idsFromText($notice->content, $profile);
  411. }
  412. $reply = null;
  413. // Handle repeat case
  414. if (isset($repeat_of)) {
  415. // Check for a private one
  416. $repeat = Notice::getKV('id', $repeat_of);
  417. if (!($repeat instanceof Notice)) {
  418. // TRANS: Client exception thrown in notice when trying to repeat a missing or deleted notice.
  419. throw new ClientException(_('Cannot repeat; original notice is missing or deleted.'));
  420. }
  421. if ($profile->id == $repeat->profile_id) {
  422. // TRANS: Client error displayed when trying to repeat an own notice.
  423. throw new ClientException(_('You cannot repeat your own notice.'));
  424. }
  425. if ($repeat->scope != Notice::SITE_SCOPE &&
  426. $repeat->scope != Notice::PUBLIC_SCOPE) {
  427. // TRANS: Client error displayed when trying to repeat a non-public notice.
  428. throw new ClientException(_('Cannot repeat a private notice.'), 403);
  429. }
  430. if (!$repeat->inScope($profile)) {
  431. // The generic checks above should cover this, but let's be sure!
  432. // TRANS: Client error displayed when trying to repeat a notice you cannot access.
  433. throw new ClientException(_('Cannot repeat a notice you cannot read.'), 403);
  434. }
  435. if ($profile->hasRepeated($repeat)) {
  436. // TRANS: Client error displayed when trying to repeat an already repeated notice.
  437. throw new ClientException(_('You already repeated that notice.'));
  438. }
  439. $notice->repeat_of = $repeat_of;
  440. } else {
  441. $reply = self::getReplyTo($reply_to, $profile_id, $source, $final);
  442. if (!empty($reply)) {
  443. if (!$reply->inScope($profile)) {
  444. // TRANS: Client error displayed when trying to reply to a notice a the target has no access to.
  445. // TRANS: %1$s is a user nickname, %2$d is a notice ID (number).
  446. throw new ClientException(sprintf(_('%1$s has no access to notice %2$d.'),
  447. $profile->nickname, $reply->id), 403);
  448. }
  449. $notice->reply_to = $reply->id;
  450. $notice->conversation = $reply->conversation;
  451. // If the original is private to a group, and notice has no group specified,
  452. // make it to the same group(s)
  453. if (empty($groups) && ($reply->scope & Notice::GROUP_SCOPE)) {
  454. $groups = array();
  455. $replyGroups = $reply->getGroups();
  456. foreach ($replyGroups as $group) {
  457. if ($profile->isMember($group)) {
  458. $groups[] = $group->id;
  459. }
  460. }
  461. }
  462. // Scope set below
  463. }
  464. }
  465. if (!empty($lat) && !empty($lon)) {
  466. $notice->lat = $lat;
  467. $notice->lon = $lon;
  468. }
  469. if (!empty($location_ns) && !empty($location_id)) {
  470. $notice->location_id = $location_id;
  471. $notice->location_ns = $location_ns;
  472. }
  473. if (!empty($rendered)) {
  474. $notice->rendered = $rendered;
  475. } else {
  476. $notice->rendered = common_render_content($final, $notice);
  477. }
  478. if (empty($verb)) {
  479. if (!empty($notice->repeat_of)) {
  480. $notice->verb = ActivityVerb::SHARE;
  481. $notice->object_type = ActivityObject::ACTIVITY;
  482. } else {
  483. $notice->verb = ActivityVerb::POST;
  484. }
  485. } else {
  486. $notice->verb = $verb;
  487. }
  488. if (empty($object_type)) {
  489. $notice->object_type = (empty($notice->reply_to)) ? ActivityObject::NOTE : ActivityObject::COMMENT;
  490. } else {
  491. $notice->object_type = $object_type;
  492. }
  493. if (is_null($scope)) { // 0 is a valid value
  494. if (!empty($reply)) {
  495. $notice->scope = $reply->scope;
  496. } else {
  497. $notice->scope = self::defaultScope();
  498. }
  499. } else {
  500. $notice->scope = $scope;
  501. }
  502. // For private streams
  503. try {
  504. $user = $profile->getUser();
  505. if ($user->private_stream &&
  506. ($notice->scope == Notice::PUBLIC_SCOPE ||
  507. $notice->scope == Notice::SITE_SCOPE)) {
  508. $notice->scope |= Notice::FOLLOWER_SCOPE;
  509. }
  510. } catch (NoSuchUserException $e) {
  511. // Cannot handle private streams for remote profiles
  512. }
  513. // Force the scope for private groups
  514. foreach ($groups as $groupId) {
  515. $group = User_group::getKV('id', $groupId);
  516. if ($group instanceof User_group) {
  517. if ($group->force_scope) {
  518. $notice->scope |= Notice::GROUP_SCOPE;
  519. break;
  520. }
  521. }
  522. }
  523. if (Event::handle('StartNoticeSave', array(&$notice))) {
  524. // XXX: some of these functions write to the DB
  525. $id = $notice->insert();
  526. if (!$id) {
  527. common_log_db_error($notice, 'INSERT', __FILE__);
  528. // TRANS: Server exception thrown when a notice cannot be saved.
  529. throw new ServerException(_('Problem saving notice.'));
  530. }
  531. // Update ID-dependent columns: URI, conversation
  532. $orig = clone($notice);
  533. $changed = false;
  534. // We can only get here if it's a local notice, since remote notices
  535. // should've bailed out earlier due to lacking a URI.
  536. if (empty($notice->uri)) {
  537. $notice->uri = sprintf('%s%s=%d:%s=%s',
  538. TagURI::mint(),
  539. 'noticeId', $notice->id,
  540. 'objectType', $notice->get_object_type(true));
  541. $changed = true;
  542. }
  543. // If it's not part of a conversation, it's
  544. // the beginning of a new conversation.
  545. if (empty($notice->conversation)) {
  546. $conv = Conversation::create($notice);
  547. $notice->conversation = $conv->id;
  548. $changed = true;
  549. }
  550. if ($changed) {
  551. if ($notice->update($orig) === false) {
  552. common_log_db_error($notice, 'UPDATE', __FILE__);
  553. // TRANS: Server exception thrown when a notice cannot be updated.
  554. throw new ServerException(_('Problem saving notice.'));
  555. }
  556. }
  557. }
  558. // Clear the cache for subscribed users, so they'll update at next request
  559. // XXX: someone clever could prepend instead of clearing the cache
  560. $notice->blowOnInsert();
  561. // Save per-notice metadata...
  562. if (isset($replies)) {
  563. $notice->saveKnownReplies($replies);
  564. } else {
  565. $notice->saveReplies();
  566. }
  567. if (isset($tags)) {
  568. $notice->saveKnownTags($tags);
  569. } else {
  570. $notice->saveTags();
  571. }
  572. // Note: groups may save tags, so must be run after tags are saved
  573. // to avoid errors on duplicates.
  574. // Note: groups should always be set.
  575. $notice->saveKnownGroups($groups);
  576. if (isset($urls)) {
  577. $notice->saveKnownUrls($urls);
  578. } else {
  579. $notice->saveUrls();
  580. }
  581. if ($distribute) {
  582. // Prepare inbox delivery, may be queued to background.
  583. $notice->distribute();
  584. }
  585. return $notice;
  586. }
  587. function blowOnInsert($conversation = false)
  588. {
  589. $this->blowStream('profile:notice_ids:%d', $this->profile_id);
  590. if ($this->isPublic()) {
  591. $this->blowStream('public');
  592. }
  593. self::blow('notice:list-ids:conversation:%s', $this->conversation);
  594. self::blow('conversation:notice_count:%d', $this->conversation);
  595. if (!empty($this->repeat_of)) {
  596. // XXX: we should probably only use one of these
  597. $this->blowStream('notice:repeats:%d', $this->repeat_of);
  598. self::blow('notice:list-ids:repeat_of:%d', $this->repeat_of);
  599. }
  600. $original = Notice::getKV('id', $this->repeat_of);
  601. if ($original instanceof Notice) {
  602. $originalUser = User::getKV('id', $original->profile_id);
  603. if ($originalUser instanceof User) {
  604. $this->blowStream('user:repeats_of_me:%d', $originalUser->id);
  605. }
  606. }
  607. $profile = Profile::getKV($this->profile_id);
  608. if ($profile instanceof Profile) {
  609. $profile->blowNoticeCount();
  610. }
  611. $ptags = $this->getProfileTags();
  612. foreach ($ptags as $ptag) {
  613. $ptag->blowNoticeStreamCache();
  614. }
  615. }
  616. /**
  617. * Clear cache entries related to this notice at delete time.
  618. * Necessary to avoid breaking paging on public, profile timelines.
  619. */
  620. function blowOnDelete()
  621. {
  622. $this->blowOnInsert();
  623. self::blow('profile:notice_ids:%d;last', $this->profile_id);
  624. if ($this->isPublic()) {
  625. self::blow('public;last');
  626. }
  627. self::blow('fave:by_notice', $this->id);
  628. if ($this->conversation) {
  629. // In case we're the first, will need to calc a new root.
  630. self::blow('notice:conversation_root:%d', $this->conversation);
  631. }
  632. $ptags = $this->getProfileTags();
  633. foreach ($ptags as $ptag) {
  634. $ptag->blowNoticeStreamCache(true);
  635. }
  636. }
  637. function blowStream()
  638. {
  639. $c = self::memcache();
  640. if (empty($c)) {
  641. return false;
  642. }
  643. $args = func_get_args();
  644. $format = array_shift($args);
  645. $keyPart = vsprintf($format, $args);
  646. $cacheKey = Cache::key($keyPart);
  647. $c->delete($cacheKey);
  648. // delete the "last" stream, too, if this notice is
  649. // older than the top of that stream
  650. $lastKey = $cacheKey.';last';
  651. $lastStr = $c->get($lastKey);
  652. if ($lastStr !== false) {
  653. $window = explode(',', $lastStr);
  654. $lastID = $window[0];
  655. $lastNotice = Notice::getKV('id', $lastID);
  656. if (!$lastNotice instanceof Notice // just weird
  657. || strtotime($lastNotice->created) >= strtotime($this->created)) {
  658. $c->delete($lastKey);
  659. }
  660. }
  661. }
  662. /** save all urls in the notice to the db
  663. *
  664. * follow redirects and save all available file information
  665. * (mimetype, date, size, oembed, etc.)
  666. *
  667. * @return void
  668. */
  669. function saveUrls() {
  670. if (common_config('attachments', 'process_links')) {
  671. common_replace_urls_callback($this->content, array($this, 'saveUrl'), $this->id);
  672. }
  673. }
  674. /**
  675. * Save the given URLs as related links/attachments to the db
  676. *
  677. * follow redirects and save all available file information
  678. * (mimetype, date, size, oembed, etc.)
  679. *
  680. * @return void
  681. */
  682. function saveKnownUrls($urls)
  683. {
  684. if (common_config('attachments', 'process_links')) {
  685. // @fixme validation?
  686. foreach (array_unique($urls) as $url) {
  687. File::processNew($url, $this->id);
  688. }
  689. }
  690. }
  691. /**
  692. * @private callback
  693. */
  694. function saveUrl($url, $notice_id) {
  695. File::processNew($url, $notice_id);
  696. }
  697. static function checkDupes($profile_id, $content) {
  698. $profile = Profile::getKV($profile_id);
  699. if (!$profile instanceof Profile) {
  700. return false;
  701. }
  702. $notice = $profile->getNotices(0, CachingNoticeStream::CACHE_WINDOW);
  703. if (!empty($notice)) {
  704. $last = 0;
  705. while ($notice->fetch()) {
  706. if (time() - strtotime($notice->created) >= common_config('site', 'dupelimit')) {
  707. return true;
  708. } else if ($notice->content == $content) {
  709. return false;
  710. }
  711. }
  712. }
  713. // If we get here, oldest item in cache window is not
  714. // old enough for dupe limit; do direct check against DB
  715. $notice = new Notice();
  716. $notice->profile_id = $profile_id;
  717. $notice->content = $content;
  718. $threshold = common_sql_date(time() - common_config('site', 'dupelimit'));
  719. $notice->whereAdd(sprintf("created > '%s'", $notice->escape($threshold)));
  720. $cnt = $notice->count();
  721. return ($cnt == 0);
  722. }
  723. static function checkEditThrottle($profile_id) {
  724. $profile = Profile::getKV($profile_id);
  725. if (!$profile instanceof Profile) {
  726. return false;
  727. }
  728. // Get the Nth notice
  729. $notice = $profile->getNotices(common_config('throttle', 'count') - 1, 1);
  730. if ($notice && $notice->fetch()) {
  731. // If the Nth notice was posted less than timespan seconds ago
  732. if (time() - strtotime($notice->created) <= common_config('throttle', 'timespan')) {
  733. // Then we throttle
  734. return false;
  735. }
  736. }
  737. // Either not N notices in the stream, OR the Nth was not posted within timespan seconds
  738. return true;
  739. }
  740. protected $_attachments = -1;
  741. function attachments() {
  742. if ($this->_attachments != -1) {
  743. return $this->_attachments;
  744. }
  745. $f2ps = File_to_post::listGet('post_id', array($this->id));
  746. $ids = array();
  747. foreach ($f2ps[$this->id] as $f2p) {
  748. $ids[] = $f2p->file_id;
  749. }
  750. $files = File::multiGet('id', $ids);
  751. $this->_attachments = $files->fetchAll();
  752. return $this->_attachments;
  753. }
  754. function _setAttachments($attachments)
  755. {
  756. $this->_attachments = $attachments;
  757. }
  758. function publicStream($offset=0, $limit=20, $since_id=0, $max_id=0)
  759. {
  760. $stream = new PublicNoticeStream();
  761. return $stream->getNotices($offset, $limit, $since_id, $max_id);
  762. }
  763. function conversationStream($id, $offset=0, $limit=20, $since_id=0, $max_id=0)
  764. {
  765. $stream = new ConversationNoticeStream($id);
  766. return $stream->getNotices($offset, $limit, $since_id, $max_id);
  767. }
  768. /**
  769. * Is this notice part of an active conversation?
  770. *
  771. * @return boolean true if other messages exist in the same
  772. * conversation, false if this is the only one
  773. */
  774. function hasConversation()
  775. {
  776. if (!empty($this->conversation)) {
  777. $conversation = Notice::conversationStream(
  778. $this->conversation,
  779. 1,
  780. 1
  781. );
  782. if ($conversation->N > 0) {
  783. return true;
  784. }
  785. }
  786. return false;
  787. }
  788. /**
  789. * Grab the earliest notice from this conversation.
  790. *
  791. * @return Notice or null
  792. */
  793. function conversationRoot($profile=-1)
  794. {
  795. // XXX: can this happen?
  796. if (empty($this->conversation)) {
  797. return null;
  798. }
  799. // Get the current profile if not specified
  800. if (is_int($profile) && $profile == -1) {
  801. $profile = Profile::current();
  802. }
  803. // If this notice is out of scope, no root for you!
  804. if (!$this->inScope($profile)) {
  805. return null;
  806. }
  807. // If this isn't a reply to anything, then it's its own
  808. // root.
  809. if (empty($this->reply_to)) {
  810. return $this;
  811. }
  812. if (is_null($profile)) {
  813. $keypart = sprintf('notice:conversation_root:%d:null', $this->id);
  814. } else {
  815. $keypart = sprintf('notice:conversation_root:%d:%d',
  816. $this->id,
  817. $profile->id);
  818. }
  819. $root = self::cacheGet($keypart);
  820. if ($root !== false && $root->inScope($profile)) {
  821. return $root;
  822. }
  823. $last = $this;
  824. while (true) {
  825. try {
  826. $parent = $last->getParent();
  827. if ($parent->inScope($profile)) {
  828. $last = $parent;
  829. continue;
  830. }
  831. } catch (Exception $e) {
  832. // Latest notice has no parent
  833. }
  834. // No parent, or parent out of scope
  835. $root = $last;
  836. break;
  837. }
  838. self::cacheSet($keypart, $root);
  839. return $root;
  840. }
  841. /**
  842. * Pull up a full list of local recipients who will be getting
  843. * this notice in their inbox. Results will be cached, so don't
  844. * change the input data wily-nilly!
  845. *
  846. * @param array $groups optional list of Group objects;
  847. * if left empty, will be loaded from group_inbox records
  848. * @param array $recipient optional list of reply profile ids
  849. * if left empty, will be loaded from reply records
  850. * @return array associating recipient user IDs with an inbox source constant
  851. */
  852. function whoGets(array $groups=null, array $recipients=null)
  853. {
  854. $c = self::memcache();
  855. if (!empty($c)) {
  856. $ni = $c->get(Cache::key('notice:who_gets:'.$this->id));
  857. if ($ni !== false) {
  858. return $ni;
  859. }
  860. }
  861. if (is_null($recipients)) {
  862. $recipients = $this->getReplies();
  863. }
  864. $ni = array();
  865. // Give plugins a chance to add folks in at start...
  866. if (Event::handle('StartNoticeWhoGets', array($this, &$ni))) {
  867. $users = $this->getSubscribedUsers();
  868. foreach ($users as $id) {
  869. $ni[$id] = NOTICE_INBOX_SOURCE_SUB;
  870. }
  871. if (is_null($groups)) {
  872. $groups = $this->getGroups();
  873. }
  874. foreach ($groups as $group) {
  875. $users = $group->getUserMembers();
  876. foreach ($users as $id) {
  877. if (!array_key_exists($id, $ni)) {
  878. $ni[$id] = NOTICE_INBOX_SOURCE_GROUP;
  879. }
  880. }
  881. }
  882. $ptAtts = $this->getAttentionsFromProfileTags();
  883. foreach ($ptAtts as $key=>$val) {
  884. if (!array_key_exists($key, $ni)) {
  885. $ni[$key] = $val;
  886. }
  887. }
  888. foreach ($recipients as $recipient) {
  889. if (!array_key_exists($recipient, $ni)) {
  890. $ni[$recipient] = NOTICE_INBOX_SOURCE_REPLY;
  891. }
  892. }
  893. // Exclude any deleted, non-local, or blocking recipients.
  894. $profile = $this->getProfile();
  895. $originalProfile = null;
  896. if ($this->repeat_of) {
  897. // Check blocks against the original notice's poster as well.
  898. $original = Notice::getKV('id', $this->repeat_of);
  899. if ($original instanceof Notice) {
  900. $originalProfile = $original->getProfile();
  901. }
  902. }
  903. foreach ($ni as $id => $source) {
  904. try {
  905. $user = User::getKV('id', $id);
  906. if (!$user instanceof User ||
  907. $user->hasBlocked($profile) ||
  908. ($originalProfile && $user->hasBlocked($originalProfile))) {
  909. unset($ni[$id]);
  910. }
  911. } catch (UserNoProfileException $e) {
  912. // User doesn't have a profile; invalid; skip them.
  913. unset($ni[$id]);
  914. }
  915. }
  916. // Give plugins a chance to filter out...
  917. Event::handle('EndNoticeWhoGets', array($this, &$ni));
  918. }
  919. if (!empty($c)) {
  920. // XXX: pack this data better
  921. $c->set(Cache::key('notice:who_gets:'.$this->id), $ni);
  922. }
  923. return $ni;
  924. }
  925. function getSubscribedUsers()
  926. {
  927. $user = new User();
  928. if(common_config('db','quote_identifiers'))
  929. $user_table = '"user"';
  930. else $user_table = 'user';
  931. $qry =
  932. 'SELECT id ' .
  933. 'FROM '. $user_table .' JOIN subscription '.
  934. 'ON '. $user_table .'.id = subscription.subscriber ' .
  935. 'WHERE subscription.subscribed = %d ';
  936. $user->query(sprintf($qry, $this->profile_id));
  937. $ids = array();
  938. while ($user->fetch()) {
  939. $ids[] = $user->id;
  940. }
  941. $user->free();
  942. return $ids;
  943. }
  944. function getProfileTags()
  945. {
  946. $profile = $this->getProfile();
  947. $list = $profile->getOtherTags($profile);
  948. $ptags = array();
  949. while($list->fetch()) {
  950. $ptags[] = clone($list);
  951. }
  952. return $ptags;
  953. }
  954. public function getAttentionsFromProfileTags()
  955. {
  956. $ni = array();
  957. $ptags = $this->getProfileTags();
  958. foreach ($ptags as $ptag) {
  959. $users = $ptag->getUserSubscribers();
  960. foreach ($users as $id) {
  961. $ni[$id] = NOTICE_INBOX_SOURCE_PROFILE_TAG;
  962. }
  963. }
  964. return $ni;
  965. }
  966. /**
  967. * Record this notice to the given group inboxes for delivery.
  968. * Overrides the regular parsing of !group markup.
  969. *
  970. * @param string $group_ids
  971. * @fixme might prefer URIs as identifiers, as for replies?
  972. * best with generalizations on user_group to support
  973. * remote groups better.
  974. */
  975. function saveKnownGroups($group_ids)
  976. {
  977. if (!is_array($group_ids)) {
  978. // TRANS: Server exception thrown when no array is provided to the method saveKnownGroups().
  979. throw new ServerException(_('Bad type provided to saveKnownGroups.'));
  980. }
  981. $groups = array();
  982. foreach (array_unique($group_ids) as $id) {
  983. $group = User_group::getKV('id', $id);
  984. if ($group instanceof User_group) {
  985. common_log(LOG_ERR, "Local delivery to group id $id, $group->nickname");
  986. $result = $this->addToGroupInbox($group);
  987. if (!$result) {
  988. common_log_db_error($gi, 'INSERT', __FILE__);
  989. }
  990. if (common_config('group', 'addtag')) {
  991. // we automatically add a tag for every group name, too
  992. $tag = Notice_tag::pkeyGet(array('tag' => common_canonical_tag($group->nickname),
  993. 'notice_id' => $this->id));
  994. if (is_null($tag)) {
  995. $this->saveTag($group->nickname);
  996. }
  997. }
  998. $groups[] = clone($group);
  999. } else {
  1000. common_log(LOG_ERR, "Local delivery to group id $id skipped, doesn't exist");
  1001. }
  1002. }
  1003. return $groups;
  1004. }
  1005. function addToGroupInbox(User_group $group)
  1006. {
  1007. $gi = Group_inbox::pkeyGet(array('group_id' => $group->id,
  1008. 'notice_id' => $this->id));
  1009. if (!$gi instanceof Group_inbox) {
  1010. $gi = new Group_inbox();
  1011. $gi->group_id = $group->id;
  1012. $gi->notice_id = $this->id;
  1013. $gi->created = $this->created;
  1014. $result = $gi->insert();
  1015. if (!$result) {
  1016. common_log_db_error($gi, 'INSERT', __FILE__);
  1017. // TRANS: Server exception thrown when an update for a group inbox fails.
  1018. throw new ServerException(_('Problem saving group inbox.'));
  1019. }
  1020. self::blow('user_group:notice_ids:%d', $gi->group_id);
  1021. }
  1022. return true;
  1023. }
  1024. /**
  1025. * Save reply records indicating that this notice needs to be
  1026. * delivered to the local users with the given URIs.
  1027. *
  1028. * Since this is expected to be used when saving foreign-sourced
  1029. * messages, we won't deliver to any remote targets as that's the
  1030. * source service's responsibility.
  1031. *
  1032. * Mail notifications etc will be handled later.
  1033. *
  1034. * @param array $uris Array of unique identifier URIs for recipients
  1035. */
  1036. function saveKnownReplies(array $uris)
  1037. {
  1038. if (empty($uris)) {
  1039. return;
  1040. }
  1041. $sender = Profile::getKV($this->profile_id);
  1042. foreach (array_unique($uris) as $uri) {
  1043. try {
  1044. $profile = Profile::fromUri($uri);
  1045. } catch (UnknownUriException $e) {
  1046. common_log(LOG_WARNING, "Unable to determine profile for URI '$uri'");
  1047. continue;
  1048. }
  1049. if ($profile->hasBlocked($sender)) {
  1050. common_log(LOG_INFO, "Not saving reply to profile {$profile->id} ($uri) from sender {$sender->id} because of a block.");
  1051. continue;
  1052. }
  1053. $this->saveReply($profile->id);
  1054. self::blow('reply:stream:%d', $profile->id);
  1055. }
  1056. return;
  1057. }
  1058. /**
  1059. * Pull @-replies from this message's content in StatusNet markup format
  1060. * and save reply records indicating that this message needs to be
  1061. * delivered to those users.
  1062. *
  1063. * Mail notifications to local profiles will be sent later.
  1064. *
  1065. * @return array of integer profile IDs
  1066. */
  1067. function saveReplies()
  1068. {
  1069. // Don't save reply data for repeats
  1070. if (!empty($this->repeat_of)) {
  1071. return array();
  1072. }
  1073. $sender = Profile::getKV($this->profile_id);
  1074. $replied = array();
  1075. // If it's a reply, save for the replied-to author
  1076. try {
  1077. $parent = $this->getParent();
  1078. $author = $parent->getProfile();
  1079. if ($author instanceof Profile) {
  1080. $this->saveReply($author->id);
  1081. $replied[$author->id] = 1;
  1082. self::blow('reply:stream:%d', $author->id);
  1083. }
  1084. } catch (Exception $e) {
  1085. // Not a reply, since it has no parent!
  1086. }
  1087. // @todo ideally this parser information would only
  1088. // be calculated once.
  1089. $mentions = common_find_mentions($this->content, $this);
  1090. // store replied only for first @ (what user/notice what the reply directed,
  1091. // we assume first @ is it)
  1092. foreach ($mentions as $mention) {
  1093. foreach ($mention['mentioned'] as $mentioned) {
  1094. // skip if they're already covered
  1095. if (!empty($replied[$mentioned->id])) {
  1096. continue;
  1097. }
  1098. // Don't save replies from blocked profile to local user
  1099. $mentioned_user = User::getKV('id', $mentioned->id);
  1100. if ($mentioned_user instanceof User && $mentioned_user->hasBlocked($sender)) {
  1101. continue;
  1102. }
  1103. $this->saveReply($mentioned->id);
  1104. $replied[$mentioned->id] = 1;
  1105. self::blow('reply:stream:%d', $mentioned->id);
  1106. }
  1107. }
  1108. $recipientIds = array_keys($replied);
  1109. return $recipientIds;
  1110. }
  1111. function saveReply($profileId)
  1112. {
  1113. $reply = new Reply();
  1114. $reply->notice_id = $this->id;
  1115. $reply->profile_id = $profileId;
  1116. $reply->modified = $this->created;
  1117. $reply->insert();
  1118. return $reply;
  1119. }
  1120. protected $_replies = -1;
  1121. /**
  1122. * Pull the complete list of @-reply targets for this notice.
  1123. *
  1124. * @return array of integer profile ids
  1125. */
  1126. function getReplies()
  1127. {
  1128. if ($this->_replies != -1) {
  1129. return $this->_replies;
  1130. }
  1131. $replyMap = Reply::listGet('notice_id', array($this->id));
  1132. $ids = array();
  1133. foreach ($replyMap[$this->id] as $reply) {
  1134. $ids[] = $reply->profile_id;
  1135. }
  1136. $this->_replies = $ids;
  1137. return $ids;
  1138. }
  1139. function _setReplies($replies)
  1140. {
  1141. $this->_replies = $replies;
  1142. }
  1143. /**
  1144. * Pull the complete list of @-reply targets for this notice.
  1145. *
  1146. * @return array of Profiles
  1147. */
  1148. function getReplyProfiles()
  1149. {
  1150. $ids = $this->getReplies();
  1151. $profiles = Profile::multiGet('id', $ids);
  1152. return $profiles->fetchAll();
  1153. }
  1154. /**
  1155. * Send e-mail notifications to local @-reply targets.
  1156. *
  1157. * Replies must already have been saved; this is expected to be run
  1158. * from the distrib queue handler.
  1159. */
  1160. function sendReplyNotifications()
  1161. {
  1162. // Don't send reply notifications for repeats
  1163. if (!empty($this->repeat_of)) {
  1164. return array();
  1165. }
  1166. $recipientIds = $this->getReplies();
  1167. foreach ($recipientIds as $recipientId) {
  1168. $user = User::getKV('id', $recipientId);
  1169. if ($user instanceof User) {
  1170. mail_notify_attn($user, $this);
  1171. }
  1172. }
  1173. }
  1174. /**
  1175. * Pull list of groups this notice needs to be delivered to,
  1176. * as previously recorded by saveKnownGroups().
  1177. *
  1178. * @return array of Group objects
  1179. */
  1180. protected $_groups = -1;
  1181. function getGroups()
  1182. {
  1183. // Don't save groups for repeats
  1184. if (!empty($this->repeat_of)) {
  1185. return array();
  1186. }
  1187. if ($this->_groups != -1)
  1188. {
  1189. return $this->_groups;
  1190. }
  1191. $gis = Group_inbox::listGet('notice_id', array($this->id));
  1192. $ids = array();
  1193. foreach ($gis[$this->id] as $gi)
  1194. {
  1195. $ids[] = $gi->group_id;
  1196. }
  1197. $groups = User_group::multiGet('id', $ids);
  1198. $this->_groups = $groups->fetchAll();
  1199. return $this->_groups;
  1200. }
  1201. function _setGroups($groups)
  1202. {
  1203. $this->_groups = $groups;
  1204. }
  1205. /**
  1206. * Convert a notice into an activity for export.
  1207. *
  1208. * @param User $cur Current user
  1209. *
  1210. * @return Activity activity object representing this Notice.
  1211. */
  1212. function asActivity($cur=null)
  1213. {
  1214. $act = self::cacheGet(Cache::codeKey('notice:as-activity:'.$this->id));
  1215. if ($act instanceof Activity) {
  1216. return $act;
  1217. }
  1218. $act = new Activity();
  1219. if (Event::handle('StartNoticeAsActivity', array($this, &$act))) {
  1220. $act->id = $this->uri;
  1221. $act->time = strtotime($this->created);
  1222. try {
  1223. $act->link = $this->getUrl();
  1224. } catch (InvalidUrlException $e) {
  1225. // The notice is probably a share or similar, which don't
  1226. // have a representational URL of their own.
  1227. }
  1228. $act->content = common_xml_safe_str($this->rendered);
  1229. $profile = $this->getProfile();
  1230. $act->actor = ActivityObject::fromProfile($profile);
  1231. $act->actor->extra[] = $profile->profileInfo($cur);
  1232. $act->verb = $this->verb;
  1233. if ($this->repeat_of) {
  1234. $repeated = Notice::getKV('id', $this->repeat_of);
  1235. if ($repeated instanceof Notice) {
  1236. $act->objects[] = $repeated->asActivity($cur);
  1237. }
  1238. } else {
  1239. $act->objects[] = ActivityObject::fromNotice($this);
  1240. }
  1241. // XXX: should this be handled by default processing for object entry?
  1242. // Categories
  1243. $tags = $this->getTags();
  1244. foreach ($tags as $tag) {
  1245. $cat = new AtomCategory();
  1246. $cat->term = $tag;
  1247. $act->categories[] = $cat;
  1248. }
  1249. // Enclosures
  1250. // XXX: use Atom Media and/or File activity objects instead
  1251. $attachments = $this->attachments();
  1252. foreach ($attachments as $attachment) {
  1253. // Save local attachments
  1254. if (!empty($attachment->filename)) {
  1255. $act->attachments[] = ActivityObject::fromFile($attachment);
  1256. }
  1257. }
  1258. $ctx = new ActivityContext();
  1259. try {
  1260. $reply = $this->getParent();
  1261. $ctx->replyToID = $reply->getUri();
  1262. $ctx->replyToUrl = $reply->getUrl();
  1263. } catch (Exception $e) {
  1264. // This is not a reply to something
  1265. }
  1266. $ctx->location = $this->getLocation();
  1267. $conv = null;
  1268. if (!empty($this->conversation)) {
  1269. $conv = Conversation::getKV('id', $this->conversation);
  1270. if ($conv instanceof Conversation) {
  1271. $ctx->conversation = $conv->uri;
  1272. }
  1273. }
  1274. $reply_ids = $this->getReplies();
  1275. foreach ($reply_ids as $id) {
  1276. $rprofile = Profile::getKV('id', $id);
  1277. if ($rprofile instanceof Profile) {
  1278. $ctx->attention[$rprofile->getUri()] = ActivityObject::PERSON;
  1279. }
  1280. }
  1281. $groups = $this->getGroups();
  1282. foreach ($groups as $group) {
  1283. $ctx->attention[$group->getUri()] = ActivityObject::GROUP;
  1284. }
  1285. switch ($this->scope) {
  1286. case Notice::PUBLIC_SCOPE:
  1287. $ctx->attention[ActivityContext::ATTN_PUBLIC] = ActivityObject::COLLECTION;
  1288. break;
  1289. case Notice::FOLLOWER_SCOPE:
  1290. $surl = common_local_url("subscribers", array('nickname' => $profile->nickname));
  1291. $ctx->attention[$surl] = ActivityObject::COLLECTION;
  1292. break;
  1293. }
  1294. $act->context = $ctx;
  1295. $source = $this->getSource();
  1296. if ($source instanceof Notice_source) {
  1297. $act->generator = ActivityObject::fromNoticeSource($source);
  1298. }
  1299. // Source
  1300. $atom_feed = $profile->getAtomFeed();
  1301. if (!empty($atom_feed)) {
  1302. $act->source = new ActivitySource();
  1303. // XXX: we should store the actual feed ID
  1304. $act->source->id = $atom_feed;
  1305. // XXX: we should store the actual feed title
  1306. $act->source->title = $profile->getBestName();
  1307. $act->source->links['alternate'] = $profile->profileurl;
  1308. $act->source->links['self'] = $atom_feed;
  1309. $act->source->icon = $profile->avatarUrl(AVATAR_PROFILE_SIZE);
  1310. $notice = $profile->getCurrentNotice();
  1311. if ($notice instanceof Notice) {
  1312. $act->source->updated = self::utcDate($notice->created);
  1313. }
  1314. $user = User::getKV('id', $profile->id);
  1315. if ($user instanceof User) {
  1316. $act->source->links['license'] = common_config('license', 'url');
  1317. }
  1318. }
  1319. if ($this->isLocal()) {
  1320. $act->selfLink = common_local_url('ApiStatusesShow', array('id' => $this->id,
  1321. 'format' => 'atom'));
  1322. $act->editLink = $act->selfLink;
  1323. }
  1324. Event::handle('EndNoticeAsActivity', array($this, &$act));
  1325. }
  1326. self::cacheSet(Cache::codeKey('notice:as-activity:'.$this->id), $act);
  1327. return $act;
  1328. }
  1329. // This has gotten way too long. Needs to be sliced up into functional bits
  1330. // or ideally exported to a utility class.
  1331. function asAtomEntry($namespace=false,
  1332. $source=false,
  1333. $author=true,
  1334. $cur=null)
  1335. {
  1336. $act = $this->asActivity($cur);
  1337. $act->extra[] = $this->noticeInfo($cur);
  1338. return $act->asString($namespace, $author, $source);
  1339. }
  1340. /**
  1341. * Extra notice info for atom entries
  1342. *
  1343. * Clients use some extra notice info in the atom stream.
  1344. * This gives it to them.
  1345. *
  1346. * @param User $cur Current user
  1347. *
  1348. * @return array representation of <statusnet:notice_info> element
  1349. */
  1350. function noticeInfo($cur)
  1351. {
  1352. // local notice ID (useful to clients for ordering)
  1353. $noticeInfoAttr = array('local_id' => $this->id);
  1354. // notice source
  1355. $ns = $this->getSource();
  1356. if ($ns instanceof Notice_source) {
  1357. $noticeInfoAttr['source'] = $ns->code;
  1358. if (!empty($ns->url)) {
  1359. $noticeInfoAttr['source_link'] = $ns->url;
  1360. if (!empty($ns->name)) {
  1361. $noticeInfoAttr['source'] = '<a href="'
  1362. . htmlspecialchars($ns->url)
  1363. . '" rel="nofollow">'
  1364. . htmlspecialchars($ns->name)
  1365. . '</a>';
  1366. }
  1367. }
  1368. }
  1369. // favorite and repeated
  1370. if (!empty($cur)) {
  1371. $cp = $cur->getProfile();
  1372. $noticeInfoAttr['favorite'] = ($cp->hasFave($this)) ? "true" : "false";
  1373. $noticeInfoAttr['repeated'] = ($cp->hasRepeated($this)) ? "true" : "false";
  1374. }
  1375. if (!empty($this->repeat_of)) {
  1376. $noticeInfoAttr['repeat_of'] = $this->repeat_of;
  1377. }
  1378. return array('statusnet:notice_info', $noticeInfoAttr, null);
  1379. }
  1380. /**
  1381. * Returns an XML string fragment with a reference to a notice as an
  1382. * Activity Streams noun object with the given element type.
  1383. *
  1384. * Assumes that 'activity' namespace has been previously defined.
  1385. *
  1386. * @param string $element one of 'subject', 'object', 'target'
  1387. * @return string
  1388. */
  1389. function asActivityNoun($element)
  1390. {
  1391. $noun = ActivityObject::fromNotice($this);
  1392. return $noun->asString('activity:' . $element);
  1393. }
  1394. /**
  1395. * Determine which notice, if any, a new notice is in reply to.
  1396. *
  1397. * For conversation tracking, we try to see where this notice fits
  1398. * in the tree. Rough algorithm is:
  1399. *
  1400. * if (reply_to is set and valid) {
  1401. * return reply_to;
  1402. * } else if ((source not API or Web) and (content starts with "T NAME" or "@name ")) {
  1403. * return ID of last notice by initial @name in content;
  1404. * }
  1405. *
  1406. * Note that all @nickname instances will still be used to save "reply" records,
  1407. * so the notice shows up in the mentioned users' "replies" tab.
  1408. *
  1409. * @param integer $reply_to ID passed in by Web or API
  1410. * @param integer $profile_id ID of author
  1411. * @param string $source Source tag, like 'web' or 'gwibber'
  1412. * @param string $content Final notice content
  1413. *
  1414. * @return integer ID of replied-to notice, or null for not a reply.
  1415. */
  1416. static function getReplyTo($reply_to, $profile_id, $source, $content)
  1417. {
  1418. static $lb = array('xmpp', 'mail', 'sms', 'omb');
  1419. // If $reply_to is specified, we check that it exists, and then
  1420. // return it if it does
  1421. if (!empty($reply_to)) {
  1422. $reply_notice = Notice::getKV('id', $reply_to);
  1423. if ($reply_notice instanceof Notice) {
  1424. return $reply_notice;
  1425. }
  1426. }
  1427. // If it's not a "low bandwidth" source (one where you can't set
  1428. // a reply_to argument), we return. This is mostly web and API
  1429. // clients.
  1430. if (!in_array($source, $lb)) {
  1431. return null;
  1432. }
  1433. // Is there an initial @ or T?
  1434. if (preg_match('/^T ([A-Z0-9]{1,64}) /', $content, $match) ||
  1435. preg_match('/^@([a-z0-9]{1,64})\s+/', $content, $match)) {
  1436. $nickname = common_canonical_nickname($match[1]);
  1437. } else {
  1438. return null;
  1439. }
  1440. // Figure out who that is.
  1441. $sender = Profile::getKV('id', $profile_id);
  1442. if (!$sender instanceof Profile) {
  1443. return null;
  1444. }
  1445. $recipient = common_relative_profile($sender, $nickname, common_sql_now());
  1446. if (!$recipient instanceof Profile) {
  1447. return null;
  1448. }
  1449. // Get their last notice
  1450. $last = $recipient->getCurrentNotice();
  1451. if ($last instanceof Notice) {
  1452. return $last;
  1453. }
  1454. return null;
  1455. }
  1456. static function maxContent()
  1457. {
  1458. $contentlimit = common_config('notice', 'contentlimit');
  1459. // null => use global limit (distinct from 0!)
  1460. if (is_null($contentlimit)) {
  1461. $contentlimit = common_config('site', 'textlimit');
  1462. }
  1463. return $contentlimit;
  1464. }
  1465. static function contentTooLong($content)
  1466. {
  1467. $contentlimit = self::maxContent();
  1468. return ($contentlimit > 0 && !empty($content) && (mb_strlen($content) > $contentlimit));
  1469. }
  1470. function getLocation()
  1471. {
  1472. $location = null;
  1473. if (!empty($this->location_id) && !empty($this->location_ns)) {
  1474. $location = Location::fromId($this->location_id, $this->location_ns);
  1475. }
  1476. if (is_null($location)) { // no ID, or Location::fromId() failed
  1477. if (!empty($this->lat) && !empty($this->lon)) {
  1478. $location = Location::fromLatLon($this->lat, $this->lon);
  1479. }
  1480. }
  1481. return $location;
  1482. }
  1483. /**
  1484. * Convenience function for posting a repeat of an existing message.
  1485. *
  1486. * @param Profile $repeater Profile which is doing the repeat
  1487. * @param string $source: posting source key, eg 'web', 'api', etc
  1488. * @return Notice
  1489. *
  1490. * @throws Exception on failure or permission problems
  1491. */
  1492. function repeat(Profile $repeater, $source)
  1493. {
  1494. $author = $this->getProfile();
  1495. // TRANS: Message used to repeat a notice. RT is the abbreviation of 'retweet'.
  1496. // TRANS: %1$s is the repeated user's name, %2$s is the repeated notice.
  1497. $content = sprintf(_('RT @%1$s %2$s'),
  1498. $author->getNickname(),
  1499. $this->content);
  1500. // Scope is same as this one's
  1501. return self::saveNew($repeater->id,
  1502. $content,
  1503. $source,
  1504. array('repeat_of' => $this->id,
  1505. 'scope' => $this->scope));
  1506. }
  1507. // These are supposed to be in chron order!
  1508. function repeatStream($limit=100)
  1509. {
  1510. $cache = Cache::instance();
  1511. if (empty($cache)) {
  1512. $ids = $this->_repeatStreamDirect($limit);
  1513. } else {
  1514. $idstr = $cache->get(Cache::key('notice:repeats:'.$this->id));
  1515. if ($idstr !== false) {
  1516. if (empty($idstr)) {
  1517. $ids = array();
  1518. } else {
  1519. $ids = explode(',', $idstr);
  1520. }
  1521. } else {
  1522. $ids = $this->_repeatStreamDirect(100);
  1523. $cache->set(Cache::key('notice:repeats:'.$this->id), implode(',', $ids));
  1524. }
  1525. if ($limit < 100) {
  1526. // We do a max of 100, so slice down to limit
  1527. $ids = array_slice($ids, 0, $limit);
  1528. }
  1529. }
  1530. return NoticeStream::getStreamByIds($ids);
  1531. }
  1532. function _repeatStreamDirect($limit)
  1533. {
  1534. $notice = new Notice();
  1535. $notice->selectAdd(); // clears it
  1536. $notice->selectAdd('id');
  1537. $notice->repeat_of = $this->id;
  1538. $notice->orderBy('created, id'); // NB: asc!
  1539. if (!is_null($limit)) {
  1540. $notice->limit(0, $limit);
  1541. }
  1542. return $notice->fetchAll('id');
  1543. }
  1544. function locationOptions($lat, $lon, $location_id, $location_ns, $profile = null)
  1545. {
  1546. $options = array();
  1547. if (!empty($location_id) && !empty($location_ns)) {
  1548. $options['location_id'] = $location_id;
  1549. $options['location_ns'] = $location_ns;
  1550. $location = Location::fromId($location_id, $location_ns);
  1551. if ($location instanceof Location) {
  1552. $options['lat'] = $location->lat;
  1553. $options['lon'] = $location->lon;
  1554. }
  1555. } else if (!empty($lat) && !empty($lon)) {
  1556. $options['lat'] = $lat;
  1557. $options['lon'] = $lon;
  1558. $location = Location::fromLatLon($lat, $lon);
  1559. if ($location instanceof Location) {
  1560. $options['location_id'] = $location->location_id;
  1561. $options['location_ns'] = $location->location_ns;
  1562. }
  1563. } else if (!empty($profile)) {
  1564. if (isset($profile->lat) && isset($profile->lon)) {
  1565. $options['lat'] = $profile->lat;
  1566. $options['lon'] = $profile->lon;
  1567. }
  1568. if (isset($profile->location_id) && isset($profile->location_ns)) {
  1569. $options['location_id'] = $profile->location_id;
  1570. $options['location_ns'] = $profile->location_ns;
  1571. }
  1572. }
  1573. return $options;
  1574. }
  1575. function clearAttentions()
  1576. {
  1577. $att = new Attention();
  1578. $att->notice_id = $this->getID();
  1579. if ($att->find()) {
  1580. while ($att->fetch()) {
  1581. // Can't do delete() on the object directly since it won't remove all of it
  1582. $other = clone($att);
  1583. $other->delete();
  1584. }
  1585. }
  1586. }
  1587. function clearReplies()
  1588. {
  1589. $replyNotice = new Notice();
  1590. $replyNotice->reply_to = $this->id;
  1591. //Null any notices that are replies to this notice
  1592. if ($replyNotice->find()) {
  1593. while ($replyNotice->fetch()) {
  1594. $orig = clone($replyNotice);
  1595. $replyNotice->reply_to = null;
  1596. $replyNotice->update($orig);
  1597. }
  1598. }
  1599. // Reply records
  1600. $reply = new Reply();
  1601. $reply->notice_id = $this->id;
  1602. if ($reply->find()) {
  1603. while($reply->fetch()) {
  1604. self::blow('reply:stream:%d', $reply->profile_id);
  1605. $reply->delete();
  1606. }
  1607. }
  1608. $reply->free();
  1609. }
  1610. function clearFiles()
  1611. {
  1612. $f2p = new File_to_post();
  1613. $f2p->post_id = $this->id;
  1614. if ($f2p->find()) {
  1615. while ($f2p->fetch()) {
  1616. $f2p->delete();
  1617. }
  1618. }
  1619. // FIXME: decide whether to delete File objects
  1620. // ...and related (actual) files
  1621. }
  1622. function clearRepeats()
  1623. {
  1624. $repeatNotice = new Notice();
  1625. $repeatNotice->repeat_of = $this->id;
  1626. //Null any notices that are repeats of this notice
  1627. if ($repeatNotice->find()) {
  1628. while ($repeatNotice->fetch()) {
  1629. $orig = clone($repeatNotice);
  1630. $repeatNotice->repeat_of = null;
  1631. $repeatNotice->update($orig);
  1632. }
  1633. }
  1634. }
  1635. function clearFaves()
  1636. {
  1637. $fave = new Fave();
  1638. $fave->notice_id = $this->id;
  1639. if ($fave->find()) {
  1640. while ($fave->fetch()) {
  1641. self::blow('fave:ids_by_user_own:%d', $fave->user_id);
  1642. self::blow('fave:ids_by_user_own:%d;last', $fave->user_id);
  1643. self::blow('fave:ids_by_user:%d', $fave->user_id);
  1644. self::blow('fave:ids_by_user:%d;last', $fave->user_id);
  1645. $fave->delete();
  1646. }
  1647. }
  1648. $fave->free();
  1649. }
  1650. function clearTags()
  1651. {
  1652. $tag = new Notice_tag();
  1653. $tag->notice_id = $this->id;
  1654. if ($tag->find()) {
  1655. while ($tag->fetch()) {
  1656. self::blow('profile:notice_ids_tagged:%d:%s', $this->profile_id, Cache::keyize($tag->tag));
  1657. self::blow('profile:notice_ids_tagged:%d:%s;last', $this->profile_id, Cache::keyize($tag->tag));
  1658. self::blow('notice_tag:notice_ids:%s', Cache::keyize($tag->tag));
  1659. self::blow('notice_tag:notice_ids:%s;last', Cache::keyize($tag->tag));
  1660. $tag->delete();
  1661. }
  1662. }
  1663. $tag->free();
  1664. }
  1665. function clearGroupInboxes()
  1666. {
  1667. $gi = new Group_inbox();
  1668. $gi->notice_id = $this->id;
  1669. if ($gi->find()) {
  1670. while ($gi->fetch()) {
  1671. self::blow('user_group:notice_ids:%d', $gi->group_id);
  1672. $gi->delete();
  1673. }
  1674. }
  1675. $gi->free();
  1676. }
  1677. function distribute()
  1678. {
  1679. // We always insert for the author so they don't
  1680. // have to wait
  1681. Event::handle('StartNoticeDistribute', array($this));
  1682. // If there's a failure, we want to _force_
  1683. // distribution at this point.
  1684. try {
  1685. $qm = QueueManager::get();
  1686. $qm->enqueue($this, 'distrib');
  1687. } catch (Exception $e) {
  1688. // If the exception isn't transient, this
  1689. // may throw more exceptions as DQH does
  1690. // its own enqueueing. So, we ignore them!
  1691. try {
  1692. $handler = new DistribQueueHandler();
  1693. $handler->handle($this);
  1694. } catch (Exception $e) {
  1695. common_log(LOG_ERR, "emergency redistribution resulted in " . $e->getMessage());
  1696. }
  1697. // Re-throw so somebody smarter can handle it.
  1698. throw $e;
  1699. }
  1700. }
  1701. function insert()
  1702. {
  1703. $result = parent::insert();
  1704. if ($result !== false) {
  1705. // Profile::hasRepeated() abuses pkeyGet(), so we
  1706. // have to clear manually
  1707. if (!empty($this->repeat_of)) {
  1708. $c = self::memcache();
  1709. if (!empty($c)) {
  1710. $ck = self::multicacheKey('Notice',
  1711. array('profile_id' => $this->profile_id,
  1712. 'repeat_of' => $this->repeat_of));
  1713. $c->delete($ck);
  1714. }
  1715. }
  1716. }
  1717. return $result;
  1718. }
  1719. /**
  1720. * Get the source of the notice
  1721. *
  1722. * @return Notice_source $ns A notice source object. 'code' is the only attribute
  1723. * guaranteed to be populated.
  1724. */
  1725. function getSource()
  1726. {
  1727. $ns = new Notice_source();
  1728. if (!empty($this->source)) {
  1729. switch ($this->source) {
  1730. case 'web':
  1731. case 'xmpp':
  1732. case 'mail':
  1733. case 'omb':
  1734. case 'system':
  1735. case 'api':
  1736. $ns->code = $this->source;
  1737. break;
  1738. default:
  1739. $ns = Notice_source::getKV($this->source);
  1740. if (!$ns) {
  1741. $ns = new Notice_source();
  1742. $ns->code = $this->source;
  1743. $app = Oauth_application::getKV('name', $this->source);
  1744. if ($app) {
  1745. $ns->name = $app->name;
  1746. $ns->url = $app->source_url;
  1747. }
  1748. }
  1749. break;
  1750. }
  1751. }
  1752. return $ns;
  1753. }
  1754. /**
  1755. * Determine whether the notice was locally created
  1756. *
  1757. * @return boolean locality
  1758. */
  1759. public function isLocal()
  1760. {
  1761. return ($this->is_local == Notice::LOCAL_PUBLIC ||
  1762. $this->is_local == Notice::LOCAL_NONPUBLIC);
  1763. }
  1764. /**
  1765. * Get the list of hash tags saved with this notice.
  1766. *
  1767. * @return array of strings
  1768. */
  1769. public function getTags()
  1770. {
  1771. $tags = array();
  1772. $keypart = sprintf('notice:tags:%d', $this->id);
  1773. $tagstr = self::cacheGet($keypart);
  1774. if ($tagstr !== false) {
  1775. $tags = explode(',', $tagstr);
  1776. } else {
  1777. $tag = new Notice_tag();
  1778. $tag->notice_id = $this->id;
  1779. if ($tag->find()) {
  1780. while ($tag->fetch()) {
  1781. $tags[] = $tag->tag;
  1782. }
  1783. }
  1784. self::cacheSet($keypart, implode(',', $tags));
  1785. }
  1786. return $tags;
  1787. }
  1788. static private function utcDate($dt)
  1789. {
  1790. $dateStr = date('d F Y H:i:s', strtotime($dt));
  1791. $d = new DateTime($dateStr, new DateTimeZone('UTC'));
  1792. return $d->format(DATE_W3C);
  1793. }
  1794. /**
  1795. * Look up the creation timestamp for a given notice ID, even
  1796. * if it's been deleted.
  1797. *
  1798. * @param int $id
  1799. * @return mixed string recorded creation timestamp, or false if can't be found
  1800. */
  1801. public static function getAsTimestamp($id)
  1802. {
  1803. if (!$id) {
  1804. return false;
  1805. }
  1806. $notice = Notice::getKV('id', $id);
  1807. if ($notice) {
  1808. return $notice->created;
  1809. }
  1810. $deleted = Deleted_notice::getKV('id', $id);
  1811. if ($deleted) {
  1812. return $deleted->created;
  1813. }
  1814. return false;
  1815. }
  1816. /**
  1817. * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
  1818. * parameter, matching notices posted after the given one (exclusive).
  1819. *
  1820. * If the referenced notice can't be found, will return false.
  1821. *
  1822. * @param int $id
  1823. * @param string $idField
  1824. * @param string $createdField
  1825. * @return mixed string or false if no match
  1826. */
  1827. public static function whereSinceId($id, $idField='id', $createdField='created')
  1828. {
  1829. $since = Notice::getAsTimestamp($id);
  1830. if ($since) {
  1831. return sprintf("($createdField = '%s' and $idField > %d) or ($createdField > '%s')", $since, $id, $since);
  1832. }
  1833. return false;
  1834. }
  1835. /**
  1836. * Build an SQL 'where' fragment for timestamp-based sorting from a since_id
  1837. * parameter, matching notices posted after the given one (exclusive), and
  1838. * if necessary add it to the data object's query.
  1839. *
  1840. * @param DB_DataObject $obj
  1841. * @param int $id
  1842. * @param string $idField
  1843. * @param string $createdField
  1844. * @return mixed string or false if no match
  1845. */
  1846. public static function addWhereSinceId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
  1847. {
  1848. $since = self::whereSinceId($id, $idField, $createdField);
  1849. if ($since) {
  1850. $obj->whereAdd($since);
  1851. }
  1852. }
  1853. /**
  1854. * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
  1855. * parameter, matching notices posted before the given one (inclusive).
  1856. *
  1857. * If the referenced notice can't be found, will return false.
  1858. *
  1859. * @param int $id
  1860. * @param string $idField
  1861. * @param string $createdField
  1862. * @return mixed string or false if no match
  1863. */
  1864. public static function whereMaxId($id, $idField='id', $createdField='created')
  1865. {
  1866. $max = Notice::getAsTimestamp($id);
  1867. if ($max) {
  1868. return sprintf("($createdField < '%s') or ($createdField = '%s' and $idField <= %d)", $max, $max, $id);
  1869. }
  1870. return false;
  1871. }
  1872. /**
  1873. * Build an SQL 'where' fragment for timestamp-based sorting from a max_id
  1874. * parameter, matching notices posted before the given one (inclusive), and
  1875. * if necessary add it to the data object's query.
  1876. *
  1877. * @param DB_DataObject $obj
  1878. * @param int $id
  1879. * @param string $idField
  1880. * @param string $createdField
  1881. * @return mixed string or false if no match
  1882. */
  1883. public static function addWhereMaxId(DB_DataObject $obj, $id, $idField='id', $createdField='created')
  1884. {
  1885. $max = self::whereMaxId($id, $idField, $createdField);
  1886. if ($max) {
  1887. $obj->whereAdd($max);
  1888. }
  1889. }
  1890. function isPublic()
  1891. {
  1892. if (common_config('public', 'localonly')) {
  1893. return ($this->is_local == Notice::LOCAL_PUBLIC);
  1894. } else {
  1895. return (($this->is_local != Notice::LOCAL_NONPUBLIC) &&
  1896. ($this->is_local != Notice::GATEWAY));
  1897. }
  1898. }
  1899. /**
  1900. * Check that the given profile is allowed to read, respond to, or otherwise
  1901. * act on this notice.
  1902. *
  1903. * The $scope member is a bitmask of scopes, representing a logical AND of the
  1904. * scope requirement. So, 0x03 (Notice::ADDRESSEE_SCOPE | Notice::SITE_SCOPE) means
  1905. * "only visible to people who are mentioned in the notice AND are users on this site."
  1906. * Users on the site who are not mentioned in the notice will not be able to see the
  1907. * notice.
  1908. *
  1909. * @param Profile $profile The profile to check; pass null to check for public/unauthenticated users.
  1910. *
  1911. * @return boolean whether the profile is in the notice's scope
  1912. */
  1913. function inScope($profile)
  1914. {
  1915. if (is_null($profile)) {
  1916. $keypart = sprintf('notice:in-scope-for:%d:null', $this->id);
  1917. } else {
  1918. $keypart = sprintf('notice:in-scope-for:%d:%d', $this->id, $profile->id);
  1919. }
  1920. $result = self::cacheGet($keypart);
  1921. if ($result === false) {
  1922. $bResult = false;
  1923. if (Event::handle('StartNoticeInScope', array($this, $profile, &$bResult))) {
  1924. $bResult = $this->_inScope($profile);
  1925. Event::handle('EndNoticeInScope', array($this, $profile, &$bResult));
  1926. }
  1927. $result = ($bResult) ? 1 : 0;
  1928. self::cacheSet($keypart, $result, 0, 300);
  1929. }
  1930. return ($result == 1) ? true : false;
  1931. }
  1932. protected function _inScope($profile)
  1933. {
  1934. if (!is_null($this->scope)) {
  1935. $scope = $this->scope;
  1936. } else {
  1937. $scope = self::defaultScope();
  1938. }
  1939. // If there's no scope, anyone (even anon) is in scope.
  1940. if ($scope == 0) { // Not private
  1941. return !$this->isHiddenSpam($profile);
  1942. } else { // Private, somehow
  1943. // If there's scope, anon cannot be in scope
  1944. if (empty($profile)) {
  1945. return false;
  1946. }
  1947. // Author is always in scope
  1948. if ($this->profile_id == $profile->id) {
  1949. return true;
  1950. }
  1951. // Only for users on this site
  1952. if (($scope & Notice::SITE_SCOPE) && !$profile->isLocal()) {
  1953. return false;
  1954. }
  1955. // Only for users mentioned in the notice
  1956. if ($scope & Notice::ADDRESSEE_SCOPE) {
  1957. $reply = Reply::pkeyGet(array('notice_id' => $this->id,
  1958. 'profile_id' => $profile->id));
  1959. if (!$reply instanceof Reply) {
  1960. return false;
  1961. }
  1962. }
  1963. // Only for members of the given group
  1964. if ($scope & Notice::GROUP_SCOPE) {
  1965. // XXX: just query for the single membership
  1966. $groups = $this->getGroups();
  1967. $foundOne = false;
  1968. foreach ($groups as $group) {
  1969. if ($profile->isMember($group)) {
  1970. $foundOne = true;
  1971. break;
  1972. }
  1973. }
  1974. if (!$foundOne) {
  1975. return false;
  1976. }
  1977. }
  1978. // Only for followers of the author
  1979. $author = null;
  1980. if ($scope & Notice::FOLLOWER_SCOPE) {
  1981. try {
  1982. $author = $this->getProfile();
  1983. } catch (Exception $e) {
  1984. return false;
  1985. }
  1986. if (!Subscription::exists($profile, $author)) {
  1987. return false;
  1988. }
  1989. }
  1990. return !$this->isHiddenSpam($profile);
  1991. }
  1992. }
  1993. function isHiddenSpam($profile) {
  1994. // Hide posts by silenced users from everyone but moderators.
  1995. if (common_config('notice', 'hidespam')) {
  1996. try {
  1997. $author = $this->getProfile();
  1998. } catch(Exception $e) {
  1999. // If we can't get an author, keep it hidden.
  2000. // XXX: technically not spam, but, whatever.
  2001. return true;
  2002. }
  2003. if ($author->hasRole(Profile_role::SILENCED)) {
  2004. if (!$profile instanceof Profile || (($profile->id !== $author->id) && (!$profile->hasRight(Right::REVIEWSPAM)))) {
  2005. return true;
  2006. }
  2007. }
  2008. }
  2009. return false;
  2010. }
  2011. public function getParent()
  2012. {
  2013. $parent = Notice::getKV('id', $this->reply_to);
  2014. if (!$parent instanceof Notice) {
  2015. throw new ServerException('Notice has no parent');
  2016. }
  2017. return $parent;
  2018. }
  2019. /**
  2020. * Magic function called at serialize() time.
  2021. *
  2022. * We use this to drop a couple process-specific references
  2023. * from DB_DataObject which can cause trouble in future
  2024. * processes.
  2025. *
  2026. * @return array of variable names to include in serialization.
  2027. */
  2028. function __sleep()
  2029. {
  2030. $vars = parent::__sleep();
  2031. $skip = array('_profile', '_groups', '_attachments', '_faves', '_replies', '_repeats');
  2032. return array_diff($vars, $skip);
  2033. }
  2034. static function defaultScope()
  2035. {
  2036. $scope = common_config('notice', 'defaultscope');
  2037. if (is_null($scope)) {
  2038. if (common_config('site', 'private')) {
  2039. $scope = 1;
  2040. } else {
  2041. $scope = 0;
  2042. }
  2043. }
  2044. return $scope;
  2045. }
  2046. static function fillProfiles($notices)
  2047. {
  2048. $map = self::getProfiles($notices);
  2049. foreach ($notices as $entry=>$notice) {
  2050. try {
  2051. if (array_key_exists($notice->profile_id, $map)) {
  2052. $notice->_setProfile($map[$notice->profile_id]);
  2053. }
  2054. } catch (NoProfileException $e) {
  2055. common_log(LOG_WARNING, "Failed to fill profile in Notice with non-existing entry for profile_id: {$e->profile_id}");
  2056. unset($notices[$entry]);
  2057. }
  2058. }
  2059. return array_values($map);
  2060. }
  2061. static function getProfiles(&$notices)
  2062. {
  2063. $ids = array();
  2064. foreach ($notices as $notice) {
  2065. $ids[] = $notice->profile_id;
  2066. }
  2067. $ids = array_unique($ids);
  2068. return Profile::pivotGet('id', $ids);
  2069. }
  2070. static function fillGroups(&$notices)
  2071. {
  2072. $ids = self::_idsOf($notices);
  2073. $gis = Group_inbox::listGet('notice_id', $ids);
  2074. $gids = array();
  2075. foreach ($gis as $id => $gi)
  2076. {
  2077. foreach ($gi as $g)
  2078. {
  2079. $gids[] = $g->group_id;
  2080. }
  2081. }
  2082. $gids = array_unique($gids);
  2083. $group = User_group::pivotGet('id', $gids);
  2084. foreach ($notices as $notice)
  2085. {
  2086. $grps = array();
  2087. $gi = $gis[$notice->id];
  2088. foreach ($gi as $g) {
  2089. $grps[] = $group[$g->group_id];
  2090. }
  2091. $notice->_setGroups($grps);
  2092. }
  2093. }
  2094. static function _idsOf(&$notices)
  2095. {
  2096. $ids = array();
  2097. foreach ($notices as $notice) {
  2098. $ids[] = $notice->id;
  2099. }
  2100. $ids = array_unique($ids);
  2101. return $ids;
  2102. }
  2103. static function fillAttachments(&$notices)
  2104. {
  2105. $ids = self::_idsOf($notices);
  2106. $f2pMap = File_to_post::listGet('post_id', $ids);
  2107. $fileIds = array();
  2108. foreach ($f2pMap as $noticeId => $f2ps) {
  2109. foreach ($f2ps as $f2p) {
  2110. $fileIds[] = $f2p->file_id;
  2111. }
  2112. }
  2113. $fileIds = array_unique($fileIds);
  2114. $fileMap = File::pivotGet('id', $fileIds);
  2115. foreach ($notices as $notice)
  2116. {
  2117. $files = array();
  2118. $f2ps = $f2pMap[$notice->id];
  2119. foreach ($f2ps as $f2p) {
  2120. $files[] = $fileMap[$f2p->file_id];
  2121. }
  2122. $notice->_setAttachments($files);
  2123. }
  2124. }
  2125. protected $_faves;
  2126. /**
  2127. * All faves of this notice
  2128. *
  2129. * @return array Array of Fave objects
  2130. */
  2131. function getFaves()
  2132. {
  2133. if (isset($this->_faves) && is_array($this->_faves)) {
  2134. return $this->_faves;
  2135. }
  2136. $faveMap = Fave::listGet('notice_id', array($this->id));
  2137. $this->_faves = $faveMap[$this->id];
  2138. return $this->_faves;
  2139. }
  2140. function _setFaves($faves)
  2141. {
  2142. $this->_faves = $faves;
  2143. }
  2144. static function fillFaves(&$notices)
  2145. {
  2146. $ids = self::_idsOf($notices);
  2147. $faveMap = Fave::listGet('notice_id', $ids);
  2148. $cnt = 0;
  2149. $faved = array();
  2150. foreach ($faveMap as $id => $faves) {
  2151. $cnt += count($faves);
  2152. if (count($faves) > 0) {
  2153. $faved[] = $id;
  2154. }
  2155. }
  2156. foreach ($notices as $notice) {
  2157. $faves = $faveMap[$notice->id];
  2158. $notice->_setFaves($faves);
  2159. }
  2160. }
  2161. static function fillReplies(&$notices)
  2162. {
  2163. $ids = self::_idsOf($notices);
  2164. $replyMap = Reply::listGet('notice_id', $ids);
  2165. foreach ($notices as $notice) {
  2166. $replies = $replyMap[$notice->id];
  2167. $ids = array();
  2168. foreach ($replies as $reply) {
  2169. $ids[] = $reply->profile_id;
  2170. }
  2171. $notice->_setReplies($ids);
  2172. }
  2173. }
  2174. protected $_repeats;
  2175. function getRepeats()
  2176. {
  2177. if (isset($this->_repeats) && is_array($this->_repeats)) {
  2178. return $this->_repeats;
  2179. }
  2180. $repeatMap = Notice::listGet('repeat_of', array($this->id));
  2181. $this->_repeats = $repeatMap[$this->id];
  2182. return $this->_repeats;
  2183. }
  2184. function _setRepeats($repeats)
  2185. {
  2186. $this->_repeats = $repeats;
  2187. }
  2188. static function fillRepeats(&$notices)
  2189. {
  2190. $ids = self::_idsOf($notices);
  2191. $repeatMap = Notice::listGet('repeat_of', $ids);
  2192. foreach ($notices as $notice) {
  2193. $repeats = $repeatMap[$notice->id];
  2194. $notice->_setRepeats($repeats);
  2195. }
  2196. }
  2197. }