PageRenderTime 59ms CodeModel.GetById 24ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Lamest/RedisEngine.php

http://github.com/nrk/lamestnews
PHP | 1087 lines | 696 code | 197 blank | 194 comment | 103 complexity | 050d2eb67e9196b6846e36616d24cf3a MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Lamest application.
  4. *
  5. * (c) Daniele Alessandri <suppakilla@gmail.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Lamest;
  11. use Predis\Client;
  12. use Predis\Pipeline\PipelineContext;
  13. use Lamest\Helpers as H;
  14. /**
  15. * Implements a Lamest engine that uses Redis as the underlying data storage.
  16. *
  17. * @author Daniele Alessandri <suppakilla@gmail.com>
  18. */
  19. class RedisEngine implements EngineInterface
  20. {
  21. private $redis;
  22. private $options;
  23. private $user;
  24. /**
  25. * Initializes the engine class.
  26. *
  27. * @param Client $redis Redis client used to access the data storage.
  28. * @param array $options Array of options.
  29. */
  30. public function __construct(Client $redis, Array $options = array())
  31. {
  32. $this->redis = $redis;
  33. $this->options = array_merge($this->getDefaults(), $options);
  34. $this->user = array();
  35. }
  36. /**
  37. * Gets the default options for the engine.
  38. *
  39. * @return array
  40. */
  41. protected function getDefaults()
  42. {
  43. return array(
  44. 'password_min_length' => 8,
  45. // comments
  46. 'comment_max_length' => 4096,
  47. 'comment_edit_time' => 3600 * 2,
  48. 'comment_reply_shift' => 60,
  49. 'user_comments_per_page' => 10,
  50. 'subthreads_in_replies_page' => 10,
  51. // karma
  52. 'user_initial_karma' => 1,
  53. 'karma_increment_interval' => 3600 * 3,
  54. 'karma_increment_amount' => 1,
  55. 'news_downvote_min_karma' => 30,
  56. 'news_downvote_karma_cost' => 6,
  57. 'news_upvote_min_karma' => 0,
  58. 'news_upvote_karma_cost' => 1,
  59. 'news_upvote_karma_transfered' => 1,
  60. 'karma_increment_comment' => 1,
  61. // news and ranking
  62. 'news_age_padding' => 60 * 60 * 8,
  63. 'top_news_per_page' => 30,
  64. 'latest_news_per_page' => 100,
  65. 'news_edit_time' => 60 * 15,
  66. 'news_score_log_start' => 10,
  67. 'news_score_log_booster' => 2,
  68. 'rank_aging_factor' => 2.2,
  69. 'prevent_repost_time' => 3600 * 48,
  70. 'news_submission_break' => 60 * 15,
  71. 'saved_news_per_page' => 10,
  72. // API
  73. 'api_max_news_count' => 32,
  74. // UI Elements
  75. 'keyboard_navigation' => true,
  76. );
  77. }
  78. /**
  79. * {@inheritdoc}
  80. */
  81. public function rateLimited($delay, Array $tags)
  82. {
  83. if (!$tags) {
  84. return false;
  85. }
  86. $key = "limit:" . join($tags, '.');
  87. if ($this->getRedis()->exists($key)) {
  88. return true;
  89. }
  90. $this->getRedis()->setex($key, $delay, 1);
  91. return false;
  92. }
  93. /**
  94. * {@inheritdoc}
  95. */
  96. public function createUser($username, $password)
  97. {
  98. $redis = $this->getRedis();
  99. if ($redis->exists("username.to.id:".strtolower($username))) {
  100. return;
  101. }
  102. $userID = $redis->incr('users.count');
  103. $authToken = H::generateRandom();
  104. $salt = H::generateRandom();
  105. $userDetails = array(
  106. 'id' => $userID,
  107. 'username' => $username,
  108. 'salt' => $salt,
  109. 'password' => H::pbkdf2($password, $salt, 20),
  110. 'ctime' => time(),
  111. 'karma' => $this->getOption('user_initial_karma'),
  112. 'about' => '',
  113. 'email' => '',
  114. 'auth' => $authToken,
  115. 'apisecret' => H::generateRandom(),
  116. 'flags' => '',
  117. 'karma_incr_time' => time(),
  118. );
  119. $redis->hmset("user:$userID", $userDetails);
  120. $redis->set("username.to.id:".strtolower($username), $userID);
  121. $redis->set("auth:$authToken", $userID);
  122. return $authToken;
  123. }
  124. /**
  125. * {@inheritdoc}
  126. */
  127. public function getUserByID($userID)
  128. {
  129. return $this->getRedis()->hgetall("user:$userID");
  130. }
  131. /**
  132. * {@inheritdoc}
  133. */
  134. public function getUserByUsername($username)
  135. {
  136. $userID = $this->getRedis()->get('username.to.id:'.strtolower($username));
  137. if (!$userID) {
  138. return;
  139. }
  140. return $this->getUserByID($userID);
  141. }
  142. /**
  143. * {@inheritdoc}
  144. */
  145. public function addUserFlags($userID, $flags)
  146. {
  147. $user = $this->getUserByID($userID);
  148. if (!$user) {
  149. return false;
  150. }
  151. $flags = $user['flags'];
  152. foreach (str_split($flags) as $flag) {
  153. if ($this->checkUserFlags($flag)) {
  154. $flags .= $flag;
  155. }
  156. }
  157. $this->getRedis()->hset("user:$userID", "flags", $flags);
  158. return true;
  159. }
  160. /**
  161. * {@inheritdoc}
  162. */
  163. public function checkUserFlags(Array $user, $flags)
  164. {
  165. if (!$user) {
  166. return false;
  167. }
  168. $userflags = $user['flags'];
  169. foreach (str_split($flags) as $flag) {
  170. if (stripos($userflags, $flag) === false) {
  171. return false;
  172. }
  173. }
  174. return true;
  175. }
  176. /**
  177. * {@inheritdoc}
  178. */
  179. public function isUserAdmin(Array $user)
  180. {
  181. return $this->checkUserFlags($user, 'a');
  182. }
  183. /**
  184. * {@inheritdoc}
  185. */
  186. public function getUserCounters(Array $user)
  187. {
  188. $counters = $this->getRedis()->pipeline(function ($pipe) use ($user) {
  189. $pipe->zcard("user.posted:{$user['id']}");
  190. $pipe->zcard("user.comments:{$user['id']}");
  191. });
  192. return array(
  193. 'posted_news' => $counters[0],
  194. 'posted_comments' => $counters[1],
  195. );
  196. }
  197. /**
  198. * {@inheritdoc}
  199. */
  200. public function verifyUserCredentials($username, $password)
  201. {
  202. $user = $this->getUserByUsername($username);
  203. if (!$user) {
  204. return;
  205. }
  206. $hashedPassword = H::pbkdf2($password, $user['salt'], 20);
  207. if ($user['password'] !== $hashedPassword) {
  208. return;
  209. }
  210. $this->user = $user;
  211. return array(
  212. $user['auth'],
  213. $user['apisecret'],
  214. );
  215. }
  216. /**
  217. * {@inheritdoc}
  218. */
  219. public function authenticateUser($authToken)
  220. {
  221. if (!$authToken) {
  222. return;
  223. }
  224. $userID = $this->getRedis()->get("auth:$authToken");
  225. if (!$userID) {
  226. return;
  227. }
  228. $user = $this->getRedis()->hgetall("user:$userID");
  229. if (!$user) {
  230. return;
  231. }
  232. $this->user = $user;
  233. return $user;
  234. }
  235. /**
  236. * {@inheritdoc}
  237. */
  238. public function updateAuthToken($userID)
  239. {
  240. $user = $this->getUserByID($userID);
  241. if (!$user) {
  242. return;
  243. }
  244. $redis = $this->getRedis();
  245. $redis->del("auth:{$user['auth']}");
  246. $newAuthToken = H::generateRandom();
  247. $redis->hmset("user:$userID","auth", $newAuthToken);
  248. $redis->set("auth:$newAuthToken", $userID);
  249. return $newAuthToken;
  250. }
  251. /**
  252. * {@inheritdoc}
  253. */
  254. public function incrementUserKarma(Array &$user, $increment, $interval = 0)
  255. {
  256. $userKey = "user:{$user['id']}";
  257. $redis = $this->getRedis();
  258. if ($interval > 0) {
  259. $now = time();
  260. if ($user['karma_incr_time'] >= $now - $interval) {
  261. return false;
  262. }
  263. $redis->hset($userKey, 'karma_incr_time', $now);
  264. }
  265. $redis->hincrby($userKey, 'karma', $increment);
  266. $user['karma'] = isset($user['karma']) ? $user['karma'] + $increment : $increment;
  267. return true;
  268. }
  269. /**
  270. * {@inheritdoc}
  271. */
  272. public function getUserKarma(Array $user)
  273. {
  274. return (int) $this->getRedis()->hget("user:{$user['id']}", 'karma') ?: 0;
  275. }
  276. /**
  277. * {@inheritdoc}
  278. */
  279. public function updateUserProfile(Array $user, Array $attributes)
  280. {
  281. $attributes = array_merge($attributes, array(
  282. 'about' => substr($attributes['about'], 0, 4095),
  283. 'email' => substr($attributes['email'], 0, 255),
  284. ));
  285. $this->getRedis()->hmset("user:{$user['id']}", $attributes);
  286. }
  287. /**
  288. * {@inheritdoc}
  289. */
  290. public function getNewPostEta(Array $user)
  291. {
  292. return $this->getRedis()->ttl("user:{$user['id']}:submitted_recently");
  293. }
  294. /**
  295. * {@inheritdoc}
  296. */
  297. public function getTopNews(Array $user = null, $start = 0, $count = null)
  298. {
  299. $redis = $this->getRedis();
  300. $count = $count ?: $this->getOption('top_news_per_page');
  301. $newsIDs = $redis->zrevrange('news.top', $start, $start + $count - 1);
  302. if (!$newsIDs) {
  303. return array('news' => array(), 'count' => 0);
  304. }
  305. $newslist = $this->getNewsByID($user, $newsIDs, true);
  306. // Sort by rank before returning, since we adjusted ranks during iteration.
  307. usort($newslist, function ($a, $b) {
  308. return $a['rank'] != $b['rank'] ? ($a['rank'] < $b['rank'] ? 1 : -1) : 0;
  309. });
  310. return array(
  311. 'news' => $newslist,
  312. 'count' => $redis->zcard('news.top'),
  313. );
  314. }
  315. /**
  316. * {@inheritdoc}
  317. */
  318. public function getLatestNews(Array $user = null, $start = 0, $count = null)
  319. {
  320. $redis = $this->getRedis();
  321. $count = $count ?: $this->getOption('latest_news_per_page');
  322. $newsIDs = $redis->zrevrange('news.cron', $start, $start + $count - 1);
  323. if (!$newsIDs) {
  324. return array('news' => array(), 'count' => 0);
  325. }
  326. return array(
  327. 'news' => $this->getNewsByID($user, $newsIDs, true),
  328. 'count' => $redis->zcard('news.cron'),
  329. );
  330. }
  331. /**
  332. * {@inheritdoc}
  333. */
  334. public function getSavedNews(Array $user, $start = 0, $count = null)
  335. {
  336. $redis = $this->getRedis();
  337. $count = $count ?: $this->getOption('saved_news_per_page');
  338. $newsIDs = $redis->zrevrange("user.saved:{$user['id']}", $start, $start + $count - 1);
  339. if (!$newsIDs) {
  340. return array('news' => array(), 'count' => 0);
  341. }
  342. return array(
  343. 'news' => $this->getNewsByID($user, $newsIDs),
  344. 'count' => $redis->zcard("user.saved:{$user['id']}"),
  345. );
  346. }
  347. /**
  348. * {@inheritdoc}
  349. */
  350. public function getReplies(Array $user, $maxSubThreads, $reset = false)
  351. {
  352. $engine = $this;
  353. $threadCallback = function ($comment) use ($engine, $user) {
  354. $thread = array('id' => $comment['thread_id']);
  355. $comment['replies'] = $engine->getNewsComments($user, $thread);
  356. return $comment;
  357. };
  358. $comments = $this->getUserComments($user, 0, $maxSubThreads, $threadCallback);
  359. if ($reset) {
  360. $this->getRedis()->hset("user:{$user['id']}", 'replies', 0);
  361. }
  362. return $comments['list'];
  363. }
  364. /**
  365. * {@inheritdoc}
  366. */
  367. public function getNewsByID(Array $user, $newsIDs, $updateRank = false)
  368. {
  369. if (!$newsIDs) {
  370. return array();
  371. }
  372. $newsIDs = !is_array($newsIDs) ? array($newsIDs) : array_values(array_filter($newsIDs));
  373. $redis = $this->getRedis();
  374. $newslist = $redis->pipeline(function ($pipe) use ($newsIDs) {
  375. foreach ($newsIDs as $newsID) {
  376. $pipe->hgetall("news:$newsID");
  377. }
  378. });
  379. if (!$newslist) {
  380. return array();
  381. }
  382. $result = array();
  383. $pipe = $redis->pipeline();
  384. // Get all the news.
  385. foreach ($newslist as $news) {
  386. if (!$news) {
  387. // TODO: how should we notify the caller of missing news items when
  388. // asking for more than one news at time?
  389. continue;
  390. }
  391. // Adjust rank if too different from the real-time value.
  392. if ($updateRank) {
  393. $this->updateNewsRank($pipe, $news);
  394. }
  395. $result[] = $news;
  396. }
  397. // Get the associated users information.
  398. $usernames = $redis->pipeline(function ($pipe) use ($result) {
  399. foreach ($result as $news) {
  400. $pipe->hget("user:{$news['user_id']}", 'username');
  401. }
  402. });
  403. foreach ($result as $i => &$news) {
  404. $news['username'] = $usernames[$i];
  405. }
  406. // Load user's vote information if we are in the context of a
  407. // registered user.
  408. if ($user) {
  409. $votes = $redis->pipeline(function ($pipe) use ($result, $user) {
  410. foreach ($result as $news) {
  411. $pipe->zscore("news.up:{$news['id']}", $user['id']);
  412. $pipe->zscore("news.down:{$news['id']}", $user['id']);
  413. }
  414. });
  415. foreach ($result as $i => &$news) {
  416. if ($votes[$i * 2]) {
  417. $news['voted'] = 'up';
  418. } elseif ($votes[$i * 2 + 1]) {
  419. $news['voted'] = 'down';
  420. } else {
  421. $news['voted'] = false;
  422. }
  423. }
  424. }
  425. return $result;
  426. }
  427. /**
  428. * {@inheritdoc}
  429. */
  430. public function getNewsComments(Array $user, Array $news)
  431. {
  432. $tree = array();
  433. $users = array();
  434. $comments = $this->getRedis()->hgetall("thread:comment:{$news['id']}");
  435. foreach ($comments as $id => $comment) {
  436. if ($id == 'nextid') {
  437. continue;
  438. }
  439. $comment = json_decode($comment, true);
  440. $userID = $comment['user_id'];
  441. $parentID = $comment['parent_id'];
  442. if (!isset($users[$userID])) {
  443. $users[$userID] = $this->getUserByID($userID);
  444. }
  445. if (!isset($tree[$parentID])) {
  446. $tree[$parentID] = array();
  447. }
  448. $tree[$parentID][] = array_merge($comment, array(
  449. 'id' => $id,
  450. 'thread_id' => $news['id'],
  451. 'voted' => H::commentVoted($user, $comment),
  452. 'user' => $users[$userID],
  453. ));
  454. }
  455. return $tree;
  456. }
  457. /**
  458. * Updates the rank of a news item.
  459. *
  460. * @param PipelineContext $pipe Pipeline used to batch the update operations.
  461. * @param array $news Single news item.
  462. */
  463. protected function updateNewsRank(PipelineContext $pipe, Array &$news)
  464. {
  465. $realRank = $this->computeNewsRank($news);
  466. if (abs($realRank - $news['rank']) > 0.001) {
  467. $pipe->hmset("news:{$news['id']}", 'rank', $realRank);
  468. $pipe->zadd('news.top', $realRank , $news['id']);
  469. $news['rank'] = $realRank;
  470. }
  471. }
  472. /**
  473. * Compute the score for a news item.
  474. *
  475. * @param array $news News item.
  476. * @return float
  477. */
  478. protected function computerNewsScore(Array $news)
  479. {
  480. $redis = $this->getRedis();
  481. // TODO: For now we are doing a naive sum of votes, without time-based
  482. // filtering, nor IP filtering. We could use just ZCARD here of course,
  483. // but ZRANGE already returns everything needed for vote analysis once
  484. // implemented.
  485. $upvotes = $redis->zrange("news.up:{$news['id']}", 0, -1, 'withscores');
  486. $downvotes = $redis->zrange("news.down:{$news['id']}", 0, -1, 'withscores');
  487. // Now let's add the logarithm of the sum of all the votes, since
  488. // something with 5 up and 5 down is less interesting than something
  489. // with 50 up and 50 down.
  490. $score = count($upvotes) / 2 - count($downvotes) / 2;
  491. $votes = count($upvotes) / 2 + count($downvotes) / 2;
  492. if ($votes > ($logStart = $this->getOption('news_score_log_start'))) {
  493. $score += log($votes - $logStart) * $this->getOption('news_score_log_booster');
  494. }
  495. return $score;
  496. }
  497. /**
  498. * Computes the rank of a news item.
  499. *
  500. * @param array $news Single news item.
  501. * @return float
  502. */
  503. protected function computeNewsRank(Array $news)
  504. {
  505. $age = time() - (int) $news['ctime'] + $this->getOption('news_age_padding');
  506. return ((float) $news['score']) / pow($age / 3600, $this->getOption('rank_aging_factor'));
  507. }
  508. /**
  509. * {@inheritdoc}
  510. */
  511. public function insertNews($title, $url, $text, $userID)
  512. {
  513. $redis = $this->getRedis();
  514. // Use a kind of URI using the "text" scheme if now URL has been provided.
  515. // TODO: remove duplicated code.
  516. $textPost = !$url;
  517. if ($textPost) {
  518. $url = 'text://' . substr($text, 0, $this->getOption('comment_max_length'));
  519. }
  520. // Verify if a news with the same URL has been already submitted.
  521. if (!$textPost && ($id = $redis->get("url:$url"))) {
  522. return (int) $id;
  523. }
  524. $ctime = time();
  525. $newsID = $redis->incr('news.count');
  526. $newsDetails = array(
  527. 'id' => $newsID,
  528. 'title' => $title,
  529. 'url' => $url,
  530. 'user_id' => $userID,
  531. 'ctime' => $ctime,
  532. 'score' => 0,
  533. 'rank' => 0,
  534. 'up' => 0,
  535. 'down' => 0,
  536. 'comments' => 0,
  537. );
  538. $redis->hmset("news:$newsID", $newsDetails);
  539. // The posting user virtually upvoted the news posting it.
  540. $newsRank = $this->voteNews($newsID, $userID, 'up');
  541. // Add the news to the user submitted news.
  542. $redis->zadd("user.posted:$userID", $ctime, $newsID);
  543. // Add the news into the chronological view.
  544. $redis->zadd('news.cron', $ctime, $newsID);
  545. // Add the news into the top view.
  546. $redis->zadd('news.top', $newsRank, $newsID);
  547. // Set a timeout indicating when the user may post again
  548. $redis->setex("user:$userID:submitted_recently", $this->getOption('news_submission_break') ,'1');
  549. if (!$textPost) {
  550. // Avoid reposts for a certain amount of time using an expiring key.
  551. $redis->setex("url:$url", $this->getOption('prevent_repost_time'), $newsID);
  552. }
  553. return $newsID;
  554. }
  555. /**
  556. * {@inheritdoc}
  557. */
  558. public function editNews(Array $user, $newsID, $title, $url, $text)
  559. {
  560. @list($news) = $this->getNewsByID($user, $newsID);
  561. if (!$news || $news['user_id'] != $user['id']) {
  562. return false;
  563. }
  564. if ($news['ctime'] < time() - $this->getOption('news_edit_time')) {
  565. return false;
  566. }
  567. // Use a kind of URI using the "text" scheme if now URL has been provided.
  568. // TODO: remove duplicated code.
  569. $textPost = !$url;
  570. if ($textPost) {
  571. $url = 'text://' . substr($text, 0, $this->getOption('comment_max_length'));
  572. }
  573. $redis = $this->getRedis();
  574. // The URL for recently posted news cannot be changed.
  575. if (!$textPost && $url != $news['url']) {
  576. if ($redis->get("url:$url")) {
  577. return false;
  578. }
  579. // Prevent DOS attacks by locking the new URL after it has been changed.
  580. $redis->del("url:{$news['url']}");
  581. if (!$textPost) {
  582. $redis->setex("url:$url", $this->getOption('prevent_repost_time'), $newsID);
  583. }
  584. }
  585. $redis->hmset("news:$newsID", array(
  586. 'title' => $title,
  587. 'url' => $url,
  588. ));
  589. return $newsID;
  590. }
  591. /**
  592. * {@inheritdoc}
  593. */
  594. public function voteNews($newsID, $user, $type, &$error = null)
  595. {
  596. if ($type !== 'up' && $type !== 'down') {
  597. $error = 'Vote must be either up or down.';
  598. return false;
  599. }
  600. $user = is_array($user) ? $user : $this->getUserByID($user);
  601. $news = $this->getNewsByID($user, $newsID);
  602. if (!$user || !$news) {
  603. $error = 'No such news or user.';
  604. return false;
  605. }
  606. list($news) = $news;
  607. $redis = $this->getRedis();
  608. // Verify that the user has not already voted the news item.
  609. $hasUpvoted = $redis->zscore("news.up:$newsID", $user['id']);
  610. $hasDownvoted = $redis->zscore("news.down:$newsID", $user['id']);
  611. if ($hasUpvoted || $hasDownvoted) {
  612. $error = 'Duplicated vote.';
  613. return false;
  614. }
  615. // Check if the user has enough karma to perform this operation
  616. if ($user['id'] != $news['user_id']) {
  617. $noUpvote = $type == 'up' && $user['karma'] < $this->getOption('news_upvote_min_karma');
  618. $noDownvote = $type == 'down' && $user['karma'] < $this->getOption('news_downvote_min_karma');
  619. if ($noUpvote || $noDownvote) {
  620. $error = "You don't have enough karma to vote $type";
  621. return false;
  622. }
  623. }
  624. $now = time();
  625. // Add the vote for the news item.
  626. if ($redis->zadd("news.$type:$newsID", $now, $user['id'])) {
  627. $redis->hincrby("news:$newsID", $type, 1);
  628. }
  629. if ($type === 'up') {
  630. $redis->zadd("user.saved:{$user['id']}", $now, $newsID);
  631. }
  632. // Compute the new score and karma updating the news accordingly.
  633. $news['score'] = $this->computerNewsScore($news);
  634. $rank = $this->computeNewsRank($news);
  635. $redis->hmset("news:$newsID", array(
  636. 'score' => $news['score'],
  637. 'rank' => $rank,
  638. ));
  639. $redis->zadd('news.top', $rank, $newsID);
  640. // Adjust the karma of the user on vote, and transfer karma to the news owner if upvoted.
  641. if ($user['id'] != $news['user_id']) {
  642. if ($type == 'up') {
  643. $this->incrementUserKarma($user, -$this->getOption('news_upvote_karma_cost'));
  644. // TODO: yes, I know, it's an uber-hack...
  645. $transfedUser = array('id' => $news['user_id']);
  646. $this->incrementUserKarma($transfedUser, $this->getOption('news_upvote_karma_transfered'));
  647. } else {
  648. $this->incrementUserKarma($user, -$this->getOption('news_downvote_karma_cost'));
  649. }
  650. }
  651. return $rank;
  652. }
  653. /**
  654. * {@inheritdoc}
  655. */
  656. public function deleteNews(Array $user, $newsID)
  657. {
  658. @list($news) = $this->getNewsByID($user, $newsID);
  659. if (!$news || $news['user_id'] != $user['id']) {
  660. return false;
  661. }
  662. if ((int)$news['ctime'] <= (time() - $this->getOption('news_edit_time'))) {
  663. return false;
  664. }
  665. $redis = $this->getRedis();
  666. $redis->hmset("news:$newsID", 'del', 1);
  667. $redis->zrem('news.top', $newsID);
  668. $redis->zrem('news.cron', $newsID);
  669. return true;
  670. }
  671. /**
  672. * {@inheritdoc}
  673. */
  674. public function handleComment(Array $user, $newsID, $commentID, $parentID, $body = null)
  675. {
  676. $redis = $this->getRedis();
  677. $news = $this->getNewsByID($user, $newsID);
  678. if (!$news) {
  679. return false;
  680. }
  681. if ($commentID == -1) {
  682. if ($parentID != -1) {
  683. $parent = $this->getComment($newsID, $parentID);
  684. if (!$parent) {
  685. return false;
  686. }
  687. }
  688. $comment = array(
  689. 'score' => 0,
  690. 'body' => $body,
  691. 'parent_id' => $parentID,
  692. 'user_id' => $user['id'],
  693. 'ctime' => time(),
  694. 'up' => array((int) $user['id']),
  695. );
  696. $commentID = $this->postComment($newsID, $comment);
  697. if (!$commentID) {
  698. return false;
  699. }
  700. $redis->hincrby("news:$newsID", 'comments', 1);
  701. $redis->zadd("user.comments:{$user['id']}", time(), "$newsID-$commentID");
  702. // NOTE: karma updates on new comments has been temporarily disabled in LN v0.9.0
  703. // $this->incrementUserKarma($user, $this->getOption('karma_increment_comment'));
  704. if (isset($parent) && $redis->exists("user:{$parent['user_id']}")) {
  705. $redis->hincrby("user:{$parent['user_id']}", 'replies', 1);
  706. }
  707. return array(
  708. 'news_id' => $newsID,
  709. 'comment_id' => $commentID,
  710. 'op' => 'insert',
  711. );
  712. }
  713. // If we reached this point the next step is either to update or
  714. // delete the comment. So we make sure the user_id of the request
  715. // matches the user_id of the comment.
  716. // We also make sure the user is in time for an edit operation.
  717. $comment = $this->getComment($newsID, $commentID);
  718. if (!$comment || $comment['user_id'] != $user['id']) {
  719. return false;
  720. }
  721. if (!$comment['ctime'] > (time() - $this->getOption('comment_edit_time'))) {
  722. return false;
  723. }
  724. if (!$body) {
  725. if (!$this->deleteComment($newsID, $commentID)) {
  726. return false;
  727. }
  728. $redis->hincrby("news:$newsID", 'comments', -1);
  729. return array(
  730. 'news_id' => $newsID,
  731. 'comment_id' => $commentID,
  732. 'op' => 'delete',
  733. );
  734. } else {
  735. $update = array('body' => $body);
  736. if (isset($comment['del']) && $comment['del'] == true) {
  737. $update['del'] = 0;
  738. }
  739. if (!$this->editComment($newsID, $commentID, $update)) {
  740. return false;
  741. }
  742. return array(
  743. 'news_id' => $newsID,
  744. 'comment_id' => $commentID,
  745. 'op' => 'update',
  746. );
  747. }
  748. }
  749. /**
  750. * {@inheritdoc}
  751. */
  752. public function getComment($newsID, $commentID)
  753. {
  754. $json = $this->getRedis()->hget("thread:comment:$newsID", $commentID);
  755. if (!$json) {
  756. return;
  757. }
  758. return array_merge(json_decode($json, true), array(
  759. 'thread_id' => $newsID,
  760. 'id' => $commentID,
  761. ));
  762. }
  763. /**
  764. * {@inheritdoc}
  765. */
  766. public function getUserComments(Array $user, $start = 0, $count = -1, $callback = null)
  767. {
  768. if (isset($callback) && !is_callable($callback)) {
  769. throw new \InvalidArgumentException('The callback arguments must be a valid callable.');
  770. }
  771. $comments = array();
  772. $redis = $this->getRedis();
  773. $total = $redis->zcard("user.comments:{$user['id']}");
  774. if ($total > 0) {
  775. $commentIDs = $redis->zrevrange("user.comments:{$user['id']}", $start, $count);
  776. foreach ($commentIDs as $compositeID) {
  777. list($newsID, $commentID) = explode('-', $compositeID);
  778. $comment = $this->getComment($newsID, $commentID);
  779. if ($comment) {
  780. $comment = array_merge($comment, array(
  781. 'user' => $this->getUserByID($comment['user_id']),
  782. 'voted' => H::commentVoted($user, $comment),
  783. ));
  784. $comments[] = isset($callback) ? $callback($comment) : $comment;
  785. }
  786. }
  787. }
  788. return array(
  789. 'list' => $comments,
  790. 'total' => $total,
  791. );
  792. }
  793. /**
  794. * {@inheritdoc}
  795. */
  796. public function postComment($newsID, Array $comment)
  797. {
  798. if (!isset($comment['parent_id'])) {
  799. // TODO: "no parent_id field"
  800. return false;
  801. }
  802. $redis = $this->getRedis();
  803. $threadKey = "thread:comment:$newsID";
  804. if ($comment['parent_id'] != -1) {
  805. if (!$redis->hget($threadKey, $comment['parent_id'])) {
  806. return false;
  807. }
  808. }
  809. $commentID = $redis->hincrby($threadKey, 'nextid', 1);
  810. $redis->hset($threadKey, $commentID, json_encode($comment));
  811. return $commentID;
  812. }
  813. /**
  814. * {@inheritdoc}
  815. */
  816. public function voteComment(Array $user, $newsID, $commentID, $type)
  817. {
  818. if ($type !== 'up' && $type !== 'down') {
  819. return false;
  820. }
  821. $comment = $this->getComment($newsID, $commentID);
  822. if (!$comment) {
  823. return false;
  824. }
  825. if (H::commentVoted($user, $comment)) {
  826. return false;
  827. }
  828. $votes[] = (int) $user['id'];
  829. return $this->editComment($newsID, $commentID, array($type => $votes));
  830. }
  831. /**
  832. * {@inheritdoc}
  833. */
  834. public function editComment($newsID, $commentID, Array $updates)
  835. {
  836. $redis = $this->getRedis();
  837. $threadKey = "thread:comment:$newsID";
  838. $json = $redis->hget($threadKey, $commentID);
  839. if (!$json) {
  840. return false;
  841. }
  842. $comment = array_merge(json_decode($json, true), $updates);
  843. $redis->hset($threadKey, $commentID, json_encode($comment));
  844. return true;
  845. }
  846. /**
  847. * {@inheritdoc}
  848. */
  849. public function deleteComment($newsID, $commentID)
  850. {
  851. return $this->editComment($newsID, $commentID, array('del' => 1));
  852. }
  853. /**
  854. * Gets an option by its name or returns all the options.
  855. *
  856. * @param string $option Name of the option.
  857. * @return mixed
  858. */
  859. public function getOption($option = null)
  860. {
  861. if (!$option) {
  862. return $this->options;
  863. }
  864. if (isset($this->options[$option])) {
  865. return $this->options[$option];
  866. }
  867. }
  868. /**
  869. * Gets the underlying Redis client used to interact with Redis.
  870. *
  871. * @return Client
  872. */
  873. public function getRedis()
  874. {
  875. return $this->redis;
  876. }
  877. /**
  878. * {@inheritdoc}
  879. */
  880. public function getUser()
  881. {
  882. return $this->user;
  883. }
  884. }