├── 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 |
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 |
13 |
14 |
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 |
12 |
13 |
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 |
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 |
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 |
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 |
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 | title
12 |
13 |
14 | url
15 |
16 |
17 | or if you don't have an url type some text
18 | text
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 |
20 |
21 | {% endif %}
22 |
23 | {% if app.user %}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | {% endif %}
34 |
35 | {% if comments %}
36 |
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 |
11 | created {{ user.ctime | elapsed }}
12 | karma {{ user.karma }} points
13 | posted news {{ user_counters.posted_news }}
14 | posted comments {{ user_counters.posted_comments }}
15 | {% if owner %}
16 | saved news
17 | {% endif %}
18 | user comments
19 |
20 |
21 |
22 | {% if owner %}
23 |
24 |
25 |
26 |
27 | email (not visible, used for gravatar)
28 |
29 |
30 | change password (optional)
31 |
32 |
33 | about
34 | {{ user.about }}
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 |
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 |
9 |
10 | at {{ news_domain(news) }}
11 | {% if is_editable %}[edit] {% endif %}
12 |
13 | {% else %}
14 |
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 |
78 | {% else %}
79 |
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 |
--------------------------------------------------------------------------------