├── VERSION ├── .gitignore ├── web ├── images │ └── lama.gif ├── index.php ├── css │ └── style.css └── js │ └── app.js ├── TODO ├── template ├── user_comments.html.twig ├── permalink_to_comment.html.twig ├── user_replies.html.twig ├── newslist.html.twig ├── login.html.twig ├── reply_to_comment.html.twig ├── newslist.rss.twig ├── edit_comment.html.twig ├── edit_news.html.twig ├── submit_news.html.twig ├── news.html.twig ├── userprofile.html.twig ├── layout.html.twig └── shared.html.twig ├── composer.json ├── LICENSE ├── src └── Lamest │ ├── Silex │ ├── LamestServiceProvider.php │ ├── WebsiteController.php │ └── ApiController.php │ ├── Twig │ └── LamestExtension.php │ ├── Helpers.php │ ├── EngineInterface.php │ └── RedisEngine.php ├── README.md └── composer.lock /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.0-dev 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.phar 2 | phpunit.xml 3 | vendor/* 4 | -------------------------------------------------------------------------------- /web/images/lama.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nrk/lamestnews/HEAD/web/images/lama.gif -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | * Use PBKDF2ServiceProvider (see https://github.com/nrk/PBKDF2ServiceProvider). 2 | 3 | * Since the website and API controllers can be mounted in subpaths, we should 4 | bind some routes such as the one to '/login' to specific route names and then 5 | make use of the UrlGeneratorProvider to handle internal redirects. 6 | 7 | * Verify if we can abstract objects like user, news and comment to PHP classes 8 | that implement ArrayAccess (to maintain the current flexibility when adding, 9 | removing or simply accessing fields). 10 | -------------------------------------------------------------------------------- /template/user_comments.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_comment %} 3 | 4 | {% block content %} 5 | 6 |

{{ title }}

7 | 8 |
9 | {% for comment in comments %} 10 | {{ render_comment(comment) }} 11 | {% endfor %} 12 | 13 | {% if pagination is defined %} 14 | {% set nextpage = pagination.start + pagination.perpage %} 15 | {% if nextpage < pagination.count %} 16 | [more] 17 | {% endif %} 18 | {% endif %} 19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /template/permalink_to_comment.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news, render_comments, render_comment %} 3 | 4 | {% block content %} 5 | 6 |
7 | {{ render_news(news) }} 8 |
9 | 10 |
11 | {{ render_comment(comment, 0) }} 12 |
13 | 14 |
15 |

Replies

16 |
17 | {% if comments[comment.id] is defined %} 18 | {{ render_comments(comments, comment.id, 0) }} 19 | {% endif %} 20 |
21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /template/user_replies.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_comments, render_comment %} 3 | 4 | {% block content %} 5 | 6 |

{{ title }}

7 | 8 | {% for comment in comments %} 9 |
10 | {{ render_comment(comment) }} 11 |
12 | 13 |
14 |
15 | {% if comment.replies[comment.id] is defined %} 16 | {{ render_comments(comment.replies, comment.id) }} 17 | {% endif %} 18 |
19 |
20 | {% endfor %} 21 | 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /template/newslist.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news %} 3 | 4 | {% block content %} 5 | 6 | {% if head_title is defined %} 7 |

{{ title }}

8 | {% endif %} 9 | 10 |
11 | {% for news in newslist %} 12 | {{ render_news(news) }} 13 | {% endfor %} 14 | 15 | {% if pagination is defined %} 16 | {% set nextpage = pagination.start + pagination.perpage %} 17 | {% if nextpage < pagination.count %} 18 | [more] 19 | {% endif %} 20 | {% endif %} 21 |
22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nrk/lamestnews", 3 | "type": "application", 4 | "description": "A port to PHP of the application that powers Lamer News.", 5 | "homepage": "https://github.com/nrk/lamestnews", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Daniele Alessandri", 10 | "email": "suppakilla@gmail.com", 11 | "homepage": "http://clorophilla.net" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-0": {"Lamest": "src/"} 16 | }, 17 | "require": { 18 | "php": ">=5.3.2", 19 | "predis/service-provider": "0.4.*", 20 | "silex/silex": "1.0.*@dev", 21 | "twig/twig": "1.9.*" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /template/login.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 | 8 | 9 | 10 | 11 |
12 | 13 | create account
14 | 15 |
16 |
17 | 18 |
19 | 20 | 25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /template/reply_to_comment.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news, render_comment %} 3 | 4 | {% block content %} 5 | 6 | {{ render_news(news) }} 7 | 8 | {{ render_comment(comment, 0) }} 9 | 10 |
11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 | 25 | 26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /template/newslist.rss.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ site_name }} 4 | {{ full_url(app.request, '/') }} 5 | {{ site_name }} - {{ description }} 6 | {% for news in newslist %} 7 | {% set url_comments = full_url(app.request, '/news/') ~ news.id %} 8 | {% set news_url = news_domain(news) ? news.url : url_comments %} 9 | 10 | {{ news.title }} 11 | {{ news_url }} 12 | {{ news_url }} 13 | 14 | Comments ]]> 15 | 16 | {{ url_comments }} 17 | 18 | {% endfor %} 19 | 20 | 21 | -------------------------------------------------------------------------------- /template/edit_comment.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news, render_comment %} 3 | 4 | {% block content %} 5 | 6 | {{ render_news(news) }} 7 | 8 | {{ render_comment(comment, 0) }} 9 | 10 |
11 | 12 | 13 | 14 |
15 | 16 |
17 | 18 |
19 | 20 | Note: to remove the comment remove all the text and press Edit. 21 | 22 | 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011-2013 Daniele Alessandri 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /template/edit_news.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news %} 3 | 4 | {% block content %} 5 | 6 | {{ render_news(news) }} 7 | 8 |
9 |
10 | 11 | 12 | 13 |
14 | 15 |
16 |
17 | 18 | or if you don't have an url type some text
19 | 20 |
21 | 22 | delete this news
23 | 24 | 25 |
26 |
27 | 28 |
29 | 30 | 35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /template/submit_news.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | 3 | {% block content %} 4 | 5 |

Submit a new story

6 | 7 |
8 |
9 | 10 | 11 | 12 |
13 | 14 |
15 |
16 | 17 | or if you don't have an url type some text
18 | 19 | 20 | 21 | 22 |
23 |
24 | 25 |
26 | 27 |

Submitting news is simpler using the bookmarklet (drag the link to your browser toolbar)

28 | 29 | 34 | 35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /web/index.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest\Silex; 13 | 14 | require __DIR__.'/../vendor/autoload.php'; 15 | 16 | use Silex\Application; 17 | use Silex\Provider\TwigServiceProvider; 18 | use Lamest\Twig\LamestExtension; 19 | use Predis\Silex\PredisServiceProvider; 20 | 21 | $app = new Application(); 22 | 23 | $app['debug'] = true; 24 | 25 | $app->register(new PredisServiceProvider(), array( 26 | 'predis.parameters' => 'tcp://127.0.0.1:6379', 27 | )); 28 | 29 | $app->register(new TwigServiceProvider(), array( 30 | 'twig.path' => __DIR__.'/../template', 31 | )); 32 | 33 | $app['twig'] = $app->share($app->extend('twig', function($twig, $app) { 34 | $twig->addExtension(new LamestExtension()); 35 | 36 | return $twig; 37 | })); 38 | 39 | $app->register(new LamestServiceProvider(), array( 40 | 'lamest.options' => array( 41 | 'site_name' => 'Lamest News', 42 | ), 43 | )); 44 | 45 | // ************************************************************************** // 46 | 47 | $app->mount('/', new WebsiteController()); 48 | $app->mount('/api', new ApiController()); 49 | 50 | // ************************************************************************** // 51 | 52 | $app->run(); 53 | -------------------------------------------------------------------------------- /template/news.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% from 'shared.html.twig' import render_news, render_comments %} 3 | 4 | {% block content %} 5 | 6 |
7 | {{ render_news(news) }} 8 |
9 | 10 | {% if not news_domain(news) %} 11 | 12 |
13 | 14 | 15 | {{ news.username }} 16 | {{ news.ctime | elapsed }}. 17 | 18 |
{{ news_text(news) | commentize }}
19 |
20 |
21 | {% endif %} 22 | 23 | {% if app.user %} 24 |
25 | 26 | 27 | 28 |
29 | 30 |
31 | 32 |
33 | {% endif %} 34 | 35 | {% if comments %} 36 |
37 | {{ render_comments(comments, -1, 0) }} 38 |
39 | {% endif %} 40 | 41 | 46 | 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /template/userprofile.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html.twig' %} 2 | {% set owner = app.user and app.user.id == user.id %} 3 | 4 | {% block content %} 5 | 6 |
7 | 8 |

{{ user.username }}

9 |
{{ user.about }}
10 | 20 |
21 | 22 | {% if owner %} 23 | 24 |
25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 | 33 |
34 |
35 | 36 | 37 |
38 | 39 |
40 | 41 | 46 | 47 | {% endif %} 48 | 49 | {% endblock %} 50 | -------------------------------------------------------------------------------- /src/Lamest/Silex/LamestServiceProvider.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest\Silex; 13 | 14 | use Lamest\RedisEngine; 15 | use Silex\Application; 16 | use Silex\ServiceProviderInterface; 17 | use Symfony\Component\HttpFoundation\Request; 18 | 19 | /** 20 | * Service provider for using Lamest with Silex. 21 | * 22 | * @author Daniele Alessandri 23 | */ 24 | class LamestServiceProvider implements ServiceProviderInterface 25 | { 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function register(Application $app) 30 | { 31 | $app['lamest'] = $app->share(function (Application $app) { 32 | return new RedisEngine($app['predis'], $app['lamest.options']); 33 | }); 34 | 35 | $app['user'] = $app->share(function (Application $app) { 36 | return $app['lamest']->getUser(); 37 | }); 38 | 39 | $app->before(function (Request $request) use ($app) { 40 | $engine = $app['lamest']; 41 | $authToken = $request->cookies->get('auth'); 42 | 43 | if ($user = $engine->authenticateUser($authToken)) { 44 | $karmaIncrement = $engine->getOption('karma_increment_amount'); 45 | $karmaInterval = $engine->getOption('karma_increment_interval'); 46 | $engine->incrementUserKarma($user, $karmaIncrement, $karmaInterval); 47 | } 48 | }); 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | public function boot(Application $app) 55 | { 56 | if (!isset($app['lamest.options'])) { 57 | $app['lamest.options'] = array(); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /template/layout.html.twig: -------------------------------------------------------------------------------- 1 | {% set replies = app.user and app.user.replies is defined ? app.user.replies : 0 %} 2 | {% set site_name = app.lamest.getOption('site_name') %} 3 | 4 | 5 | 6 | 7 | {{ title }} - {{ site_name }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

18 | {{ site_name }} 19 | {{ constant('Lamest\\EngineInterface::VERSION') }} 20 |

21 | 29 |
38 | 39 |
40 | {% block content %}{% endblock %} 41 |
42 | 43 |
44 | source code | rss feed
45 | Lamest News v{{ constant('Lamest\\EngineInterface::VERSION') }} is based on Lamer News v{{ constant('Lamest\\EngineInterface::COMPATIBILITY') }} 46 |
47 | 48 | {% if app.user %} 49 | 50 | {% endif %} 51 | 52 | {% if app.lamest.getOption('keyboard_navigation') %} 53 | 54 | {% endif %} 55 |
56 | 57 | 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Lamest News 2 | =========== 3 | 4 | Lamer than lame. 5 | 6 | [Lamest News](http://github.com/nrk/lamestnews) is a port of [Salvatore Sanfilippo](http://antirez.com)'s 7 | [Lamer News](http://github.com/antirez/lamernews) to PHP implemented using [Silex](http://silex.sensiolabs.com) 8 | and [Twig](http://twig.sensiolabs.org) for the web part and [Predis](http://github.com/nrk/predis) to 9 | access the Redis datastore. 10 | 11 | While the project tries to stick to the original Ruby implementation as much as possible, especially when 12 | it comes to the layout of the data stored in Redis and the look and feel of its web interface, some things 13 | might differ (one of them is the use of a templating engine). 14 | 15 | Right now it is a work in progress and the priority so far has been to catch up with Lamer News, so 16 | there are still a lot of areas that could be improved and tests are still missing. As for performances, 17 | they are worse than the original Ruby implementation, even with an opcode cache like APC or XCache, 18 | mostly due to the fact that the application is reloaded on each request instead of being long-running, 19 | but a few experiments using [AiP](http://github.com/indeyets/appserver-in-php) showed that following 20 | such an approach would result in noticeable gains by bringing latency down to 5ms from the current 11ms. 21 | 22 | 23 | ## Installation 24 | 25 | Once the Git repository has been cloned, enter the directory and fetch all the needed dependencies using 26 | [Composer](http://packagist.org/about-composer) by typing `composer install` in a shell. If Composer is 27 | not installed or globally available on your system then you can download its phar package and use it to 28 | install the dependencies: 29 | 30 | ```bash 31 | $ wget http://getcomposer.org/composer.phar 32 | $ php composer.phar install 33 | ``` 34 | 35 | 36 | ## Execution 37 | 38 | When using PHP >= 5.4.0, you can start experimenting with Lamest News right away by using PHP's 39 | [built-in webserver](http://php.net/manual/en/features.commandline.webserver.php) simply by executing: 40 | 41 | ```bash 42 | $ php -S localhost:8000 -t web/ 43 | ``` 44 | 45 | In production environments you can just use any webserver by following the usual configuration 46 | instructions and pointing the document root to the `web` subdirectory of the repository. 47 | For performance reasons you might want to make sure that Silex debugging is disabled and the 48 | Twig template cache is set and points to a directory writable by the PHP process: 49 | 50 | ```php 51 | //... 52 | $app['debug'] = false; 53 | //... 54 | $app->register(new TwigProvider(), array( 55 | // ... 56 | 'twig.options' => array('cache' => '/path/to/cache'), 57 | // ... 58 | )); 59 | ``` 60 | 61 | 62 | ## Development 63 | 64 | When modifying code please make sure that no warnings or notices are emitted by PHP by running 65 | the interpreter in your development environment with the `error_reporting` variable set to 66 | `E_ALL | E_STRICT`. 67 | 68 | The recommended way to contribute is to fork the project on GitHub, create new topic branches on 69 | your newly created repository to fix or add features and then open a new pull request with a 70 | description of the applied changes. Obviously, you can use any other Git hosting provider of your 71 | preference. Diff patches will be accepted too, even though they are not the preferred way to 72 | contribute to the project. 73 | 74 | When reporting issues on the bug tracker please provide as much information as possible if you do 75 | not want to be redirected [here](http://yourbugreportneedsmore.info/). 76 | 77 | 78 | ## Dependencies 79 | 80 | - [PHP](http://www.php.net) >= 5.3.2 81 | - [Silex](http://silex.sensiolabs.com) 82 | - [Twig](http://twig.sensiolabs.com) 83 | - [Predis](http://github.com/nrk/predis) 84 | - [PredisServiceProvider](http://github.com/nrk/PredisServiceProvider) 85 | 86 | 87 | ## Authors 88 | 89 | - [Daniele Alessandri](mailto:suppakilla@gmail.com) ([twitter](http://twitter.com/JoL1hAHN)) 90 | 91 | 92 | ## License 93 | 94 | The code for Lamest News is distributed under the terms of the [MIT license](LICENSE). 95 | Parts taken from the original Lamer News code base remain licensed under the BSD license. 96 | -------------------------------------------------------------------------------- /template/shared.html.twig: -------------------------------------------------------------------------------- 1 | {% macro render_news(news) %} 2 | {% if news.del is not defined or news.del == false %} 3 |
4 | 5 | {% set is_editable = news_editable(app.user, news, app.lamest.getOption('news_edit_time')) %} 6 | 7 | {% if news_domain(news) %} 8 |

{{ news.title }}

9 |
10 | at {{ news_domain(news) }} 11 | {% if is_editable %}[edit]{% endif %} 12 |
13 | {% else %} 14 |

{{ news.title }}

15 |
{% if is_editable %}[edit]{% endif %}
16 | {% endif %} 17 | 18 | 19 |

20 | {{ news.up }} up and {{ news.down }} down, 21 | posted by {{ news.username }} 22 | {{ news.ctime | elapsed }} {{ news.comments }} comments 23 |

24 |
25 | {% else %} 26 |
[deleted news]
27 | {% endif %} 28 | {% endmacro %} 29 | 30 | {% macro vote_class(news, type) %} 31 | {% spaceless %} 32 | {% set typearrow = type ~ 'arrow ' %} 33 | {% if news.voted is defined and news.voted %} 34 | {{ typearrow ~ (news.voted == type ? 'voted' : 'disabled') }} 35 | {% else %} 36 | {{ typearrow }} 37 | {% endif %} 38 | {% endspaceless %} 39 | {% endmacro %} 40 | 41 | {% macro render_comments(tree, parent_id, level) %} 42 | {% for comment in sort_comments(tree[parent_id]) %} 43 | {% set parents = tree[comment.id] is defined ? tree[comment.id] : null %} 44 | {{ _self.render_comment(comment, level, parents) }} 45 | {% if parents %} 46 | {{ _self.render_comments(tree, comment.id, level + 1) }} 47 | {% endif %} 48 | {% endfor %} 49 | {% endmacro %} 50 | 51 | {% macro render_comment(comment, level, parents) %} 52 | {% set user = comment.user %} 53 | {% set news_id = comment.thread_id %} 54 | {% set css_indentation = level * app.lamest.getOption('comment_reply_shift') %} 55 | {% set edit_time_left = ((app.lamest.getOption('comment_edit_time') - (now() - comment.ctime)) / 60) | to_int %} 56 | 57 | {% if comment.del is not defined or comment.del == false and parents %} 58 | {% set comment_id = news_id ~ '-' ~ comment.id %} 59 |
60 | 61 | 62 | {{ user.username }} 63 | {{ comment.ctime | elapsed }}. 64 | link 65 | {% if app.user and comment.topcomment is not defined %} 66 | reply 67 | {% endif %} 68 | {{ comment_score(comment) }} points 69 | 70 | 71 | {% if app.user and app.user.id == comment.user_id and edit_time_left > 0 %} 72 | edit 73 | ({{ edit_time_left }} minutes left) 74 | {% endif %} 75 | 76 |
{{ comment.body | commentize }}
77 |
78 | {% else %} 79 |
[comment deleted]
80 | {% endif %} 81 | {% endmacro %} 82 | -------------------------------------------------------------------------------- /web/css/style.css: -------------------------------------------------------------------------------- 1 | html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td { margin: 0; padding: 0; border: 0; outline: 0; font-weight: inherit; font-style: inherit; font-size: 100%; font-family: inherit; vertical-align: baseline; } 2 | 3 | body { 4 | font-family: "Helvetica Neue"; 5 | text-rendering: optimizeLegibility; 6 | } 7 | 8 | a { 9 | text-decoration:none; 10 | color:#666; 11 | } 12 | 13 | h2 { 14 | margin-bottom:20px; 15 | font-size:22px; 16 | color:#333; 17 | } 18 | 19 | header { 20 | display:block; 21 | position:relative; 22 | padding: 15px 0px 0px 15px; 23 | background-color:#eee; 24 | border-bottom: 1px #ccc solid; 25 | } 26 | 27 | header h1 { 28 | display: inline; 29 | font-size:1.3em; 30 | font-weight:bold; 31 | color:#333; 32 | } 33 | 34 | header h1 a { 35 | color:#333; 36 | } 37 | 38 | header h1 small { 39 | font-size: 14px; 40 | } 41 | 42 | #content { 43 | display:block; 44 | margin-top:20px; 45 | margin-bottom:20px; 46 | padding: 15px; 47 | } 48 | 49 | footer { 50 | display:block; 51 | border-top: 1px #ccc solid; 52 | text-align: center; 53 | color:#ccc; 54 | font-size:14px; 55 | margin-bottom:30px; 56 | } 57 | 58 | nav { 59 | margin-bottom:15px; 60 | display: inline; 61 | } 62 | 63 | nav a { 64 | margin-left: 20px; 65 | } 66 | 67 | #account { 68 | color:#aaa; 69 | position: absolute; 70 | top: 5px; 71 | right: 10px; 72 | font-weight:bold; 73 | font-size:14px; 74 | } 75 | 76 | #account a { 77 | margin-left:10px; 78 | margin-right:10px; 79 | } 80 | 81 | #login,#submitform { 82 | display:block; 83 | position:relative; 84 | width:150px; 85 | } 86 | 87 | #login label, #submitform label { 88 | font-size:12px; 89 | font-weight:bold; 90 | } 91 | 92 | div #errormsg { 93 | width:400px; 94 | color:#ff9999; 95 | font-weight:bold; 96 | margin-top: 15px; 97 | } 98 | 99 | article { 100 | display: block; 101 | font-size: 16px; 102 | margin-bottom:15px; 103 | border: 1px solid transparent; 104 | } 105 | 106 | article.active { 107 | border: 1px dashed #000; 108 | } 109 | 110 | article h2 { 111 | display: inline; 112 | font-size:20px; 113 | } 114 | 115 | article address { 116 | display: inline; 117 | color: #999; 118 | font-size:14px; 119 | } 120 | 121 | article h2 a { 122 | color: black; 123 | } 124 | 125 | article h2 a:visited { 126 | color: #999999; 127 | } 128 | 129 | article .uparrow, article .downarrow { 130 | font-size: 16px; 131 | cursor: pointer; 132 | } 133 | 134 | article .uparrow { 135 | color:#999; 136 | } 137 | 138 | article .downarrow { 139 | color:#ccc; 140 | } 141 | 142 | article p { 143 | font-size:14px; 144 | color:#999; 145 | margin-left:20px; 146 | } 147 | 148 | article p a { 149 | color: #666; 150 | } 151 | 152 | article.comment .uparrow.voted, article .uparrow.voted { 153 | color:#99dd99; 154 | } 155 | 156 | article.comment .downarrow.voted, article .downarrow.voted { 157 | color:#dd9999; 158 | } 159 | 160 | article .downarrow.disabled, .uparrow.disabled { 161 | visibility: hidden; 162 | } 163 | 164 | article.deleted { 165 | padding-left:20px; 166 | color:#999; 167 | } 168 | 169 | form textarea { 170 | font-family: Courier; 171 | font-size:14px; 172 | } 173 | 174 | .comment { 175 | position:relative; 176 | width:500px; 177 | display:block; 178 | margin-bottom:40px; 179 | min-height:50px; 180 | } 181 | 182 | .comment .avatar img { 183 | position:absolute; 184 | top:0px; 185 | left:0px; 186 | width:48px; 187 | height:48px; 188 | } 189 | 190 | .comment .info { 191 | position:relative; 192 | left:60px; 193 | font-size:14px; 194 | color:#999; 195 | } 196 | 197 | .comment .info a.reply { 198 | text-decoration:underline; 199 | padding-left:5px; 200 | } 201 | 202 | .comment pre { 203 | width:50em; 204 | position:relative; 205 | left:60px; 206 | top:10px; 207 | font-family: Courier; 208 | font-size: 14px; 209 | white-space: pre-wrap; /* css-3 */ 210 | white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ 211 | white-space: -pre-wrap; /* Opera 4-6 */ 212 | white-space: -o-pre-wrap; /* Opera 7 */ 213 | word-wrap: break-word; /* Internet Explorer 5.5+ */ 214 | } 215 | 216 | article.comment .uparrow, article.comment .downarrow { 217 | color:#aaa; 218 | font-size:14px; 219 | } 220 | 221 | note { 222 | color: #666; 223 | margin-left:20px; 224 | font-size:14px; 225 | } 226 | 227 | .comment.deleted { 228 | color: #999; 229 | } 230 | 231 | topcomment { 232 | display:block; 233 | margin:20px; 234 | } 235 | 236 | .singlecomment { 237 | background-color: #eee; 238 | border: 1px dotted #ccc; 239 | padding: 15px; 240 | margin:10px; 241 | } 242 | 243 | .commentreplies h2 { 244 | font-size: 18px; 245 | border-bottom: 1px solid #aaa; 246 | } 247 | 248 | .commentreplies { 249 | margin-left:50px; 250 | margin-bottom:50px; 251 | } 252 | 253 | .userinfo ul li { 254 | list-style-type: none; 255 | margin-bottom:10px; 256 | } 257 | 258 | .userinfo pre { 259 | color: #666; 260 | font-style: italic; 261 | margin:20px; 262 | } 263 | 264 | .userinfo .avatar img { 265 | width:48px; 266 | height:48px; 267 | } 268 | 269 | .userinfo h2 { 270 | position:relative; 271 | left:15px; 272 | top:-15px; 273 | display:inline; 274 | font-size:28px; 275 | color: #444; 276 | } 277 | 278 | .userinfo a { 279 | text-decoration: underline; 280 | } 281 | 282 | .replies sup { 283 | font-size:12px; 284 | color: white; 285 | background-color:red; 286 | border: red 1px solid; 287 | border-radius: 25px; 288 | padding-left:3px; 289 | padding-right:3px; 290 | position:relative; 291 | top:-7px; 292 | left:-5px; 293 | opacity:0.7 294 | } 295 | -------------------------------------------------------------------------------- /src/Lamest/Twig/LamestExtension.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest\Twig; 13 | 14 | use \Twig_Environment; 15 | use \Twig_Extension; 16 | use \Twig_Filter_Method; 17 | use \Twig_Filter_Function; 18 | use \Twig_Function_Function; 19 | 20 | /** 21 | * Twig extension that provides common filters and functions used in 22 | * the templates that compose an Lamest-based website. 23 | * 24 | * @author Daniele Alessandri 25 | */ 26 | class LamestExtension extends Twig_Extension 27 | { 28 | const COMMENT_LINKS = '/((https?:\/\/|www\.)([-\w\.]+)+(:\d+)?(\/([\w\/_\.\-\%]*(\?\S+)?)?)?)/'; 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function getName() 34 | { 35 | return 'lamest'; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function getFilters() 42 | { 43 | return array( 44 | 'to_int' => new Twig_Filter_Function('intval'), 45 | 'elapsed' => new Twig_Filter_Function(__CLASS__.'::timeElapsed'), 46 | 'commentize' => new Twig_Filter_Function(__CLASS__.'::renderCommentText', array( 47 | 'needs_environment' => true, 48 | 'is_safe' => array('html'), 49 | )), 50 | ); 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | public function getFunctions() 57 | { 58 | return array( 59 | 'now' => new Twig_Function_Function('time'), 60 | 'gravatar' => new Twig_Function_Function(__CLASS__.'::getGravatarLink'), 61 | 'news_editable' => new Twig_Function_Function(__CLASS__.'::isNewsEditable'), 62 | 'comment_score' => new Twig_Function_Function(__CLASS__.'::commentScore'), 63 | 'sort_comments'=> new Twig_Function_Function(__CLASS__.'::sortComments'), 64 | 'full_url' => new Twig_Function_Function('Lamest\Helpers::getSiteURL'), 65 | 'news_domain' => new Twig_Function_Function('Lamest\Helpers::getNewsDomain'), 66 | 'news_text' => new Twig_Function_Function('Lamest\Helpers::getNewsText'), 67 | ); 68 | } 69 | 70 | /** 71 | * Returns a formatted string representing the time elapsed from the 72 | * specified UNIX time. 73 | * 74 | * @param int $time Time in seconds. 75 | * @return string 76 | */ 77 | public static function timeElapsed($time) 78 | { 79 | if (($elapsed = time() - $time) <= 10) { 80 | return 'now'; 81 | } 82 | 83 | if ($elapsed < 60) { 84 | return sprintf("%d %s ago", $elapsed, 'seconds'); 85 | } 86 | 87 | if ($elapsed < 60 * 60) { 88 | return sprintf("%d %s ago", $elapsed / 60, 'minutes'); 89 | } 90 | 91 | if ($elapsed < 60 * 60 * 24) { 92 | return sprintf("%d %s ago", $elapsed / 60 / 60, 'hours'); 93 | } 94 | 95 | return sprintf("%d %s ago", $elapsed / 60 / 60 / 24, 'days'); 96 | } 97 | 98 | /** 99 | * Generates the URL to the Gravatar of a user. 100 | * 101 | * @param string $email User email. 102 | * @return array $options Options. 103 | * @return string 104 | */ 105 | public static function getGravatarLink($email, $options = array()) 106 | { 107 | $options = array_merge(array('s' => 48, 'd' => 'mm'), $options); 108 | $url = 'http://gravatar.com/avatar/' . md5($email) . '?'; 109 | 110 | if ($options) { 111 | foreach ($options as $k => $v) { 112 | $url .= urlencode($k) . '=' . urlencode($v) . '&'; 113 | } 114 | } 115 | 116 | return substr($url, 0, -1); 117 | } 118 | 119 | /** 120 | * Checks if a news is editable by the specified user. 121 | * 122 | * @param array $user User details. 123 | * @param array $news News details. 124 | * @param int $timeLimit Limit in seconds for the editable status. 125 | * @return boolean 126 | */ 127 | public static function isNewsEditable(Array $user, Array $news, $timeLimit = 900) 128 | { 129 | if (!$user) { 130 | return false; 131 | } 132 | 133 | return $user['id'] == $news['user_id'] && $news['ctime'] > (time() - $timeLimit); 134 | } 135 | 136 | /** 137 | * Computes a score for the specified comment. 138 | * 139 | * @param array $comment Comment details. 140 | * @return int 141 | */ 142 | public static function commentScore(Array $comment) 143 | { 144 | $upvotes = isset($comment['up']) ? count($comment['up']) : 0; 145 | $downvotes = isset($comment['down']) ? count($comment['down']) : 0; 146 | 147 | return $upvotes - $downvotes; 148 | } 149 | 150 | /** 151 | * Sort the passed list of comments. 152 | * 153 | * @param array $comments List of comments. 154 | * @return array 155 | */ 156 | public static function sortComments(Array $comments) 157 | { 158 | uasort($comments, function($a, $b) { 159 | $ascore = LamestExtension::commentScore($a); 160 | $bscore = LamestExtension::commentScore($b); 161 | 162 | if ($ascore == $bscore) { 163 | return $a['ctime'] != $b['ctime'] ? ($b['ctime'] < $a['ctime'] ? -1 : 1) : 0; 164 | } 165 | 166 | return $bscore < $ascore ? -1 : 1; 167 | }); 168 | 169 | return $comments; 170 | } 171 | 172 | /** 173 | * Callback used by preg_replace_callback to transform URL occurrences 174 | * in a string into HTML links. 175 | * 176 | * The returned string MUST NOT be escaped. 177 | * 178 | * @param array $matches Matches from preg_replace_callback. 179 | * @return string 180 | */ 181 | protected static function linkifierCallback(Array $matches) 182 | { 183 | $url = $matches[0]; 184 | $dot = ''; 185 | 186 | if ($url[strlen($url) - 1] === '.') { 187 | $url = substr($url, 0, -1); 188 | $dot = '.'; 189 | } 190 | 191 | return "$url$dot"; 192 | } 193 | 194 | /** 195 | * Escapes text for output with some additional processing. 196 | * 197 | * @param Twig_Environment $env Twig environment. 198 | * @param string $text Text to parse and escape. 199 | * @return string 200 | */ 201 | public static function renderCommentText($env, $text) 202 | { 203 | // Escape HTML first using Twig's standard escape filter. 204 | $escaper = $env->getFilter('escape')->compile(); 205 | $text = $escaper($env, $text); 206 | 207 | // Transform URLs in text into HTML links. 208 | $text = preg_replace_callback(self::COMMENT_LINKS, 'self::linkifierCallback', $text); 209 | 210 | return $text; 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/Lamest/Helpers.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest; 13 | 14 | use Symfony\Component\HttpFoundation\Request; 15 | use Symfony\Component\HttpFoundation\Response; 16 | 17 | /** 18 | * Shared helpers for the application. 19 | * 20 | * @author Daniele Alessandri 21 | */ 22 | class Helpers 23 | { 24 | /** 25 | * Generates a random ID. 26 | * 27 | * @return string 28 | */ 29 | public static function generateRandom() 30 | { 31 | if (!@is_readable('/dev/urandom')) { 32 | throw new \Exception("Cannot generate a random ID (Unreadable /dev/urandom)"); 33 | } 34 | 35 | $resource = fopen('/dev/urandom', 'r'); 36 | $urandom = fread($resource, 20); 37 | fclose($resource); 38 | 39 | return bin2hex($urandom); 40 | } 41 | 42 | /** 43 | * Generates a key from password and salt using the PBKDF2 algorithm. 44 | * 45 | * @link http://www.ietf.org/rfc/rfc2898.txt 46 | * @link http://gist.github.com/1162409 47 | * 48 | * @param string $password Password. 49 | * @param string $salt Salt. 50 | * @param int $keyLength Length of the derived key. 51 | * @param int $iterations Number of iterations. 52 | * @param string $algorithm Hash algorithm. 53 | * @return string 54 | */ 55 | public static function pbkdf2($password, $salt, $keyLength = 160, $iterations = 1000, $algorithm = 'sha1') 56 | { 57 | $derivedKey = ''; 58 | 59 | for ($blockPos = 1; $blockPos < $keyLength; $blockPos++) { 60 | $block = $hmac = hash_hmac($algorithm, $salt . pack('N', $blockPos), $password, true); 61 | 62 | for ($i = 1; $i < $iterations; $i++) { 63 | $block ^= ($hmac = hash_hmac($algorithm, $hmac, $password, true)); 64 | } 65 | 66 | $derivedKey .= $block; 67 | } 68 | 69 | return bin2hex(substr($derivedKey, 0, $keyLength)); 70 | } 71 | 72 | /** 73 | * Verifies the validity of a request by comparing the passed api secret 74 | * key with the one stored for the specified user. 75 | * 76 | * @param array $user Logged-in user details. 77 | * @param string $apisecret Token. 78 | */ 79 | public static function verifyApiSecret(Array $user, $apisecret) 80 | { 81 | if (!isset($user) || !isset($user['apisecret'])) { 82 | return false; 83 | } 84 | 85 | return $user['apisecret'] === $apisecret; 86 | } 87 | 88 | /** 89 | * Returns the host part from the URL of a news item, if present. 90 | * 91 | * @param array $news News item details. 92 | * @return string 93 | */ 94 | public static function getNewsDomain(Array $news) 95 | { 96 | if (strpos($news['url'], 'text://') === 0) { 97 | return; 98 | } 99 | 100 | return parse_url($news['url'], PHP_URL_HOST); 101 | } 102 | 103 | /** 104 | * Returns the text excerpt from a text:// URL of a news item. 105 | * 106 | * @param array $news News item details. 107 | * @return string 108 | */ 109 | public static function getNewsText(Array $news) 110 | { 111 | if (strpos($news['url'], 'text://') !== 0) { 112 | return; 113 | } 114 | 115 | return substr($news['url'], strlen('text://')); 116 | } 117 | 118 | /** 119 | * Verifies if the request for the user is valid. 120 | * 121 | * @param array $user User details. 122 | * @param string $apisecret API secret token. 123 | * @param string $response Error message on invalid requests. 124 | * @return boolean 125 | */ 126 | public static function isRequestValid(Array $user, $apisecret, &$response) 127 | { 128 | if (!$user) { 129 | $response = Helpers::apiError('Not authenticated.'); 130 | 131 | return false; 132 | } 133 | 134 | if (!Helpers::verifyApiSecret($user, $apisecret)) { 135 | $response = Helpers::apiError('Wrong form secret.'); 136 | 137 | return false; 138 | } 139 | 140 | return true; 141 | } 142 | 143 | /** 144 | * Returns the full URL for the specified path depending on the request. 145 | * 146 | * @param string $path Absolute path. 147 | * @return string 148 | */ 149 | public static function getSiteURL(Request $request, $path = '/') 150 | { 151 | return $request->getUriForPath($path); 152 | } 153 | 154 | /** 155 | * Generates the response payload for an API call when it is successful. 156 | * 157 | * @param array $response Other values that compose the response. 158 | * @return string 159 | */ 160 | public static function apiOK(Array $response = array()) 161 | { 162 | $json = json_encode(array_merge($response, array('status' => 'ok'))); 163 | 164 | return new Response($json, 200, array( 165 | 'Content-Type' => 'application/json', 166 | )); 167 | } 168 | 169 | /** 170 | * Generates the response payload for an API call when it fails. 171 | * 172 | * @param string $error Error message. 173 | * @param array $response Other values that compose the response. 174 | * @return string 175 | */ 176 | public static function apiError($error, Array $response = array()) 177 | { 178 | $json = json_encode(array_merge($response, array( 179 | 'status' => 'err', 180 | 'error' => $error, 181 | ))); 182 | 183 | return new Response($json, 200, array( 184 | 'Content-Type' => 'application/json', 185 | )); 186 | } 187 | 188 | /** 189 | * Checks if a comment has been voted by the specified user and returns 190 | * which kind of vote has been given, or FALSE. 191 | * 192 | * @param array $user User details. 193 | * @param array $comment Comment details. 194 | * @param string $vote Type of vote (either up or down) 195 | * @return mixed 196 | */ 197 | public static function commentVoted(Array $user, Array $comment) 198 | { 199 | if (!$user) { 200 | return false; 201 | } 202 | 203 | $votes = isset($comment['up']) ? $comment['up'] : array(); 204 | 205 | if (in_array($user['id'], $votes)) { 206 | return 'up'; 207 | } 208 | 209 | $votes = isset($comment['down']) ? $comment['down'] : array(); 210 | 211 | if (in_array($user['id'], $votes)) { 212 | return 'down'; 213 | } 214 | 215 | return false; 216 | } 217 | 218 | /** 219 | * Returns the details for a generic deleted user. 220 | * 221 | * @return array 222 | */ 223 | public static function getDeletedUser() 224 | { 225 | return array('username' => 'deleted_user', 'email' => '', 'id' => -1); 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /web/js/app.js: -------------------------------------------------------------------------------- 1 | function login() { 2 | var data = { 3 | username: $("input[name=username]").val(), 4 | password: $("input[name=password]").val(), 5 | }; 6 | var register = $("input[name=register]").attr("checked"); 7 | $.ajax({ 8 | type: register ? "POST" : "GET", 9 | url: register ? "/api/create_account" : "/api/login", 10 | data: data, 11 | success: function(r) { 12 | if (r.status == "ok") { 13 | document.cookie = 14 | 'auth='+r.auth+ 15 | '; expires=Thu, 1 Aug 2030 20:00:00 UTC; path=/'; 16 | window.location.href = "/"; 17 | } else { 18 | $("#errormsg").html(r.error) 19 | } 20 | } 21 | }); 22 | return false; 23 | } 24 | 25 | function submit() { 26 | var data = { 27 | news_id: $("input[name=news_id]").val(), 28 | title: $("input[name=title]").val(), 29 | url: $("input[name=url]").val(), 30 | text: $("textarea[name=text]").val(), 31 | apisecret: apisecret 32 | }; 33 | var del = $("input[name=del]").length && $("input[name=del]").attr("checked"); 34 | $.ajax({ 35 | type: "POST", 36 | url: del ? "/api/delnews" : "/api/submit", 37 | data: data, 38 | success: function(r) { 39 | if (r.status == "ok") { 40 | if (r.news_id == -1) { 41 | window.location.href = "/"; 42 | } else { 43 | window.location.href = "/news/"+r.news_id; 44 | } 45 | } else { 46 | $("#errormsg").html(r.error) 47 | } 48 | } 49 | }); 50 | return false; 51 | } 52 | 53 | function update_profile() { 54 | var data = { 55 | email: $("input[name=email]").val(), 56 | password: $("input[name=password]").val(), 57 | about: $("textarea[name=about]").val(), 58 | apisecret: apisecret 59 | }; 60 | $.ajax({ 61 | type: "POST", 62 | url: "/api/updateprofile", 63 | data: data, 64 | success: function(r) { 65 | if (r.status == "ok") { 66 | window.location.reload(); 67 | } else { 68 | $("#errormsg").html(r.error) 69 | } 70 | } 71 | }); 72 | return false; 73 | } 74 | 75 | function post_comment() { 76 | var data = { 77 | news_id: $("input[name=news_id]").val(), 78 | comment_id: $("input[name=comment_id]").val(), 79 | parent_id: $("input[name=parent_id]").val(), 80 | comment: $("textarea[name=comment]").val(), 81 | apisecret: apisecret 82 | }; 83 | $.ajax({ 84 | type: "POST", 85 | url: "/api/postcomment", 86 | data: data, 87 | success: function(r) { 88 | if (r.status == "ok") { 89 | if (r.op == "insert") { 90 | window.location.href = "/news/"+r.news_id+"?r="+Math.random()+"#"+ 91 | r.news_id+"-"+r.comment_id; 92 | } else if (r.op == "update") { 93 | window.location.href = "/editcomment/"+r.news_id+"/"+ 94 | r.comment_id; 95 | } else if (r.op == "delete") { 96 | window.location.href = "/news/"+r.news_id; 97 | } 98 | } else { 99 | $("#errormsg").html(r.error) 100 | } 101 | } 102 | }); 103 | return false; 104 | } 105 | 106 | function setKeyboardNavigation() { 107 | $(function() { 108 | $(document).keyup(function(e) { 109 | var active = $('article.active'); 110 | if (e.which == 74 || e.which == 75) { 111 | var newActive; 112 | if (active.length == 0) { 113 | if (e.which == 74) { 114 | newActive = $('article').first(); 115 | } else { 116 | newActive = $('article').last(); 117 | } 118 | } else if (e.which == 74){ 119 | newActive = $($('article').get($('article').index(active)+1)); 120 | } else if (e.which == 75){ 121 | var index = $('article').index(active); 122 | if (index == 0) return; 123 | newActive = $($('article').get(index-1)); 124 | } 125 | if (newActive.length == 0) return; 126 | active.removeClass('active'); 127 | newActive.addClass('active'); 128 | if ($(window).scrollTop() > newActive.offset().top) 129 | $('html, body').animate({ scrollTop: newActive.offset().top - 10 }, 100); 130 | if ($(window).scrollTop() + $(window).height() < newActive.offset().top) 131 | $('html, body').animate({ scrollTop: newActive.offset().top - $(window).height() + newActive.height() + 10 }, 100); 132 | } 133 | if (e.which == 13 && active.length > 0) { 134 | location.href = active.find('h2 a').attr('href'); 135 | } 136 | if (e.which == 65 && active.length > 0) { 137 | active.find('.uparrow').click(); 138 | } 139 | if (e.which == 90 && active.length > 0) { 140 | active.find('.downarrow').click(); 141 | } 142 | }); 143 | $('#newslist article').each(function(i,news) { 144 | $(news).click(function() { 145 | var active = $('article.active'); 146 | active.removeClass('active'); 147 | $(news).addClass('active'); 148 | }); 149 | }); 150 | }); 151 | } 152 | 153 | // Install the onclick event in all news arrows the user did not voted already. 154 | $(function() { 155 | $('#newslist article').each(function(i,news) { 156 | var news_id = $(news).data("newsId"); 157 | news = $(news); 158 | up = news.find(".uparrow"); 159 | down = news.find(".downarrow"); 160 | var voted = up.hasClass("voted") || down.hasClass("voted"); 161 | if (!voted) { 162 | up.click(function(e) { 163 | if (typeof(apisecret) == 'undefined') return; // Not logged in 164 | e.preventDefault(); 165 | var data = { 166 | news_id: news_id, 167 | vote_type: "up", 168 | apisecret: apisecret 169 | }; 170 | $.ajax({ 171 | type: "POST", 172 | url: "/api/votenews", 173 | data: data, 174 | success: function(r) { 175 | if (r.status == "ok") { 176 | n = $("article[data-news-id="+news_id+"]"); 177 | n.find(".uparrow").addClass("voted"); 178 | n.find(".downarrow").addClass("disabled"); 179 | } else { 180 | alert(r.error); 181 | } 182 | } 183 | }); 184 | }); 185 | 186 | down.click(function(e) { 187 | if (typeof(apisecret) == 'undefined') return; // Not logged in 188 | e.preventDefault(); 189 | var data = { 190 | news_id : news_id, 191 | vote_type: "down", 192 | apisecret: apisecret 193 | }; 194 | $.ajax({ 195 | type: "POST", 196 | url: "/api/votenews", 197 | data: data, 198 | success: function(r) { 199 | if (r.status == "ok") { 200 | n = $("article[data-news-id="+news_id+"]"); 201 | n.find(".uparrow").addClass("disabled"); 202 | n.find(".downarrow").addClass("voted"); 203 | } else { 204 | alert(r.error); 205 | } 206 | } 207 | }); 208 | }); 209 | } 210 | }); 211 | }); 212 | 213 | // Install the onclick event in all comments arrows the user did not 214 | // voted already. 215 | $(function() { 216 | $('#comments article.comment, .singlecomment article.comment').each(function(i,comment) { 217 | var comment_id = $(comment).data("commentId"); 218 | comment = $(comment); 219 | up = comment.find(".uparrow"); 220 | down = comment.find(".downarrow"); 221 | var voted = up.hasClass("voted") || down.hasClass("voted"); 222 | if (!voted) { 223 | up.click(function(e) { 224 | if (typeof(apisecret) == 'undefined') return; // Not logged in 225 | e.preventDefault(); 226 | var data = { 227 | comment_id: comment_id, 228 | vote_type: "up", 229 | apisecret: apisecret 230 | }; 231 | $.ajax({ 232 | type: "POST", 233 | url: "/api/votecomment", 234 | data: data, 235 | success: function(r) { 236 | if (r.status == "ok") { 237 | $('article[data-comment-id="'+r.comment_id+'"]').find(".uparrow").addClass("voted") 238 | $('article[data-comment-id="'+r.comment_id+'"]').find(".downarrow").addClass("disabled") 239 | } else { 240 | alert(r.error); 241 | } 242 | } 243 | }); 244 | }); 245 | down.click(function(e) { 246 | if (typeof(apisecret) == 'undefined') return; // Not logged in 247 | e.preventDefault(); 248 | var data = { 249 | comment_id: comment_id, 250 | vote_type: "down", 251 | apisecret: apisecret 252 | }; 253 | $.ajax({ 254 | type: "POST", 255 | url: "/api/votecomment", 256 | data: data, 257 | success: function(r) { 258 | if (r.status == "ok") { 259 | $('article[data-comment-id="'+r.comment_id+'"]').find(".uparrow").addClass("disabled") 260 | $('article[data-comment-id="'+r.comment_id+'"]').find(".downarrow").addClass("voted") 261 | } else { 262 | alert(r.error); 263 | } 264 | } 265 | }); 266 | }); 267 | } 268 | }); 269 | }); 270 | -------------------------------------------------------------------------------- /src/Lamest/Silex/WebsiteController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest\Silex; 13 | 14 | use Lamest\Helpers as H; 15 | use Silex\Application; 16 | use Silex\ControllerProviderInterface; 17 | use Symfony\Component\HttpFoundation\Request; 18 | use Symfony\Component\HttpFoundation\Response; 19 | 20 | /** 21 | * Defines the methods and routes for the website. 22 | * 23 | * @author Daniele Alessandri 24 | */ 25 | class WebsiteController implements ControllerProviderInterface 26 | { 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function connect(Application $app) 31 | { 32 | $controllers = $app['controllers_factory']; 33 | 34 | $controllers->get('/', function (Application $app) { 35 | $newslist = $app['lamest']->getTopNews($app['user']); 36 | 37 | return $app['twig']->render('newslist.html.twig', array( 38 | 'title' => 'Top news', 39 | 'head_title' => 'Top news', 40 | 'newslist' => $newslist['news'], 41 | )); 42 | }); 43 | 44 | $controllers->get('/rss', function (Application $app, Request $request) { 45 | $newslist = $app['lamest']->getLatestNews($app['user']); 46 | 47 | $rss = $app['twig']->render('newslist.rss.twig', array( 48 | 'site_name' => 'Lamer News', 49 | 'description' => 'Latest news', 50 | 'newslist' => $newslist['news'], 51 | )); 52 | 53 | return new Response($rss, 200, array( 54 | 'Content-Type' => 'text/xml', 55 | )); 56 | }); 57 | 58 | $controllers->get('/latest/{start}', function (Application $app, $start) { 59 | $engine = $app['lamest']; 60 | $perpage = $engine->getOption('latest_news_per_page'); 61 | $newslist = $engine->getLatestNews($app['user'], $start, $perpage); 62 | 63 | return $app['twig']->render('newslist.html.twig', array( 64 | 'title' => 'Latest news', 65 | 'newslist' => $newslist['news'], 66 | 'pagination' => array( 67 | 'start' => $start, 68 | 'count' => $newslist['count'], 69 | 'perpage' => $perpage, 70 | 'linkbase' => 'latest', 71 | ), 72 | )); 73 | })->value('start', 0); 74 | 75 | $controllers->get('/saved/{start}', function (Application $app, $start) { 76 | if (!$app['user']) { 77 | return $app->redirect('/login'); 78 | } 79 | 80 | $engine = $app['lamest']; 81 | $perpage = $engine->getOption('latest_news_per_page'); 82 | $newslist = $engine->getSavedNews($app['user'], $start); 83 | 84 | return $app['twig']->render('newslist.html.twig', array( 85 | 'title' => 'Saved news', 86 | 'head_title' => 'Your saved news', 87 | 'newslist' => $newslist['news'], 88 | 'pagination' => array( 89 | 'start' => $start, 90 | 'count' => $newslist['count'], 91 | 'perpage' => $perpage, 92 | 'linkbase' => 'saved', 93 | ), 94 | )); 95 | })->value('start', 0); 96 | 97 | $controllers->get('/usercomments/{username}/{start}', function (Application $app, $username, $start) { 98 | $user = $app['lamest']->getUserByUsername($username); 99 | 100 | if (!$user) { 101 | return $app->abort(404, 'Non existing user'); 102 | } 103 | 104 | $perpage = $app['lamest']->getOption('user_comments_per_page'); 105 | $comments = $app['lamest']->getUserComments($user, $start ?: 0, $perpage); 106 | 107 | return $app['twig']->render('user_comments.html.twig', array( 108 | 'title' => "$username comments", 109 | 'comments' => $comments['list'], 110 | 'username' => $username, 111 | 'pagination' => array( 112 | 'start' => $start, 113 | 'count' => $comments['total'], 114 | 'perpage' => $perpage, 115 | ), 116 | )); 117 | })->value('start', 0); 118 | 119 | $controllers->get('/login', function (Application $app) { 120 | return $app['twig']->render('login.html.twig', array( 121 | 'title' => 'Login', 122 | )); 123 | }); 124 | 125 | $controllers->get('/logout', function (Application $app, Request $request) { 126 | $apisecret = $request->get('apisecret'); 127 | 128 | if (isset($app['user']) && H::verifyApiSecret($app['user'], $apisecret)) { 129 | $app['lamest']->updateAuthToken($app['user']['id']); 130 | } 131 | 132 | return $app->redirect('/'); 133 | }); 134 | 135 | $controllers->get('/submit', function (Application $app, Request $request) { 136 | if (!$app['user']) { 137 | return $app->redirect('/login'); 138 | } 139 | 140 | return $app['twig']->render('submit_news.html.twig', array( 141 | 'title' => 'Submit a new story', 142 | 'bm_url' => $request->get('u'), 143 | 'bm_title' => $request->get('t'), 144 | )); 145 | }); 146 | 147 | $controllers->get('/news/{newsID}', function (Application $app, $newsID) { 148 | $engine = $app['lamest']; 149 | @list($news) = $engine->getNewsByID($app['user'], $newsID); 150 | 151 | if (!$news) { 152 | return $app->abort(404, 'This news does not exist.'); 153 | } 154 | 155 | return $app['twig']->render('news.html.twig', array( 156 | 'title' => $news['title'], 157 | 'news' => $news, 158 | 'user' => $engine->getUserByID($news['user_id']), 159 | 'comments' => $engine->getNewsComments($app['user'], $news), 160 | )); 161 | }); 162 | 163 | $controllers->get('/comment/{newsID}/{commentID}', function (Application $app, $newsID, $commentID) { 164 | $engine = $app['lamest']; 165 | 166 | if (!$news = $engine->getNewsByID($app['user'], $newsID)) { 167 | return $app->abort(404, 'This news does not exist.'); 168 | } 169 | 170 | if (!$comment = $engine->getComment($newsID, $commentID)) { 171 | return $app->abort(404, 'This comment does not exist.'); 172 | } 173 | 174 | if (!$user = $engine->getUserByID($comment['user_id'])) { 175 | $user = H::getDeletedUser(); 176 | } 177 | 178 | list($news) = $news; 179 | 180 | return $app['twig']->render('permalink_to_comment.html.twig', array( 181 | 'title' => $news['title'], 182 | 'news' => $news, 183 | 'comment' => array_merge($comment, array( 184 | 'id' => $commentID, 185 | 'user' => $user, 186 | 'voted' => H::commentVoted($app['user'], $comment), 187 | )), 188 | 'comments' => $engine->getNewsComments($app['user'], $news), 189 | )); 190 | }); 191 | 192 | $controllers->get('/reply/{newsID}/{commentID}', function (Application $app, $newsID, $commentID) { 193 | $engine = $app['lamest']; 194 | 195 | if (!$app['user']) { 196 | return $app->redirect('/login'); 197 | } 198 | 199 | if (!$news = $engine->getNewsByID($app['user'], $newsID)) { 200 | return $app->abort(404, 'This news does not exist.'); 201 | } 202 | 203 | if (!$comment = $engine->getComment($newsID, $commentID)) { 204 | return $app->abort(404, 'This comment does not exist.'); 205 | } 206 | 207 | if (!$user = $engine->getUserByID($comment['user_id'])) { 208 | $user = H::getDeletedUser(); 209 | } 210 | 211 | list($news) = $news; 212 | 213 | return $app['twig']->render('reply_to_comment.html.twig', array( 214 | 'title' => 'Reply to comment', 215 | 'news' => $news, 216 | 'comment' => array_merge($comment, array( 217 | 'id' => $commentID, 218 | 'user' => $user, 219 | 'voted' => H::commentVoted($app['user'], $comment), 220 | )), 221 | )); 222 | }); 223 | 224 | $controllers->get('/replies', function (Application $app) { 225 | $engine = $app['lamest']; 226 | 227 | if (!$app['user']) { 228 | return $app->redirect('/login'); 229 | } 230 | 231 | $perpage = $engine->getOption('subthreads_in_replies_page') - 1; 232 | $comments = $engine->getReplies($app['user'], $perpage, true); 233 | 234 | return $app['twig']->render('user_replies.html.twig', array( 235 | 'title' => 'Your threads', 236 | 'comments' => $comments, 237 | )); 238 | }); 239 | 240 | $controllers->get('/editcomment/{newsID}/{commentID}', function (Application $app, $newsID, $commentID) { 241 | $engine = $app['lamest']; 242 | 243 | if (!$app['user']) { 244 | return $app->redirect('/login'); 245 | } 246 | 247 | if (!$news = $engine->getNewsByID($app['user'], $newsID)) { 248 | return $app->abort(404, 'This news does not exist.'); 249 | } 250 | 251 | if (!$comment = $engine->getComment($newsID, $commentID)) { 252 | return $app->abort(404, 'This comment does not exist.'); 253 | } 254 | 255 | $user = $engine->getUserByID($comment['user_id']); 256 | 257 | if (!$user || $app['user']['id'] != $user['id']) { 258 | return $app->abort(500, 'Permission denied.'); 259 | } 260 | 261 | list($news) = $news; 262 | 263 | return $app['twig']->render('edit_comment.html.twig', array( 264 | 'title' => 'Edit comment', 265 | 'news' => $news, 266 | 'comment' => array_merge($comment, array( 267 | 'id' => $commentID, 268 | 'user' => $user, 269 | 'voted' => H::commentVoted($app['user'], $comment), 270 | )), 271 | )); 272 | }); 273 | 274 | $controllers->get('/editnews/{newsID}', function (Application $app, $newsID) { 275 | $engine = $app['lamest']; 276 | 277 | if (!$app['user']) { 278 | return $app->redirect('/login'); 279 | } 280 | 281 | if (!$news = $engine->getNewsByID($app['user'], $newsID)) { 282 | return $app->abort(404, 'This news does not exist.'); 283 | } 284 | 285 | list($news) = $news; 286 | 287 | $user = $engine->getUserByID($news['user_id']); 288 | if (!$user || $app['user']['id'] != $user['id']) { 289 | return $app->abort(500, 'Permission denied.'); 290 | } 291 | 292 | $text = ''; 293 | 294 | if (!H::getNewsDomain($news)) { 295 | $text = H::getNewsText($news); 296 | $news['url'] = ''; 297 | } 298 | 299 | return $app['twig']->render('edit_news.html.twig', array( 300 | 'title' => 'Edit news', 301 | 'news' => $news, 302 | 'text' => $text, 303 | )); 304 | }); 305 | 306 | $controllers->get('/user/{username}', function (Application $app, $username) { 307 | $engine = $app['lamest']; 308 | $user = $engine->getUserByUsername($username); 309 | 310 | if (!$user) { 311 | return $app->abort(404, 'Non existing user'); 312 | } 313 | 314 | return $app['twig']->render('userprofile.html.twig', array( 315 | 'title' => $username, 316 | 'user' => $user, 317 | 'user_counters' => $engine->getUserCounters($user), 318 | )); 319 | }); 320 | 321 | return $controllers; 322 | } 323 | } 324 | -------------------------------------------------------------------------------- /src/Lamest/Silex/ApiController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest\Silex; 13 | 14 | use Lamest\Helpers as H; 15 | use Silex\Application; 16 | use Silex\ControllerProviderInterface; 17 | use Symfony\Component\HttpFoundation\Request; 18 | 19 | /** 20 | * Defines methods and routes exposing the public API of the application. 21 | * 22 | * @author Daniele Alessandri 23 | */ 24 | class ApiController implements ControllerProviderInterface 25 | { 26 | /** 27 | * {@inheritdoc} 28 | */ 29 | public function connect(Application $app) 30 | { 31 | $controllers = $app['controllers_factory']; 32 | 33 | $controllers->get('/login', function (Application $app, Request $request) { 34 | $username = $request->get('username'); 35 | $password = $request->get('password'); 36 | 37 | if (!strlen(trim($username)) || !strlen(trim($password))) { 38 | return H::apiError('Username and password are two required fields.'); 39 | } 40 | 41 | @list($auth, $apisecret) = $app['lamest']->verifyUserCredentials($username, $password); 42 | 43 | if (!isset($auth)) { 44 | return H::apiError('No match for the specified username / password pair.'); 45 | } 46 | 47 | return H::apiOK(array('auth' => $auth, 'apisecret' => $apisecret)); 48 | }); 49 | 50 | $controllers->post('/logout', function (Application $app, Request $request) { 51 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 52 | return $error; 53 | } 54 | 55 | $app['lamest']->updateAuthToken($app['user']['id']); 56 | 57 | return H::apiOK(); 58 | }); 59 | 60 | $controllers->post('/create_account', function (Application $app, Request $request) { 61 | $engine = $app['lamest']; 62 | $username = $request->get('username'); 63 | $password = $request->get('password'); 64 | 65 | if (!strlen(trim($username)) || !strlen(trim($password))) { 66 | return H::apiError('Username and password are two required fields.'); 67 | } 68 | 69 | if ($engine->rateLimited(3600 * 15, array('create_user', $request->getClientIp()))) { 70 | return H::apiError('Please wait some time before creating a new user.'); 71 | } 72 | 73 | if (strlen($password) < ($minPwdLen = $engine->getOption('password_min_length'))) { 74 | return H::apiError("Password is too short. Min length: $minPwdLen"); 75 | } 76 | 77 | $authToken = $engine->createUser($username, $password); 78 | 79 | if (!$authToken) { 80 | return H::apiError('Username is busy. Please select a different one.'); 81 | } 82 | 83 | return H::apiOK(array('auth' => $authToken)); 84 | }); 85 | 86 | $controllers->post('/submit', function (Application $app, Request $request) { 87 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 88 | return $error; 89 | } 90 | 91 | $engine = $app['lamest']; 92 | $newsID = $request->get('news_id'); 93 | $title = $request->get('title'); 94 | $url = $request->get('url'); 95 | $text = $request->get('text'); 96 | 97 | // We can have an empty url or an empty first comment, but not both. 98 | if (empty($newsID) || empty($title) || (!strlen(trim($url)) && !strlen(trim($text)))) { 99 | return H::apiError('Please specify a news title and address or text.'); 100 | } 101 | 102 | // Make sure the news has an accepted URI scheme (only http or https for now). 103 | if (!empty($url)) { 104 | $scheme = parse_url($url, PHP_URL_SCHEME); 105 | 106 | if ($scheme !== 'http' && $scheme !== 'https') { 107 | return H::apiError('We only accept http:// and https:// news.'); 108 | } 109 | } 110 | 111 | if ($newsID == -1) { 112 | if (($eta = $engine->getNewPostEta($app['user'])) > 0) { 113 | return H::apiError("You have submitted a story too recently, please wait $eta seconds."); 114 | } 115 | 116 | $newsID = $engine->insertNews($title, $url, $text, $app['user']['id']); 117 | } else { 118 | $newsID = $engine->editNews($app['user'], $newsID, $title, $url, $text); 119 | 120 | if (!$newsID) { 121 | return H::apiError('Invalid parameters, news too old to be modified or URL recently posted.'); 122 | } 123 | } 124 | 125 | return H::apiOK(array('news_id' => $newsID)); 126 | }); 127 | 128 | $controllers->post('/delnews', function (Application $app, Request $request) { 129 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 130 | return $error; 131 | } 132 | 133 | $newsID = $request->get('news_id'); 134 | 135 | if (empty($newsID)) { 136 | return H::apiError('Please specify a news title.'); 137 | } 138 | 139 | if (!$app['lamest']->deleteNews($app['user'], $newsID)) { 140 | return H::apiError('News too old or wrong ID/owner.'); 141 | } 142 | 143 | return H::apiOK(array('news_id' => -1)); 144 | }); 145 | 146 | $controllers->post('/votenews', function (Application $app, Request $request) { 147 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 148 | return $error; 149 | } 150 | 151 | $newsID = $request->get('news_id'); 152 | $voteType = $request->get('vote_type'); 153 | 154 | if (empty($newsID) || ($voteType !== 'up' && $voteType !== 'down')) { 155 | return H::apiError('Missing news ID or invalid vote type.'); 156 | } 157 | 158 | if ($app['lamest']->voteNews($newsID, $app['user'], $voteType, $error) === false) { 159 | return H::apiError($error); 160 | } 161 | 162 | return H::apiOK(); 163 | }); 164 | 165 | $controllers->post('/postcomment', function (Application $app, Request $request) { 166 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 167 | return $error; 168 | } 169 | 170 | $newsID = $request->get('news_id'); 171 | $commentID = $request->get('comment_id'); 172 | $parentID = $request->get('parent_id'); 173 | $comment = $request->get('comment'); 174 | 175 | if (empty($newsID) || empty($commentID) || empty($parentID) || !isset($comment)) { 176 | return H::apiError('Missing news_id, comment_id, parent_id, or comment parameter.'); 177 | } 178 | 179 | $info = $app['lamest']->handleComment($app['user'], $newsID, $commentID, $parentID, $comment); 180 | 181 | if (!$info) { 182 | return H::apiError('Invalid news, comment, or edit time expired.'); 183 | } 184 | 185 | return H::apiOK(array( 186 | 'op' => $info['op'], 187 | 'comment_id' => $info['comment_id'], 188 | 'parent_id' => $parentID, 189 | 'news_id' => $newsID, 190 | )); 191 | }); 192 | 193 | $controllers->post('/votecomment', function (Application $app, Request $request) { 194 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 195 | return $error; 196 | } 197 | 198 | $compositeID = $request->get('comment_id'); 199 | $voteType = $request->get('vote_type'); 200 | 201 | if (!preg_match('/^\d+-\d+$/', $compositeID) || ($voteType !== 'up' && $voteType !== 'down')) { 202 | return H::apiError('Missing or invalid comment ID or invalid vote type.'); 203 | } 204 | 205 | list($newsID, $commentID) = explode('-', $compositeID); 206 | 207 | if (!$app['lamest']->voteComment($app['user'], $newsID, $commentID, $voteType)) { 208 | return H::apiError('Invalid parameters or duplicated vote.'); 209 | } 210 | 211 | return H::apiOK(array( 212 | 'comment_id' => $compositeID, 213 | )); 214 | }); 215 | 216 | $controllers->post('/updateprofile', function (Application $app, Request $request) { 217 | if (!H::isRequestValid($app['user'], $request->get('apisecret'), $error)) { 218 | return $error; 219 | } 220 | 221 | $about = $request->get('about'); 222 | $email = $request->get('email'); 223 | $password = $request->get('password'); 224 | 225 | $attributes = array( 226 | 'about' => $about, 227 | 'email' => $email, 228 | ); 229 | 230 | if (($pwdLen = strlen($password)) > 0) { 231 | if ($pwdLen < ($minPwdLen = $app['lamest']->getOption('password_min_length'))) { 232 | return H::apiError("Password is too short. Min length: $minPwdLen"); 233 | } 234 | 235 | $attributes['password'] = H::pbkdf2($password, $app['user']['salt']); 236 | } 237 | 238 | $app['lamest']->updateUserProfile($app['user'], $attributes); 239 | 240 | return H::apiOK(); 241 | }); 242 | 243 | $controllers->get('/getnews/{sort}/{start}/{count}', function (Application $app, $sort, $start, $count) { 244 | $engine = $app['lamest']; 245 | 246 | if ($sort !== 'latest' && $sort !== 'top') { 247 | return H::apiError('Invalid sort parameter'); 248 | } 249 | 250 | if ($count > $engine->getOption('api_max_news_count')) { 251 | return H::apiError('Count is too big'); 252 | } 253 | 254 | if ($start < 0) { 255 | $start = 0; 256 | } 257 | 258 | $newslist = $engine->{"get{$sort}News"}($app['user'], $start, $count); 259 | 260 | foreach ($newslist['news'] as &$news) { 261 | unset($news['rank'], $news['score'], $news['user_id']); 262 | } 263 | 264 | return H::apiOK(array( 265 | 'news' => $newslist['news'], 266 | 'count' => $newslist['count'], 267 | )); 268 | }); 269 | 270 | $controllers->get('/getcomments/{newsID}', function (Application $app, $newsID) { 271 | $engine = $app['lamest']; 272 | $user = $app['user']; 273 | @list($news) = $engine->getNewsByID($user, $newsID); 274 | 275 | if (!$news) { 276 | return H::apiError('Wrong news ID.'); 277 | } 278 | 279 | $topcomments = array(); 280 | $thread = $engine->getNewsComments($user, $news); 281 | 282 | foreach ($thread as $parentID => &$replies) { 283 | if ($parentID == -1) { 284 | $topcomments = &$replies; 285 | } 286 | 287 | foreach ($replies as &$reply) { 288 | $poster = $engine->getUserByID($reply['user_id']) ?: H::getDeletedUser(); 289 | 290 | $reply['username'] = $poster['username']; 291 | 292 | if (isset($thread[$reply['id']])) { 293 | $reply['replies'] = &$thread[$reply['id']]; 294 | } else { 295 | $reply['replies'] = array(); 296 | } 297 | 298 | if (!H::commentVoted($user, $reply)) { 299 | unset($reply['voted']); 300 | } 301 | 302 | if (isset($reply['up'])) { 303 | $reply['up'] = count($reply['up']); 304 | } 305 | 306 | if (isset($reply['down'])) { 307 | $reply['down'] = count($reply['down']); 308 | } 309 | 310 | unset( 311 | $reply['user'], $reply['id'], $reply['thread_id'], 312 | $reply['score'], $reply['parent_id'], $reply['user_id'] 313 | ); 314 | } 315 | } 316 | 317 | return H::apiOK(array( 318 | 'comments' => $topcomments, 319 | )); 320 | }); 321 | 322 | return $controllers; 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/Lamest/EngineInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest; 13 | 14 | /** 15 | * Defines an engine for a Lamest-driven application. 16 | * 17 | * @author Daniele Alessandri 18 | */ 19 | interface EngineInterface 20 | { 21 | const VERSION = '0.1.0'; 22 | const COMPATIBILITY = '0.9.2'; 23 | 24 | /** 25 | * Implements a generic and persisted rate limiting mechanism. 26 | * 27 | * @param int $delay Delay for the rate limite. 28 | * @param array $tags List of tags to create a limit key. 29 | * @return boolean 30 | */ 31 | public function rateLimited($delay, Array $tags); 32 | 33 | /** 34 | * Creates a new user and returns a new autorization token. 35 | * 36 | * @param $username Username for the new user. 37 | * @param $password Password for the new user. 38 | * @return string 39 | */ 40 | public function createUser($username, $password); 41 | 42 | /** 43 | * Fetches user details using the given user ID. 44 | * 45 | * @param string $userID ID of a registered user. 46 | * @return array 47 | */ 48 | public function getUserByID($userID); 49 | 50 | /** 51 | * Fetches user details using the given username. 52 | * 53 | * @param string $username Username of a registered user. 54 | * @return array 55 | */ 56 | public function getUserByUsername($username); 57 | 58 | /** 59 | * Adds the specified flags to the user. 60 | * 61 | * @param string $userID ID of the user. 62 | * @param string $flags Sequence of flags. 63 | * @return boolean 64 | */ 65 | public function addUserFlags($userID, $flags); 66 | 67 | /** 68 | * Checks if one or more flags are set for the specified user. 69 | * 70 | * @param array $user User details. 71 | * @param string $flags Sequence of flags. 72 | * @return boolean 73 | */ 74 | public function checkUserFlags(Array $user, $flags); 75 | 76 | /** 77 | * Checks if the specified user is an administrator. 78 | * 79 | * @param array $user User details. 80 | * @return boolean 81 | */ 82 | public function isUserAdmin(Array $user); 83 | 84 | /** 85 | * Returns some counters for the specified user. 86 | * 87 | * @param array $user User details. 88 | * @return array 89 | */ 90 | public function getUserCounters(Array $user); 91 | 92 | /** 93 | * Verifies if the username / password pair identifies a user and 94 | * returns its authorization token and form secret. 95 | * 96 | * @param $username Username of a registered user. 97 | * @param $password Password of a registered user. 98 | * @return array 99 | */ 100 | public function verifyUserCredentials($username, $password); 101 | 102 | /** 103 | * Updates the authentication token for the specified users with a new one, 104 | * effectively invalidating the current sessions for that user. 105 | * 106 | * @param string $userID ID of a registered user. 107 | * @return string 108 | */ 109 | public function updateAuthToken($userID); 110 | 111 | /** 112 | * Returns the data for a logged in user. 113 | * 114 | * @param string $authToken Token used for user authentication. 115 | * @return array 116 | */ 117 | public function authenticateUser($authToken); 118 | 119 | /** 120 | * Increments the user karma when a certain amout of time has passed. 121 | * 122 | * @param array $user User details. 123 | * @param int $increment Amount of the increment. 124 | * @param int $interval Interval of time in seconds. 125 | * @return boolean 126 | */ 127 | public function incrementUserKarma(Array &$user, $increment, $interval = 0); 128 | 129 | /** 130 | * Gets the karma of the specified user. 131 | * 132 | * @param array $user User details. 133 | * @return int 134 | */ 135 | public function getUserKarma(Array $user); 136 | 137 | /** 138 | * Updates the profile for the given user. 139 | * 140 | * @param array $user User details. 141 | * @param array $attributes Profile attributes. 142 | */ 143 | public function updateUserProfile(Array $user, Array $attributes); 144 | 145 | /** 146 | * Gets how many seconds the user has to wait before submitting a new post. 147 | * 148 | * @param array $user User details. 149 | */ 150 | public function getNewPostEta(Array $user); 151 | 152 | /** 153 | * Gets the list of the current top news items. 154 | * 155 | * @param array $user Current user. 156 | * @param int $start Offset from which to start in the list of latest news. 157 | * @param int $count Maximum number of news items. 158 | * @return array 159 | */ 160 | public function getTopNews(Array $user = null, $start = 0, $count = null); 161 | 162 | /** 163 | * Gets the list of the latest news in chronological order. 164 | * 165 | * @param array $user Current user. 166 | * @param int $start Offset from which to start in the list of latest news. 167 | * @param int $count Maximum number of news items. 168 | * @return array 169 | */ 170 | public function getLatestNews(Array $user = null, $start = 0, $count = null); 171 | 172 | /** 173 | * Gets the list of the saved news for the specified user. 174 | * 175 | * @param array $user Current user. 176 | * @param int $start Offset from which to start in the list of saved news. 177 | * @param int $count Maximum number of news items. 178 | * @return array 179 | */ 180 | public function getSavedNews(Array $user, $start = 0, $count = null); 181 | 182 | /** 183 | * Gets the list of comments for the specified user that received one or more 184 | * (including them). 185 | * 186 | * @param array $user Current user. 187 | * @param int $maxSubThreads Number of comments to retrieve. 188 | * @param boolean $reset Reset the unread replies count. 189 | * @return array 190 | */ 191 | public function getReplies(Array $user, $maxSubThreads, $reset = false); 192 | 193 | /** 194 | * Retrieves one or more news items using their IDs. 195 | * 196 | * @param array $user Details of the current user. 197 | * @param string|array $newsIDs One or multiple news IDs. 198 | * @param boolean $updateRank Specify if the rank of news should be updated. 199 | * @return mixed 200 | */ 201 | public function getNewsByID(Array $user, $newsIDs, $updateRank = false); 202 | 203 | /** 204 | * Retrieves the comments tree for the news. 205 | * 206 | * @param array $user Details of the current user. 207 | * @param array $news Details of the news item. 208 | * @return array 209 | */ 210 | public function getNewsComments(Array $user, Array $news); 211 | 212 | /** 213 | * Adds a new news item. 214 | * 215 | * @param string $title Title of the news. 216 | * @param string $url URL of the news. 217 | * @param string $text Text of the news. 218 | * @param string $userID User that sumbitted the news. 219 | * @return string 220 | */ 221 | public function insertNews($title, $url, $text, $userID); 222 | 223 | /** 224 | * Edit an already existing news item. 225 | * 226 | * @param string $user User that edited the news. 227 | * @param string $newsID ID of the news item. 228 | * @param string $title Title of the news. 229 | * @param string $url URL of the news. 230 | * @param string $text Text of the news. 231 | * @return string 232 | */ 233 | public function editNews(Array $user, $newsID, $title, $url, $text); 234 | 235 | /** 236 | * Upvotes or downvotes the specified news item. 237 | * 238 | * The function ensures that: 239 | * 1) The vote is not duplicated. 240 | * 2) The karma is decreased for the voting user, accordingly to the vote type. 241 | * 3) The karma is transferred to the author of the post, if different. 242 | * 4) The news score is updated. 243 | * 244 | * It returns the news rank if the vote was inserted, or false upon failure. 245 | * 246 | * @param string $newsID ID of the news being voted. 247 | * @param array|string $user Instance or string ID of the voting user. 248 | * @param string $type 'up' for upvoting a news item. 249 | * 'down' for downvoting a news item. 250 | * @param string $error Error message returned on a failed vote. 251 | * @return mixed New rank for the voted news, or FALSE upon error. 252 | */ 253 | public function voteNews($newsID, $user, $type, &$error = null); 254 | 255 | /** 256 | * Deletes an already existing news item. 257 | * 258 | * @param string $user User that edited the news. 259 | * @param string $newsID ID of the news item. 260 | * @return boolean 261 | */ 262 | public function deleteNews(Array $user, $newsID); 263 | 264 | /** 265 | * Handles various kind of actions on a comment depending on the arguments. 266 | * 267 | * 1) If comment_id is -1 insert a new comment into the specified news. 268 | * 2) If comment_id is an already existing comment in the context of the 269 | * specified news, updates the comment. 270 | * 3) If comment_id is an already existing comment in the context of the 271 | * specified news, but the comment is an empty string, delete the comment. 272 | * 273 | * Return value: 274 | * 275 | * If news_id does not exist or comment_id is not -1 but neither a valid 276 | * comment for that news, nil is returned. 277 | * Otherwise an hash is returned with the following fields: 278 | * news_id: the news id 279 | * comment_id: the updated comment id, or the new comment id 280 | * op: the operation performed: "insert", "update", or "delete" 281 | * 282 | * More informations: 283 | * 284 | * The parent_id is only used for inserts (when comment_id == -1), otherwise 285 | * is ignored. 286 | * 287 | * @param array $user Details of the current user. 288 | * @param string $newsID ID of the news associated to the comment. 289 | * @param string $commentID ID of the comment, or -1 for a new comment. 290 | * @param string $parentID ID of the parent comment. 291 | * @param string $body Body of the comment, or null to delete an existing comment. 292 | * @return array 293 | */ 294 | public function handleComment(Array $user, $newsID, $commentID, $parentID, $body = null); 295 | 296 | /** 297 | * Gets a specific comment. 298 | * 299 | * @param string $newsID ID of the associated news item. 300 | * @param string $commentID ID of the comment. 301 | * @return array 302 | */ 303 | public function getComment($newsID, $commentID); 304 | 305 | /** 306 | * Retrieves the list of comments for the specified user. 307 | * 308 | * @param array $user Details of the current user. 309 | * @param string $start Offset for the list of comments. 310 | * @param string $count Maximum number of comments (-1 to retrieve all of them). 311 | * @param mixed $callback Callback invoked on each comment. 312 | * @return array 313 | */ 314 | public function getUserComments(Array $user, $start = 0, $count = -1, $callback = null); 315 | 316 | /** 317 | * Post a new comment on the specified news item. 318 | * 319 | * @param string $newsID ID of the associated news item. 320 | * @param array $comment Details and contents of the new comment. 321 | * @return boolean 322 | */ 323 | public function postComment($newsID, Array $comment); 324 | 325 | /** 326 | * Registers a vote (up or down) for the specified comment. 327 | * 328 | * @param array $user Details of the voting user. 329 | * @param string $newsID ID of the associated news item. 330 | * @param string $commentID ID of the comment. 331 | * @param string $type 'up' for upvoting a news item. 332 | * 'down' for downvoting a news item. 333 | * @return boolean 334 | */ 335 | public function voteComment(Array $user, $newsID, $commentID, $type); 336 | 337 | /** 338 | * Edits the specified comment by updating only the passed values. 339 | * 340 | * @param string $newsID ID of the associated news item. 341 | * @param string $commentID ID of the comment. 342 | * @param array $updates Fields and values for the update. 343 | * @return boolean 344 | */ 345 | public function editComment($newsID, $commentID, Array $updates); 346 | 347 | /** 348 | * Deletes a specific comment. 349 | * 350 | * @param string $newsID ID of the associated news item. 351 | * @param string $commentID ID of the comment. 352 | * @return boolean 353 | */ 354 | public function deleteComment($newsID, $commentID); 355 | 356 | /** 357 | * Returns the currently authenticated user. 358 | * 359 | * @return array 360 | */ 361 | public function getUser(); 362 | } 363 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "hash": "443cbcd8116c488440b23cee052e0a15", 3 | "packages": [ 4 | { 5 | "name": "pimple/pimple", 6 | "version": "v1.0.1", 7 | "source": { 8 | "type": "git", 9 | "url": "git://github.com/fabpot/Pimple.git", 10 | "reference": "v1.0.1" 11 | }, 12 | "dist": { 13 | "type": "zip", 14 | "url": "https://github.com/fabpot/Pimple/archive/v1.0.1.zip", 15 | "reference": "v1.0.1", 16 | "shasum": "" 17 | }, 18 | "require": { 19 | "php": ">=5.3.0" 20 | }, 21 | "time": "2012-11-11 08:32:34", 22 | "type": "library", 23 | "extra": { 24 | "branch-alias": { 25 | "dev-master": "1.0.x-dev" 26 | } 27 | }, 28 | "autoload": { 29 | "psr-0": { 30 | "Pimple": "lib/" 31 | } 32 | }, 33 | "notification-url": "https://packagist.org/downloads/", 34 | "license": [ 35 | "MIT" 36 | ], 37 | "authors": [ 38 | { 39 | "name": "Fabien Potencier", 40 | "email": "fabien@symfony.com" 41 | } 42 | ], 43 | "description": "Pimple is a simple Dependency Injection Container for PHP 5.3", 44 | "homepage": "http://pimple.sensiolabs.org", 45 | "keywords": [ 46 | "container", 47 | "dependency injection" 48 | ] 49 | }, 50 | { 51 | "name": "predis/predis", 52 | "version": "v0.8.2", 53 | "source": { 54 | "type": "git", 55 | "url": "git://github.com/nrk/predis.git", 56 | "reference": "v0.8.2" 57 | }, 58 | "dist": { 59 | "type": "zip", 60 | "url": "https://api.github.com/repos/nrk/predis/zipball/v0.8.2", 61 | "reference": "v0.8.2", 62 | "shasum": "" 63 | }, 64 | "require": { 65 | "php": ">=5.3.2" 66 | }, 67 | "suggest": { 68 | "ext-curl": "Allows access to Webdis when paired with phpiredis", 69 | "ext-phpiredis": "Allows faster serialization and deserialization of the Redis protocol" 70 | }, 71 | "time": "2013-02-03 12:59:55", 72 | "type": "library", 73 | "autoload": { 74 | "psr-0": { 75 | "Predis": "lib/" 76 | } 77 | }, 78 | "notification-url": "https://packagist.org/downloads/", 79 | "license": [ 80 | "MIT" 81 | ], 82 | "authors": [ 83 | { 84 | "name": "Daniele Alessandri", 85 | "email": "suppakilla@gmail.com", 86 | "homepage": "http://clorophilla.net" 87 | } 88 | ], 89 | "description": "Flexible and feature-complete PHP client library for Redis", 90 | "homepage": "http://github.com/nrk/predis", 91 | "keywords": [ 92 | "nosql", 93 | "predis", 94 | "redis" 95 | ] 96 | }, 97 | { 98 | "name": "predis/service-provider", 99 | "version": "v0.4.0", 100 | "source": { 101 | "type": "git", 102 | "url": "git://github.com/nrk/PredisServiceProvider.git", 103 | "reference": "v0.4.0" 104 | }, 105 | "dist": { 106 | "type": "zip", 107 | "url": "https://api.github.com/repos/nrk/PredisServiceProvider/zipball/v0.4.0", 108 | "reference": "v0.4.0", 109 | "shasum": "" 110 | }, 111 | "require": { 112 | "php": ">=5.3.2", 113 | "predis/predis": "0.8.*@stable", 114 | "silex/silex": "1.0.*@dev" 115 | }, 116 | "time": "2013-02-03 13:48:08", 117 | "type": "library", 118 | "autoload": { 119 | "psr-0": { 120 | "Predis\\Silex": "lib/" 121 | } 122 | }, 123 | "notification-url": "https://packagist.org/downloads/", 124 | "license": [ 125 | "MIT" 126 | ], 127 | "authors": [ 128 | { 129 | "name": "Daniele Alessandri", 130 | "email": "suppakilla@gmail.com", 131 | "homepage": "http://clorophilla.net" 132 | } 133 | ], 134 | "description": "Predis service provider for the Silex microframework", 135 | "homepage": "https://github.com/nrk/PredisServiceProvider", 136 | "keywords": [ 137 | "predis", 138 | "redis", 139 | "silex" 140 | ] 141 | }, 142 | { 143 | "name": "silex/silex", 144 | "version": "dev-master", 145 | "source": { 146 | "type": "git", 147 | "url": "git://github.com/fabpot/Silex.git", 148 | "reference": "3563829af174cae24d99938c8c1dab45c33946fa" 149 | }, 150 | "dist": { 151 | "type": "zip", 152 | "url": "https://api.github.com/repos/fabpot/Silex/zipball/3563829af174cae24d99938c8c1dab45c33946fa", 153 | "reference": "3563829af174cae24d99938c8c1dab45c33946fa", 154 | "shasum": "" 155 | }, 156 | "require": { 157 | "php": ">=5.3.3", 158 | "pimple/pimple": "1.*", 159 | "symfony/event-dispatcher": ">=2.1,<2.3-dev", 160 | "symfony/http-foundation": ">=2.1,<2.3-dev", 161 | "symfony/http-kernel": ">=2.1,<2.3-dev", 162 | "symfony/routing": ">=2.1,<2.3-dev" 163 | }, 164 | "require-dev": { 165 | "doctrine/dbal": ">=2.2.0,<2.4.0-dev", 166 | "swiftmailer/swiftmailer": "4.2.*", 167 | "symfony/browser-kit": ">=2.1,<2.3-dev", 168 | "symfony/config": ">=2.1,<2.3-dev", 169 | "symfony/css-selector": ">=2.1,<2.3-dev", 170 | "symfony/dom-crawler": ">=2.1,<2.3-dev", 171 | "symfony/finder": ">=2.1,<2.3-dev", 172 | "symfony/form": ">=2.1,<2.3-dev", 173 | "symfony/locale": ">=2.1,<2.3-dev", 174 | "symfony/monolog-bridge": ">=2.1,<2.3-dev", 175 | "symfony/options-resolver": ">=2.1,<2.3-dev", 176 | "symfony/process": ">=2.1,<2.3-dev", 177 | "symfony/security": ">=2.1,<2.3-dev", 178 | "symfony/serializer": ">=2.1,<2.3-dev", 179 | "symfony/translation": ">=2.1,<2.3-dev", 180 | "symfony/twig-bridge": ">=2.1,<2.3-dev", 181 | "symfony/validator": ">=2.1,<2.3-dev", 182 | "twig/twig": ">=1.8.0,<2.0-dev" 183 | }, 184 | "suggest": { 185 | "symfony/browser-kit": ">=2.1,<2.3-dev", 186 | "symfony/css-selector": ">=2.1,<2.3-dev", 187 | "symfony/dom-crawler": ">=2.1,<2.3-dev" 188 | }, 189 | "time": "2013-02-08 11:41:41", 190 | "type": "library", 191 | "extra": { 192 | "branch-alias": { 193 | "dev-master": "1.0.x-dev" 194 | } 195 | }, 196 | "autoload": { 197 | "psr-0": { 198 | "Silex": "src/" 199 | } 200 | }, 201 | "notification-url": "https://packagist.org/downloads/", 202 | "license": [ 203 | "MIT" 204 | ], 205 | "authors": [ 206 | { 207 | "name": "Fabien Potencier", 208 | "email": "fabien@symfony.com" 209 | }, 210 | { 211 | "name": "Igor Wiedler", 212 | "email": "igor@wiedler.ch", 213 | "homepage": "http://wiedler.ch/igor/" 214 | } 215 | ], 216 | "description": "The PHP micro-framework based on the Symfony2 Components", 217 | "homepage": "http://silex.sensiolabs.org", 218 | "keywords": [ 219 | "microframework" 220 | ] 221 | }, 222 | { 223 | "name": "symfony/event-dispatcher", 224 | "version": "v2.1.7", 225 | "target-dir": "Symfony/Component/EventDispatcher", 226 | "source": { 227 | "type": "git", 228 | "url": "https://github.com/symfony/EventDispatcher", 229 | "reference": "v2.1.7" 230 | }, 231 | "dist": { 232 | "type": "zip", 233 | "url": "https://github.com/symfony/EventDispatcher/archive/v2.1.7.zip", 234 | "reference": "v2.1.7", 235 | "shasum": "" 236 | }, 237 | "require": { 238 | "php": ">=5.3.3" 239 | }, 240 | "require-dev": { 241 | "symfony/dependency-injection": "2.1.*" 242 | }, 243 | "suggest": { 244 | "symfony/dependency-injection": "2.1.*", 245 | "symfony/http-kernel": "2.1.*" 246 | }, 247 | "time": "2013-01-11 00:31:43", 248 | "type": "library", 249 | "autoload": { 250 | "psr-0": { 251 | "Symfony\\Component\\EventDispatcher": "" 252 | } 253 | }, 254 | "notification-url": "https://packagist.org/downloads/", 255 | "license": [ 256 | "MIT" 257 | ], 258 | "authors": [ 259 | { 260 | "name": "Fabien Potencier", 261 | "email": "fabien@symfony.com" 262 | }, 263 | { 264 | "name": "Symfony Community", 265 | "homepage": "http://symfony.com/contributors" 266 | } 267 | ], 268 | "description": "Symfony EventDispatcher Component", 269 | "homepage": "http://symfony.com" 270 | }, 271 | { 272 | "name": "symfony/http-foundation", 273 | "version": "v2.1.7", 274 | "target-dir": "Symfony/Component/HttpFoundation", 275 | "source": { 276 | "type": "git", 277 | "url": "https://github.com/symfony/HttpFoundation", 278 | "reference": "v2.1.7" 279 | }, 280 | "dist": { 281 | "type": "zip", 282 | "url": "https://github.com/symfony/HttpFoundation/archive/v2.1.7.zip", 283 | "reference": "v2.1.7", 284 | "shasum": "" 285 | }, 286 | "require": { 287 | "php": ">=5.3.3" 288 | }, 289 | "time": "2013-01-11 00:31:43", 290 | "type": "library", 291 | "autoload": { 292 | "psr-0": { 293 | "Symfony\\Component\\HttpFoundation": "", 294 | "SessionHandlerInterface": "Symfony/Component/HttpFoundation/Resources/stubs" 295 | } 296 | }, 297 | "notification-url": "https://packagist.org/downloads/", 298 | "license": [ 299 | "MIT" 300 | ], 301 | "authors": [ 302 | { 303 | "name": "Fabien Potencier", 304 | "email": "fabien@symfony.com" 305 | }, 306 | { 307 | "name": "Symfony Community", 308 | "homepage": "http://symfony.com/contributors" 309 | } 310 | ], 311 | "description": "Symfony HttpFoundation Component", 312 | "homepage": "http://symfony.com" 313 | }, 314 | { 315 | "name": "symfony/http-kernel", 316 | "version": "v2.1.7", 317 | "target-dir": "Symfony/Component/HttpKernel", 318 | "source": { 319 | "type": "git", 320 | "url": "https://github.com/symfony/HttpKernel", 321 | "reference": "v2.1.7" 322 | }, 323 | "dist": { 324 | "type": "zip", 325 | "url": "https://api.github.com/repos/symfony/HttpKernel/zipball/v2.1.7", 326 | "reference": "v2.1.7", 327 | "shasum": "" 328 | }, 329 | "require": { 330 | "php": ">=5.3.3", 331 | "symfony/event-dispatcher": "2.1.*", 332 | "symfony/http-foundation": "2.1.*" 333 | }, 334 | "require-dev": { 335 | "symfony/browser-kit": "2.1.*", 336 | "symfony/class-loader": "2.1.*", 337 | "symfony/config": "2.1.*", 338 | "symfony/console": "2.1.*", 339 | "symfony/dependency-injection": "2.1.*", 340 | "symfony/finder": "2.1.*", 341 | "symfony/process": "2.1.*", 342 | "symfony/routing": "2.1.*" 343 | }, 344 | "suggest": { 345 | "symfony/browser-kit": "2.1.*", 346 | "symfony/class-loader": "2.1.*", 347 | "symfony/config": "2.1.*", 348 | "symfony/console": "2.1.*", 349 | "symfony/dependency-injection": "2.1.*", 350 | "symfony/finder": "2.1.*" 351 | }, 352 | "time": "2013-01-17 16:21:47", 353 | "type": "library", 354 | "autoload": { 355 | "psr-0": { 356 | "Symfony\\Component\\HttpKernel": "" 357 | } 358 | }, 359 | "notification-url": "https://packagist.org/downloads/", 360 | "license": [ 361 | "MIT" 362 | ], 363 | "authors": [ 364 | { 365 | "name": "Fabien Potencier", 366 | "email": "fabien@symfony.com" 367 | }, 368 | { 369 | "name": "Symfony Community", 370 | "homepage": "http://symfony.com/contributors" 371 | } 372 | ], 373 | "description": "Symfony HttpKernel Component", 374 | "homepage": "http://symfony.com" 375 | }, 376 | { 377 | "name": "symfony/routing", 378 | "version": "v2.1.7", 379 | "target-dir": "Symfony/Component/Routing", 380 | "source": { 381 | "type": "git", 382 | "url": "https://github.com/symfony/Routing", 383 | "reference": "v2.1.7" 384 | }, 385 | "dist": { 386 | "type": "zip", 387 | "url": "https://api.github.com/repos/symfony/Routing/zipball/v2.1.7", 388 | "reference": "v2.1.7", 389 | "shasum": "" 390 | }, 391 | "require": { 392 | "php": ">=5.3.3" 393 | }, 394 | "require-dev": { 395 | "doctrine/common": ">=2.2,<2.4-dev", 396 | "symfony/config": "2.1.*", 397 | "symfony/http-kernel": "2.1.*", 398 | "symfony/yaml": "2.1.*" 399 | }, 400 | "suggest": { 401 | "doctrine/common": ">=2.2,<2.4-dev", 402 | "symfony/config": "2.1.*", 403 | "symfony/yaml": "2.1.*" 404 | }, 405 | "time": "2013-01-09 08:51:07", 406 | "type": "library", 407 | "autoload": { 408 | "psr-0": { 409 | "Symfony\\Component\\Routing": "" 410 | } 411 | }, 412 | "notification-url": "https://packagist.org/downloads/", 413 | "license": [ 414 | "MIT" 415 | ], 416 | "authors": [ 417 | { 418 | "name": "Fabien Potencier", 419 | "email": "fabien@symfony.com" 420 | }, 421 | { 422 | "name": "Symfony Community", 423 | "homepage": "http://symfony.com/contributors" 424 | } 425 | ], 426 | "description": "Symfony Routing Component", 427 | "homepage": "http://symfony.com" 428 | }, 429 | { 430 | "name": "twig/twig", 431 | "version": "v1.9.2", 432 | "source": { 433 | "type": "git", 434 | "url": "git://github.com/fabpot/Twig.git", 435 | "reference": "v1.9.2" 436 | }, 437 | "dist": { 438 | "type": "zip", 439 | "url": "https://github.com/fabpot/Twig/zipball/v1.9.2", 440 | "reference": "v1.9.2", 441 | "shasum": "" 442 | }, 443 | "require": { 444 | "php": ">=5.2.4" 445 | }, 446 | "time": "2012-08-25 10:32:57", 447 | "type": "library", 448 | "extra": { 449 | "branch-alias": { 450 | "dev-master": "1.9-dev" 451 | } 452 | }, 453 | "autoload": { 454 | "psr-0": { 455 | "Twig_": "lib/" 456 | } 457 | }, 458 | "notification-url": "https://packagist.org/downloads/", 459 | "license": [ 460 | "BSD-3" 461 | ], 462 | "authors": [ 463 | { 464 | "name": "Fabien Potencier", 465 | "email": "fabien@symfony.com" 466 | }, 467 | { 468 | "name": "Armin Ronacher", 469 | "email": "armin.ronacher@active-4.com" 470 | } 471 | ], 472 | "description": "Twig, the flexible, fast, and secure template language for PHP", 473 | "homepage": "http://twig.sensiolabs.org", 474 | "keywords": [ 475 | "templating" 476 | ] 477 | } 478 | ], 479 | "packages-dev": null, 480 | "aliases": [ 481 | 482 | ], 483 | "minimum-stability": "stable", 484 | "stability-flags": { 485 | "silex/silex": 20 486 | } 487 | } 488 | -------------------------------------------------------------------------------- /src/Lamest/RedisEngine.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Lamest; 13 | 14 | use Predis\Client; 15 | use Predis\Pipeline\PipelineContext; 16 | 17 | use Lamest\Helpers as H; 18 | 19 | /** 20 | * Implements a Lamest engine that uses Redis as the underlying data storage. 21 | * 22 | * @author Daniele Alessandri 23 | */ 24 | class RedisEngine implements EngineInterface 25 | { 26 | private $redis; 27 | private $options; 28 | private $user; 29 | 30 | /** 31 | * Initializes the engine class. 32 | * 33 | * @param Client $redis Redis client used to access the data storage. 34 | * @param array $options Array of options. 35 | */ 36 | public function __construct(Client $redis, Array $options = array()) 37 | { 38 | $this->redis = $redis; 39 | $this->options = array_merge($this->getDefaults(), $options); 40 | $this->user = array(); 41 | } 42 | 43 | /** 44 | * Gets the default options for the engine. 45 | * 46 | * @return array 47 | */ 48 | protected function getDefaults() 49 | { 50 | return array( 51 | 'password_min_length' => 8, 52 | 53 | // comments 54 | 'comment_max_length' => 4096, 55 | 'comment_edit_time' => 3600 * 2, 56 | 'comment_reply_shift' => 60, 57 | 'user_comments_per_page' => 10, 58 | 'subthreads_in_replies_page' => 10, 59 | 60 | // karma 61 | 'user_initial_karma' => 1, 62 | 'karma_increment_interval' => 3600 * 3, 63 | 'karma_increment_amount' => 1, 64 | 'news_downvote_min_karma' => 30, 65 | 'news_downvote_karma_cost' => 6, 66 | 'news_upvote_min_karma' => 0, 67 | 'news_upvote_karma_cost' => 1, 68 | 'news_upvote_karma_transfered' => 1, 69 | 'karma_increment_comment' => 1, 70 | 71 | // news and ranking 72 | 'news_age_padding' => 60 * 60 * 8, 73 | 'top_news_per_page' => 30, 74 | 'latest_news_per_page' => 100, 75 | 'news_edit_time' => 60 * 15, 76 | 'news_score_log_start' => 10, 77 | 'news_score_log_booster' => 2, 78 | 'rank_aging_factor' => 2.2, 79 | 'prevent_repost_time' => 3600 * 48, 80 | 'news_submission_break' => 60 * 15, 81 | 'saved_news_per_page' => 10, 82 | 83 | // API 84 | 'api_max_news_count' => 32, 85 | 86 | // UI Elements 87 | 'keyboard_navigation' => true, 88 | ); 89 | } 90 | 91 | /** 92 | * {@inheritdoc} 93 | */ 94 | public function rateLimited($delay, Array $tags) 95 | { 96 | if (!$tags) { 97 | return false; 98 | } 99 | 100 | $key = "limit:" . join($tags, '.'); 101 | 102 | if ($this->getRedis()->exists($key)) { 103 | return true; 104 | } 105 | 106 | $this->getRedis()->setex($key, $delay, 1); 107 | 108 | return false; 109 | } 110 | 111 | /** 112 | * {@inheritdoc} 113 | */ 114 | public function createUser($username, $password) 115 | { 116 | $redis = $this->getRedis(); 117 | 118 | if ($redis->exists("username.to.id:".strtolower($username))) { 119 | return; 120 | } 121 | 122 | $userID = $redis->incr('users.count'); 123 | $authToken = H::generateRandom(); 124 | $salt = H::generateRandom(); 125 | 126 | $userDetails = array( 127 | 'id' => $userID, 128 | 'username' => $username, 129 | 'salt' => $salt, 130 | 'password' => H::pbkdf2($password, $salt, 20), 131 | 'ctime' => time(), 132 | 'karma' => $this->getOption('user_initial_karma'), 133 | 'about' => '', 134 | 'email' => '', 135 | 'auth' => $authToken, 136 | 'apisecret' => H::generateRandom(), 137 | 'flags' => '', 138 | 'karma_incr_time' => time(), 139 | ); 140 | 141 | $redis->hmset("user:$userID", $userDetails); 142 | $redis->set("username.to.id:".strtolower($username), $userID); 143 | $redis->set("auth:$authToken", $userID); 144 | 145 | return $authToken; 146 | } 147 | 148 | /** 149 | * {@inheritdoc} 150 | */ 151 | public function getUserByID($userID) 152 | { 153 | return $this->getRedis()->hgetall("user:$userID"); 154 | } 155 | 156 | /** 157 | * {@inheritdoc} 158 | */ 159 | public function getUserByUsername($username) 160 | { 161 | $userID = $this->getRedis()->get('username.to.id:'.strtolower($username)); 162 | 163 | if (!$userID) { 164 | return; 165 | } 166 | 167 | return $this->getUserByID($userID); 168 | } 169 | 170 | /** 171 | * {@inheritdoc} 172 | */ 173 | public function addUserFlags($userID, $flags) 174 | { 175 | $user = $this->getUserByID($userID); 176 | 177 | if (!$user) { 178 | return false; 179 | } 180 | 181 | $flags = $user['flags']; 182 | 183 | foreach (str_split($flags) as $flag) { 184 | if ($this->checkUserFlags($flag)) { 185 | $flags .= $flag; 186 | } 187 | } 188 | 189 | $this->getRedis()->hset("user:$userID", "flags", $flags); 190 | 191 | return true; 192 | } 193 | 194 | /** 195 | * {@inheritdoc} 196 | */ 197 | public function checkUserFlags(Array $user, $flags) 198 | { 199 | if (!$user) { 200 | return false; 201 | } 202 | 203 | $userflags = $user['flags']; 204 | 205 | foreach (str_split($flags) as $flag) { 206 | if (stripos($userflags, $flag) === false) { 207 | return false; 208 | } 209 | } 210 | 211 | return true; 212 | } 213 | 214 | /** 215 | * {@inheritdoc} 216 | */ 217 | public function isUserAdmin(Array $user) 218 | { 219 | return $this->checkUserFlags($user, 'a'); 220 | } 221 | 222 | /** 223 | * {@inheritdoc} 224 | */ 225 | public function getUserCounters(Array $user) 226 | { 227 | $counters = $this->getRedis()->pipeline(function ($pipe) use ($user) { 228 | $pipe->zcard("user.posted:{$user['id']}"); 229 | $pipe->zcard("user.comments:{$user['id']}"); 230 | }); 231 | 232 | return array( 233 | 'posted_news' => $counters[0], 234 | 'posted_comments' => $counters[1], 235 | ); 236 | } 237 | 238 | /** 239 | * {@inheritdoc} 240 | */ 241 | public function verifyUserCredentials($username, $password) 242 | { 243 | $user = $this->getUserByUsername($username); 244 | 245 | if (!$user) { 246 | return; 247 | } 248 | 249 | $hashedPassword = H::pbkdf2($password, $user['salt'], 20); 250 | 251 | if ($user['password'] !== $hashedPassword) { 252 | return; 253 | } 254 | 255 | $this->user = $user; 256 | 257 | return array( 258 | $user['auth'], 259 | $user['apisecret'], 260 | ); 261 | } 262 | 263 | /** 264 | * {@inheritdoc} 265 | */ 266 | public function authenticateUser($authToken) 267 | { 268 | if (!$authToken) { 269 | return; 270 | } 271 | 272 | $userID = $this->getRedis()->get("auth:$authToken"); 273 | 274 | if (!$userID) { 275 | return; 276 | } 277 | 278 | $user = $this->getRedis()->hgetall("user:$userID"); 279 | 280 | if (!$user) { 281 | return; 282 | } 283 | 284 | $this->user = $user; 285 | 286 | return $user; 287 | } 288 | 289 | /** 290 | * {@inheritdoc} 291 | */ 292 | public function updateAuthToken($userID) 293 | { 294 | $user = $this->getUserByID($userID); 295 | 296 | if (!$user) { 297 | return; 298 | } 299 | 300 | $redis = $this->getRedis(); 301 | $redis->del("auth:{$user['auth']}"); 302 | 303 | $newAuthToken = H::generateRandom(); 304 | $redis->hmset("user:$userID","auth", $newAuthToken); 305 | $redis->set("auth:$newAuthToken", $userID); 306 | 307 | return $newAuthToken; 308 | } 309 | 310 | /** 311 | * {@inheritdoc} 312 | */ 313 | public function incrementUserKarma(Array &$user, $increment, $interval = 0) 314 | { 315 | $userKey = "user:{$user['id']}"; 316 | $redis = $this->getRedis(); 317 | 318 | if ($interval > 0) { 319 | $now = time(); 320 | 321 | if ($user['karma_incr_time'] >= $now - $interval) { 322 | return false; 323 | } 324 | 325 | $redis->hset($userKey, 'karma_incr_time', $now); 326 | } 327 | 328 | $redis->hincrby($userKey, 'karma', $increment); 329 | $user['karma'] = isset($user['karma']) ? $user['karma'] + $increment : $increment; 330 | 331 | return true; 332 | } 333 | 334 | /** 335 | * {@inheritdoc} 336 | */ 337 | public function getUserKarma(Array $user) 338 | { 339 | return (int) $this->getRedis()->hget("user:{$user['id']}", 'karma') ?: 0; 340 | } 341 | 342 | /** 343 | * {@inheritdoc} 344 | */ 345 | public function updateUserProfile(Array $user, Array $attributes) 346 | { 347 | $attributes = array_merge($attributes, array( 348 | 'about' => substr($attributes['about'], 0, 4095), 349 | 'email' => substr($attributes['email'], 0, 255), 350 | )); 351 | 352 | $this->getRedis()->hmset("user:{$user['id']}", $attributes); 353 | } 354 | 355 | /** 356 | * {@inheritdoc} 357 | */ 358 | public function getNewPostEta(Array $user) 359 | { 360 | return $this->getRedis()->ttl("user:{$user['id']}:submitted_recently"); 361 | } 362 | 363 | /** 364 | * {@inheritdoc} 365 | */ 366 | public function getTopNews(Array $user = null, $start = 0, $count = null) 367 | { 368 | $redis = $this->getRedis(); 369 | $count = $count ?: $this->getOption('top_news_per_page'); 370 | $newsIDs = $redis->zrevrange('news.top', $start, $start + $count - 1); 371 | 372 | if (!$newsIDs) { 373 | return array('news' => array(), 'count' => 0); 374 | } 375 | 376 | $newslist = $this->getNewsByID($user, $newsIDs, true); 377 | 378 | // Sort by rank before returning, since we adjusted ranks during iteration. 379 | usort($newslist, function ($a, $b) { 380 | return $a['rank'] != $b['rank'] ? ($a['rank'] < $b['rank'] ? 1 : -1) : 0; 381 | }); 382 | 383 | return array( 384 | 'news' => $newslist, 385 | 'count' => $redis->zcard('news.top'), 386 | ); 387 | } 388 | 389 | /** 390 | * {@inheritdoc} 391 | */ 392 | public function getLatestNews(Array $user = null, $start = 0, $count = null) 393 | { 394 | $redis = $this->getRedis(); 395 | $count = $count ?: $this->getOption('latest_news_per_page'); 396 | $newsIDs = $redis->zrevrange('news.cron', $start, $start + $count - 1); 397 | 398 | if (!$newsIDs) { 399 | return array('news' => array(), 'count' => 0); 400 | } 401 | 402 | return array( 403 | 'news' => $this->getNewsByID($user, $newsIDs, true), 404 | 'count' => $redis->zcard('news.cron'), 405 | ); 406 | } 407 | 408 | /** 409 | * {@inheritdoc} 410 | */ 411 | public function getSavedNews(Array $user, $start = 0, $count = null) 412 | { 413 | $redis = $this->getRedis(); 414 | $count = $count ?: $this->getOption('saved_news_per_page'); 415 | $newsIDs = $redis->zrevrange("user.saved:{$user['id']}", $start, $start + $count - 1); 416 | 417 | if (!$newsIDs) { 418 | return array('news' => array(), 'count' => 0); 419 | } 420 | 421 | return array( 422 | 'news' => $this->getNewsByID($user, $newsIDs), 423 | 'count' => $redis->zcard("user.saved:{$user['id']}"), 424 | ); 425 | } 426 | 427 | /** 428 | * {@inheritdoc} 429 | */ 430 | public function getReplies(Array $user, $maxSubThreads, $reset = false) 431 | { 432 | $engine = $this; 433 | $threadCallback = function ($comment) use ($engine, $user) { 434 | $thread = array('id' => $comment['thread_id']); 435 | $comment['replies'] = $engine->getNewsComments($user, $thread); 436 | 437 | return $comment; 438 | }; 439 | 440 | $comments = $this->getUserComments($user, 0, $maxSubThreads, $threadCallback); 441 | 442 | if ($reset) { 443 | $this->getRedis()->hset("user:{$user['id']}", 'replies', 0); 444 | } 445 | 446 | return $comments['list']; 447 | } 448 | 449 | /** 450 | * {@inheritdoc} 451 | */ 452 | public function getNewsByID(Array $user, $newsIDs, $updateRank = false) 453 | { 454 | if (!$newsIDs) { 455 | return array(); 456 | } 457 | 458 | $newsIDs = !is_array($newsIDs) ? array($newsIDs) : array_values(array_filter($newsIDs)); 459 | 460 | $redis = $this->getRedis(); 461 | 462 | $newslist = $redis->pipeline(function ($pipe) use ($newsIDs) { 463 | foreach ($newsIDs as $newsID) { 464 | $pipe->hgetall("news:$newsID"); 465 | } 466 | }); 467 | 468 | if (!$newslist) { 469 | return array(); 470 | } 471 | 472 | $result = array(); 473 | $pipe = $redis->pipeline(); 474 | 475 | // Get all the news. 476 | foreach ($newslist as $news) { 477 | if (!$news) { 478 | // TODO: how should we notify the caller of missing news items when 479 | // asking for more than one news at time? 480 | continue; 481 | } 482 | 483 | // Adjust rank if too different from the real-time value. 484 | if ($updateRank) { 485 | $this->updateNewsRank($pipe, $news); 486 | } 487 | 488 | $result[] = $news; 489 | } 490 | 491 | // Get the associated users information. 492 | $usernames = $redis->pipeline(function ($pipe) use ($result) { 493 | foreach ($result as $news) { 494 | $pipe->hget("user:{$news['user_id']}", 'username'); 495 | } 496 | }); 497 | 498 | foreach ($result as $i => &$news) { 499 | $news['username'] = $usernames[$i]; 500 | } 501 | 502 | // Load user's vote information if we are in the context of a 503 | // registered user. 504 | if ($user) { 505 | $votes = $redis->pipeline(function ($pipe) use ($result, $user) { 506 | foreach ($result as $news) { 507 | $pipe->zscore("news.up:{$news['id']}", $user['id']); 508 | $pipe->zscore("news.down:{$news['id']}", $user['id']); 509 | } 510 | }); 511 | 512 | foreach ($result as $i => &$news) { 513 | if ($votes[$i * 2]) { 514 | $news['voted'] = 'up'; 515 | } elseif ($votes[$i * 2 + 1]) { 516 | $news['voted'] = 'down'; 517 | } else { 518 | $news['voted'] = false; 519 | } 520 | } 521 | } 522 | 523 | return $result; 524 | } 525 | 526 | /** 527 | * {@inheritdoc} 528 | */ 529 | public function getNewsComments(Array $user, Array $news) 530 | { 531 | $tree = array(); 532 | $users = array(); 533 | $comments = $this->getRedis()->hgetall("thread:comment:{$news['id']}"); 534 | 535 | foreach ($comments as $id => $comment) { 536 | if ($id == 'nextid') { 537 | continue; 538 | } 539 | 540 | $comment = json_decode($comment, true); 541 | $userID = $comment['user_id']; 542 | $parentID = $comment['parent_id']; 543 | 544 | if (!isset($users[$userID])) { 545 | $users[$userID] = $this->getUserByID($userID); 546 | } 547 | 548 | if (!isset($tree[$parentID])) { 549 | $tree[$parentID] = array(); 550 | } 551 | 552 | $tree[$parentID][] = array_merge($comment, array( 553 | 'id' => $id, 554 | 'thread_id' => $news['id'], 555 | 'voted' => H::commentVoted($user, $comment), 556 | 'user' => $users[$userID], 557 | )); 558 | } 559 | 560 | return $tree; 561 | } 562 | 563 | /** 564 | * Updates the rank of a news item. 565 | * 566 | * @param PipelineContext $pipe Pipeline used to batch the update operations. 567 | * @param array $news Single news item. 568 | */ 569 | protected function updateNewsRank(PipelineContext $pipe, Array &$news) 570 | { 571 | $realRank = $this->computeNewsRank($news); 572 | 573 | if (abs($realRank - $news['rank']) > 0.001) { 574 | $pipe->hmset("news:{$news['id']}", 'rank', $realRank); 575 | $pipe->zadd('news.top', $realRank , $news['id']); 576 | $news['rank'] = $realRank; 577 | } 578 | } 579 | 580 | /** 581 | * Compute the score for a news item. 582 | * 583 | * @param array $news News item. 584 | * @return float 585 | */ 586 | protected function computerNewsScore(Array $news) 587 | { 588 | $redis = $this->getRedis(); 589 | 590 | // TODO: For now we are doing a naive sum of votes, without time-based 591 | // filtering, nor IP filtering. We could use just ZCARD here of course, 592 | // but ZRANGE already returns everything needed for vote analysis once 593 | // implemented. 594 | $upvotes = $redis->zrange("news.up:{$news['id']}", 0, -1, 'withscores'); 595 | $downvotes = $redis->zrange("news.down:{$news['id']}", 0, -1, 'withscores'); 596 | 597 | // Now let's add the logarithm of the sum of all the votes, since 598 | // something with 5 up and 5 down is less interesting than something 599 | // with 50 up and 50 down. 600 | $score = count($upvotes) / 2 - count($downvotes) / 2; 601 | $votes = count($upvotes) / 2 + count($downvotes) / 2; 602 | 603 | if ($votes > ($logStart = $this->getOption('news_score_log_start'))) { 604 | $score += log($votes - $logStart) * $this->getOption('news_score_log_booster'); 605 | } 606 | 607 | return $score; 608 | } 609 | 610 | /** 611 | * Computes the rank of a news item. 612 | * 613 | * @param array $news Single news item. 614 | * @return float 615 | */ 616 | protected function computeNewsRank(Array $news) 617 | { 618 | $age = time() - (int) $news['ctime'] + $this->getOption('news_age_padding'); 619 | return ((float) $news['score']) / pow($age / 3600, $this->getOption('rank_aging_factor')); 620 | } 621 | 622 | /** 623 | * {@inheritdoc} 624 | */ 625 | public function insertNews($title, $url, $text, $userID) 626 | { 627 | $redis = $this->getRedis(); 628 | 629 | // Use a kind of URI using the "text" scheme if now URL has been provided. 630 | // TODO: remove duplicated code. 631 | $textPost = !$url; 632 | 633 | if ($textPost) { 634 | $url = 'text://' . substr($text, 0, $this->getOption('comment_max_length')); 635 | } 636 | 637 | // Verify if a news with the same URL has been already submitted. 638 | if (!$textPost && ($id = $redis->get("url:$url"))) { 639 | return (int) $id; 640 | } 641 | 642 | $ctime = time(); 643 | $newsID = $redis->incr('news.count'); 644 | $newsDetails = array( 645 | 'id' => $newsID, 646 | 'title' => $title, 647 | 'url' => $url, 648 | 'user_id' => $userID, 649 | 'ctime' => $ctime, 650 | 'score' => 0, 651 | 'rank' => 0, 652 | 'up' => 0, 653 | 'down' => 0, 654 | 'comments' => 0, 655 | ); 656 | $redis->hmset("news:$newsID", $newsDetails); 657 | 658 | // The posting user virtually upvoted the news posting it. 659 | $newsRank = $this->voteNews($newsID, $userID, 'up'); 660 | // Add the news to the user submitted news. 661 | $redis->zadd("user.posted:$userID", $ctime, $newsID); 662 | // Add the news into the chronological view. 663 | $redis->zadd('news.cron', $ctime, $newsID); 664 | // Add the news into the top view. 665 | $redis->zadd('news.top', $newsRank, $newsID); 666 | // Set a timeout indicating when the user may post again 667 | $redis->setex("user:$userID:submitted_recently", $this->getOption('news_submission_break') ,'1'); 668 | 669 | if (!$textPost) { 670 | // Avoid reposts for a certain amount of time using an expiring key. 671 | $redis->setex("url:$url", $this->getOption('prevent_repost_time'), $newsID); 672 | } 673 | 674 | return $newsID; 675 | } 676 | 677 | /** 678 | * {@inheritdoc} 679 | */ 680 | public function editNews(Array $user, $newsID, $title, $url, $text) 681 | { 682 | @list($news) = $this->getNewsByID($user, $newsID); 683 | 684 | if (!$news || $news['user_id'] != $user['id']) { 685 | return false; 686 | } 687 | 688 | if ($news['ctime'] < time() - $this->getOption('news_edit_time')) { 689 | return false; 690 | } 691 | 692 | // Use a kind of URI using the "text" scheme if now URL has been provided. 693 | // TODO: remove duplicated code. 694 | $textPost = !$url; 695 | 696 | if ($textPost) { 697 | $url = 'text://' . substr($text, 0, $this->getOption('comment_max_length')); 698 | } 699 | 700 | $redis = $this->getRedis(); 701 | 702 | // The URL for recently posted news cannot be changed. 703 | if (!$textPost && $url != $news['url']) { 704 | if ($redis->get("url:$url")) { 705 | return false; 706 | } 707 | 708 | // Prevent DOS attacks by locking the new URL after it has been changed. 709 | $redis->del("url:{$news['url']}"); 710 | 711 | if (!$textPost) { 712 | $redis->setex("url:$url", $this->getOption('prevent_repost_time'), $newsID); 713 | } 714 | } 715 | 716 | $redis->hmset("news:$newsID", array( 717 | 'title' => $title, 718 | 'url' => $url, 719 | )); 720 | 721 | return $newsID; 722 | } 723 | 724 | /** 725 | * {@inheritdoc} 726 | */ 727 | public function voteNews($newsID, $user, $type, &$error = null) 728 | { 729 | if ($type !== 'up' && $type !== 'down') { 730 | $error = 'Vote must be either up or down.'; 731 | return false; 732 | } 733 | 734 | $user = is_array($user) ? $user : $this->getUserByID($user); 735 | $news = $this->getNewsByID($user, $newsID); 736 | 737 | if (!$user || !$news) { 738 | $error = 'No such news or user.'; 739 | 740 | return false; 741 | } 742 | 743 | list($news) = $news; 744 | $redis = $this->getRedis(); 745 | 746 | // Verify that the user has not already voted the news item. 747 | $hasUpvoted = $redis->zscore("news.up:$newsID", $user['id']); 748 | $hasDownvoted = $redis->zscore("news.down:$newsID", $user['id']); 749 | 750 | if ($hasUpvoted || $hasDownvoted) { 751 | $error = 'Duplicated vote.'; 752 | 753 | return false; 754 | } 755 | 756 | // Check if the user has enough karma to perform this operation 757 | if ($user['id'] != $news['user_id']) { 758 | $noUpvote = $type == 'up' && $user['karma'] < $this->getOption('news_upvote_min_karma'); 759 | $noDownvote = $type == 'down' && $user['karma'] < $this->getOption('news_downvote_min_karma'); 760 | 761 | if ($noUpvote || $noDownvote) { 762 | $error = "You don't have enough karma to vote $type"; 763 | 764 | return false; 765 | } 766 | } 767 | 768 | $now = time(); 769 | 770 | // Add the vote for the news item. 771 | if ($redis->zadd("news.$type:$newsID", $now, $user['id'])) { 772 | $redis->hincrby("news:$newsID", $type, 1); 773 | } 774 | 775 | if ($type === 'up') { 776 | $redis->zadd("user.saved:{$user['id']}", $now, $newsID); 777 | } 778 | 779 | // Compute the new score and karma updating the news accordingly. 780 | $news['score'] = $this->computerNewsScore($news); 781 | $rank = $this->computeNewsRank($news); 782 | $redis->hmset("news:$newsID", array( 783 | 'score' => $news['score'], 784 | 'rank' => $rank, 785 | )); 786 | $redis->zadd('news.top', $rank, $newsID); 787 | 788 | // Adjust the karma of the user on vote, and transfer karma to the news owner if upvoted. 789 | if ($user['id'] != $news['user_id']) { 790 | if ($type == 'up') { 791 | $this->incrementUserKarma($user, -$this->getOption('news_upvote_karma_cost')); 792 | // TODO: yes, I know, it's an uber-hack... 793 | $transfedUser = array('id' => $news['user_id']); 794 | $this->incrementUserKarma($transfedUser, $this->getOption('news_upvote_karma_transfered')); 795 | } else { 796 | $this->incrementUserKarma($user, -$this->getOption('news_downvote_karma_cost')); 797 | } 798 | } 799 | 800 | return $rank; 801 | } 802 | 803 | /** 804 | * {@inheritdoc} 805 | */ 806 | public function deleteNews(Array $user, $newsID) 807 | { 808 | @list($news) = $this->getNewsByID($user, $newsID); 809 | 810 | if (!$news || $news['user_id'] != $user['id']) { 811 | return false; 812 | } 813 | 814 | if ((int)$news['ctime'] <= (time() - $this->getOption('news_edit_time'))) { 815 | return false; 816 | } 817 | 818 | $redis = $this->getRedis(); 819 | $redis->hmset("news:$newsID", 'del', 1); 820 | $redis->zrem('news.top', $newsID); 821 | $redis->zrem('news.cron', $newsID); 822 | 823 | return true; 824 | } 825 | 826 | /** 827 | * {@inheritdoc} 828 | */ 829 | public function handleComment(Array $user, $newsID, $commentID, $parentID, $body = null) 830 | { 831 | $redis = $this->getRedis(); 832 | $news = $this->getNewsByID($user, $newsID); 833 | 834 | if (!$news) { 835 | return false; 836 | } 837 | 838 | if ($commentID == -1) { 839 | if ($parentID != -1) { 840 | $parent = $this->getComment($newsID, $parentID); 841 | 842 | if (!$parent) { 843 | return false; 844 | } 845 | } 846 | 847 | $comment = array( 848 | 'score' => 0, 849 | 'body' => $body, 850 | 'parent_id' => $parentID, 851 | 'user_id' => $user['id'], 852 | 'ctime' => time(), 853 | 'up' => array((int) $user['id']), 854 | ); 855 | 856 | $commentID = $this->postComment($newsID, $comment); 857 | 858 | if (!$commentID) { 859 | return false; 860 | } 861 | 862 | $redis->hincrby("news:$newsID", 'comments', 1); 863 | $redis->zadd("user.comments:{$user['id']}", time(), "$newsID-$commentID"); 864 | 865 | // NOTE: karma updates on new comments has been temporarily disabled in LN v0.9.0 866 | // $this->incrementUserKarma($user, $this->getOption('karma_increment_comment')); 867 | if (isset($parent) && $redis->exists("user:{$parent['user_id']}")) { 868 | $redis->hincrby("user:{$parent['user_id']}", 'replies', 1); 869 | } 870 | 871 | return array( 872 | 'news_id' => $newsID, 873 | 'comment_id' => $commentID, 874 | 'op' => 'insert', 875 | ); 876 | } 877 | 878 | // If we reached this point the next step is either to update or 879 | // delete the comment. So we make sure the user_id of the request 880 | // matches the user_id of the comment. 881 | // We also make sure the user is in time for an edit operation. 882 | $comment = $this->getComment($newsID, $commentID); 883 | 884 | if (!$comment || $comment['user_id'] != $user['id']) { 885 | return false; 886 | } 887 | 888 | if (!$comment['ctime'] > (time() - $this->getOption('comment_edit_time'))) { 889 | return false; 890 | } 891 | 892 | if (!$body) { 893 | if (!$this->deleteComment($newsID, $commentID)) { 894 | return false; 895 | } 896 | 897 | $redis->hincrby("news:$newsID", 'comments', -1); 898 | 899 | return array( 900 | 'news_id' => $newsID, 901 | 'comment_id' => $commentID, 902 | 'op' => 'delete', 903 | ); 904 | } else { 905 | $update = array('body' => $body); 906 | 907 | if (isset($comment['del']) && $comment['del'] == true) { 908 | $update['del'] = 0; 909 | } 910 | 911 | if (!$this->editComment($newsID, $commentID, $update)) { 912 | return false; 913 | } 914 | 915 | return array( 916 | 'news_id' => $newsID, 917 | 'comment_id' => $commentID, 918 | 'op' => 'update', 919 | ); 920 | } 921 | } 922 | 923 | /** 924 | * {@inheritdoc} 925 | */ 926 | public function getComment($newsID, $commentID) 927 | { 928 | $json = $this->getRedis()->hget("thread:comment:$newsID", $commentID); 929 | 930 | if (!$json) { 931 | return; 932 | } 933 | 934 | return array_merge(json_decode($json, true), array( 935 | 'thread_id' => $newsID, 936 | 'id' => $commentID, 937 | )); 938 | } 939 | 940 | /** 941 | * {@inheritdoc} 942 | */ 943 | public function getUserComments(Array $user, $start = 0, $count = -1, $callback = null) 944 | { 945 | if (isset($callback) && !is_callable($callback)) { 946 | throw new \InvalidArgumentException('The callback arguments must be a valid callable.'); 947 | } 948 | 949 | $comments = array(); 950 | $redis = $this->getRedis(); 951 | $total = $redis->zcard("user.comments:{$user['id']}"); 952 | 953 | if ($total > 0) { 954 | $commentIDs = $redis->zrevrange("user.comments:{$user['id']}", $start, $count); 955 | 956 | foreach ($commentIDs as $compositeID) { 957 | list($newsID, $commentID) = explode('-', $compositeID); 958 | $comment = $this->getComment($newsID, $commentID); 959 | 960 | if ($comment) { 961 | $comment = array_merge($comment, array( 962 | 'user' => $this->getUserByID($comment['user_id']), 963 | 'voted' => H::commentVoted($user, $comment), 964 | )); 965 | 966 | $comments[] = isset($callback) ? $callback($comment) : $comment; 967 | } 968 | } 969 | } 970 | 971 | return array( 972 | 'list' => $comments, 973 | 'total' => $total, 974 | ); 975 | } 976 | 977 | /** 978 | * {@inheritdoc} 979 | */ 980 | public function postComment($newsID, Array $comment) 981 | { 982 | if (!isset($comment['parent_id'])) { 983 | // TODO: "no parent_id field" 984 | return false; 985 | } 986 | 987 | $redis = $this->getRedis(); 988 | $threadKey = "thread:comment:$newsID"; 989 | 990 | if ($comment['parent_id'] != -1) { 991 | if (!$redis->hget($threadKey, $comment['parent_id'])) { 992 | return false; 993 | } 994 | } 995 | 996 | $commentID = $redis->hincrby($threadKey, 'nextid', 1); 997 | $redis->hset($threadKey, $commentID, json_encode($comment)); 998 | 999 | return $commentID; 1000 | } 1001 | 1002 | /** 1003 | * {@inheritdoc} 1004 | */ 1005 | public function voteComment(Array $user, $newsID, $commentID, $type) 1006 | { 1007 | if ($type !== 'up' && $type !== 'down') { 1008 | return false; 1009 | } 1010 | 1011 | $comment = $this->getComment($newsID, $commentID); 1012 | 1013 | if (!$comment) { 1014 | return false; 1015 | } 1016 | 1017 | if (H::commentVoted($user, $comment)) { 1018 | return false; 1019 | } 1020 | 1021 | $votes[] = (int) $user['id']; 1022 | 1023 | return $this->editComment($newsID, $commentID, array($type => $votes)); 1024 | } 1025 | 1026 | /** 1027 | * {@inheritdoc} 1028 | */ 1029 | public function editComment($newsID, $commentID, Array $updates) 1030 | { 1031 | $redis = $this->getRedis(); 1032 | $threadKey = "thread:comment:$newsID"; 1033 | $json = $redis->hget($threadKey, $commentID); 1034 | 1035 | if (!$json) { 1036 | return false; 1037 | } 1038 | 1039 | $comment = array_merge(json_decode($json, true), $updates); 1040 | $redis->hset($threadKey, $commentID, json_encode($comment)); 1041 | 1042 | return true; 1043 | } 1044 | 1045 | /** 1046 | * {@inheritdoc} 1047 | */ 1048 | public function deleteComment($newsID, $commentID) 1049 | { 1050 | return $this->editComment($newsID, $commentID, array('del' => 1)); 1051 | } 1052 | 1053 | /** 1054 | * Gets an option by its name or returns all the options. 1055 | * 1056 | * @param string $option Name of the option. 1057 | * @return mixed 1058 | */ 1059 | public function getOption($option = null) 1060 | { 1061 | if (!$option) { 1062 | return $this->options; 1063 | } 1064 | 1065 | if (isset($this->options[$option])) { 1066 | return $this->options[$option]; 1067 | } 1068 | } 1069 | 1070 | /** 1071 | * Gets the underlying Redis client used to interact with Redis. 1072 | * 1073 | * @return Client 1074 | */ 1075 | public function getRedis() 1076 | { 1077 | return $this->redis; 1078 | } 1079 | 1080 | /** 1081 | * {@inheritdoc} 1082 | */ 1083 | public function getUser() 1084 | { 1085 | return $this->user; 1086 | } 1087 | } 1088 | --------------------------------------------------------------------------------