diff --git a/CHANGES.rst b/CHANGES.rst index e8e4ee2c..aceb1d1a 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -13,6 +13,7 @@ New Features - Add CSS variables for better organization and flexibility (`#1001`_, pkvach) - Add support for comment search by Thread URL in admin interface (`#1020`_, pkvach) - Add sorting option for comments (`#1005`_, pkvach) +- Add server API endpoint /pending for moderation queue (gflohr) .. _#966: https://github.com/posativ/isso/pull/966 .. _#998: https://github.com/isso-comments/isso/pull/998 diff --git a/contrib/isso-dev.cfg b/contrib/isso-dev.cfg index 760494b4..992415f7 100644 --- a/contrib/isso-dev.cfg +++ b/contrib/isso-dev.cfg @@ -18,6 +18,7 @@ notify = stdout reply-notifications = false log-file = latest-enabled = true +pending-enabled = true [admin] enabled = true diff --git a/isso/isso.cfg b/isso/isso.cfg index fec23829..1bcee5bb 100644 --- a/isso/isso.cfg +++ b/isso/isso.cfg @@ -65,6 +65,10 @@ gravatar-url = https://www.gravatar.com/avatar/{}?d=identicon&s=55 # needing to previously know the posts URIs) latest-enabled = false +# enable the "/pending" endpoint, that works likes "/latest" but only +# for comments waiting moderation +pending-enabled = false + [admin] enabled = false diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index a3989c98..941e680f 100644 --- a/isso/tests/test_comments.py +++ b/isso/tests/test_comments.py @@ -5,10 +5,12 @@ import re import tempfile import unittest +import base64 from urllib.parse import urlencode from werkzeug.wrappers import Response +from werkzeug.datastructures import Headers from isso import Isso, core, config from isso.utils import http @@ -689,6 +691,20 @@ def testLatestNotEnabled(self): response = self.get('/latest?limit=5') self.assertEqual(response.status_code, 404) + def testPendingNotFound(self): + # load some comments in a mix of posts + saved = [] + for idx, post_id in enumerate([1, 2, 2, 1, 2, 1, 3, 1, 4, 2, 3, 4, 1, 2]): + text = 'text-{}'.format(idx) + post_uri = 'test-{}'.format(post_id) + self.post('/new?uri=' + post_uri, data=json.dumps({'text': text})) + saved.append((post_uri, text)) + + response = self.get('/pending?limit=5') + + # If the admin interface was not enabled we should get a 404. + self.assertEqual(response.status_code, 404) + class TestHostDependent(unittest.TestCase): @@ -763,6 +779,8 @@ def setUp(self): conf.set("moderation", "enabled", "true") conf.set("guard", "enabled", "off") conf.set("hash", "algorithm", "none") + conf.set("admin", "enabled", "true") + self.conf = conf class App(Isso, core.Mixin): pass @@ -770,6 +788,8 @@ class App(Isso, core.Mixin): self.app = App(conf) self.app.wsgi_app = FakeIP(self.app.wsgi_app, "192.168.1.1") self.client = JSONClient(self.app, Response) + self.post = self.client.post + self.get = self.client.get def tearDown(self): os.unlink(self.path) @@ -844,6 +864,64 @@ def testModerateComment(self): # Comment should no longer exist self.assertEqual(self.app.db.comments.get(id_), None) + def testPendingWithoutAdmin(self): + self.conf.set("admin", "enabled", "false") + response = self.get('/pending?limit=5') + self.assertEqual(response.status_code, 404) + + def testPendingUnauthorized(self): + response = self.get('/pending?limit=5') + self.assertEqual(response.status_code, 401) + + def getAuthenticated(self, url, username, password): + credentials = f"{username}:{password}" + encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8') + headers = Headers() + headers.add('Authorization', f'Basic {encoded_credentials}') + + return self.client.get(url, headers=headers) + + def testPendingNotEnabled(self): + password = "s3cr3t" + self.conf.set("admin", "enabled", "true") + self.conf.set("admin", "password", password) + response = self.getAuthenticated('/pending?limit=5', 'admin', password) + self.assertEqual(response.status_code, 404) + + def testPendingNotEnabled(self): + password = "s3cr3t" + self.conf.set("admin", "enabled", "true") + self.conf.set("admin", "password", password) + self.conf.set("general", "pending-enabled", "true") + response = self.getAuthenticated('/pending?limit=5', 'admin', password) + self.assertEqual(response.status_code, 200) + + body = loads(response.data) + self.assertEqual(body, []) + + def testPendingPosts(self): + # load some comments in a mix of posts + saved = [] + for idx, post_id in enumerate([1, 2, 2, 1, 2, 1, 3, 1, 4, 2, 3, 4, 1, 2]): + text = 'text-{}'.format(idx) + post_uri = 'test-{}'.format(post_id) + self.post('/new?uri=' + post_uri, data=json.dumps({'text': text})) + saved.append((post_uri, text)) + + password = "s3cr3t" + self.conf.set("admin", "enabled", "true") + self.conf.set("admin", "password", password) + self.conf.set("general", "pending-enabled", "true") + response = self.getAuthenticated('/pending?limit=5', 'admin', password) + self.assertEqual(response.status_code, 200) + + body = loads(response.data) + expected_items = saved[-5:] # latest 5 + for reply, expected in zip(body, expected_items): + expected_uri, expected_text = expected + self.assertIn(expected_text, reply['text']) + self.assertEqual(expected_uri, reply['uri']) + class TestUnsubscribe(unittest.TestCase): diff --git a/isso/views/comments.py b/isso/views/comments.py index 9352dd00..74b68844 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -132,6 +132,33 @@ def get_uri_from_url(url): return uri +def requires_auth(method): + def decorated(self, *args, **kwargs): + request = args[1] + auth = request.authorization + if not auth: + return Response( + "Unauthorized", 401, + {'WWW-Authenticate': 'Basic realm="Authentication Required"'}) + if not self.check_auth(auth.username, auth.password): + return Response( + "Wrong username or password", 401, + {'WWW-Authenticate': 'Basic realm="Authentication Required"'}) + return method(self, *args, **kwargs) + return decorated + + +def requires_admin(method): + def decorated(self, *args, **kwargs): + if not self.isso.conf.getboolean("admin", "enabled"): + return NotFound( + "Unavailable because 'admin' not enabled by site admin" + ) + + return method(self, *args, **kwargs) + return decorated + + class API(object): FIELDS = set(['id', 'parent', 'text', 'author', 'website', @@ -146,6 +173,7 @@ class API(object): ('counts', ('POST', '/count')), ('feed', ('GET', '/feed')), ('latest', ('GET', '/latest')), + ('pending', ('GET', '/pending')), ('view', ('GET', '/id/')), ('edit', ('PUT', '/id/')), ('delete', ('DELETE', '/id/')), @@ -1515,12 +1543,12 @@ def admin(self, env, req): @apiName latest @apiVersion 0.12.6 @apiDescription - Get the latest comments from the system, no matter which thread. Only available if `[general] latest-enabled` is set to `true` in server config. + Get the latest accepted comments from the system, no matter which thread. Only available if `[general] latest-enabled` is set to `true` in server config. @apiQuery {Number} limit The quantity of last comments to retrieve - @apiExample {curl} Get the latest 5 comments + @apiExample {curl} Get the latest 5 accepted comments curl 'https://comments.example.com/latest?limit=5' @apiUse commentResponse @@ -1562,6 +1590,79 @@ def latest(self, environ, request): "Unavailable because 'latest-enabled' not set by site admin" ) + return self._latest(environ, request, "1") + + + def check_auth(self, username, password): + admin_password = self.isso.conf.get("admin", "password") + + return username == 'admin' and password == admin_password + + + """ + @api {get} /pending pending + @apiGroup Comment + @apiName pending + @apiVersion 0.13.1 + @apiDescription + Get the latest comments waiting moderation from the system, no matter which thread. Only available if `[general] pending-enabled` is set to `true` and `[admin] enabled is set to `true` in server config. + + @apiHeader {String="Basic BASE64_CREDENTIALS"} authorization Base64 encoded "USERNAME:PASSWORD" + + @apiQuery {Number} limit + The quantity of last comments to retrieve + + @apiExample {curl} Get the latest 5 pending comments + curl -u 'admin:ADMIN_PASSWORD' 'https://comments.example.com/pending?limit=5' + + @apiUse commentResponse + + @apiSuccessExample Example result: + [ + { + "website": null, + "uri": "/some", + "author": null, + "parent": null, + "created": 1464912312.123416, + "text": " <p>I want to use MySQL</p>", + "dislikes": 0, + "modified": null, + "mode": 2, + "id": 3, + "likes": 1 + }, + { + "website": null, + "uri": "/other", + "author": null, + "parent": null, + "created": 1464914341.312426, + "text": " <p>I want to use MySQL</p>", + "dislikes": 0, + "modified": null, + "mode": 2, + "id": 4, + "likes": 0 + } + ] + """ + # If the admin interface is not enabled, people may have not changed + # the default password. We therefore disallow the /pending endpoint, + # as well. + @requires_admin + @requires_auth + def pending(self, environ, request): + # if the feature is not allowed, don't present the endpoint + if not self.conf.getboolean("pending-enabled"): + return NotFound( + "Unavailable because 'pending-enabled' not set by site admin" + ) + + return self._latest(environ, request, "2") + + + def _latest(self, environ, request, mode): # get and check the limit bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)" try: @@ -1572,7 +1673,7 @@ def latest(self, environ, request): return BadRequest(bad_limit_msg) # retrieve the latest N comments from the DB - all_comments_gen = self.comments.fetchall(limit=None, order_by='created', mode='1') + all_comments_gen = self.comments.fetchall(limit=None, order_by='created', mode=mode) comments = collections.deque(all_comments_gen, maxlen=limit) # prepare a special set of fields (except text which is rendered specifically)