diff --git a/CHANGES.rst b/CHANGES.rst index 0126f04b..cf7f293f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -16,6 +16,7 @@ New Features - admin: Add log out button (`#870`_, bbaovanc) - Add support for environment variables in config (`#1037`_, pkvach) - Add Japanese localisation (`#1051`_, zurukumo) +- Allow access moderation queue (`#1028`_, gflohr) .. _#870: https://github.com/posativ/isso/pull/870 .. _#966: https://github.com/posativ/isso/pull/966 @@ -24,6 +25,7 @@ New Features .. _#1001: https://github.com/isso-comments/isso/pull/1001 .. _#1020: https://github.com/isso-comments/isso/pull/1020 .. _#1005: https://github.com/isso-comments/isso/pull/1005 +.. _#1028: https://github.com/isso-comments/isso/pull/1028 .. _#1037: https://github.com/isso-comments/isso/pull/1037 .. _#1051: https://github.com/isso-comments/isso/pull/1051 @@ -57,6 +59,9 @@ Bugfixes & Improvements - Python 3.12 support (`#1015`_, ix5) - Disable Postbox submit button on click, enable after response (`#993`_, pkvach) - Document title parameter and improve error handling for /new API (`#1058`_, pkvach) +- The `/latest` endpoint now has an optional parameter `mode`. The default value + of '1' retrieves published comments, the mode '2' retrieves posts waiting + moderation. - Set reply sorting to always be oldest (`#1035`_, ggtylerr) .. _#951: https://github.com/posativ/isso/pull/951 diff --git a/isso/tests/test_comments.py b/isso/tests/test_comments.py index a00fc086..437e17cb 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 @@ -682,6 +684,37 @@ def testLatestOk(self): self.assertIn(expected_text, reply['text']) self.assertEqual(expected_uri, reply['uri']) + def testLatestWithMode(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('/latest?limit=5&mode=1') + 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']) + + def testLatestWithInvalidMode(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('/latest?limit=5&mode=3') + self.assertEqual(response.status_code, 400) + def testLatestWithoutLimit(self): response = self.get('/latest') self.assertEqual(response.status_code, 400) @@ -705,6 +738,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): @@ -779,6 +826,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 @@ -786,6 +835,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) @@ -860,6 +911,38 @@ def testModerateComment(self): # Comment should no longer exist self.assertEqual(self.app.db.comments.get(id_), None) + 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 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", "latest-enabled", "true") + response = self.getAuthenticated('/latest?mode=2&limit=5', 'admin', password) + print(response.status) + 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 80fee5d9..90962051 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', @@ -1523,7 +1550,14 @@ def admin(self, env, req): @apiQuery {Number} limit The quantity of last comments to retrieve - @apiExample {curl} Get the latest 5 comments + @apiQuery {Number{1,2}} [mode=1] + The comments’ mode: + value | explanation + --- | --- + `1` | accepted: The comment was accepted by the server and is published. + `2` | in moderation queue: The comment was accepted by the server but awaits moderation. + + @apiExample {curl} Get the latest 5 accepted comments curl 'https://comments.example.com/latest?limit=5' @apiUse commentResponse @@ -1565,6 +1599,21 @@ def latest(self, environ, request): "Unavailable because 'latest-enabled' not set by site admin" ) + mode = request.args.get('mode', "1") + + if mode != "1" and mode != "2": + return BadRequest( + "Mode must either be '1' for accepted comments or '2' for pedning comments waiting moderation" + ) + + return self._latest(environ, request, mode) + + def check_auth(self, username, password): + admin_password = self.isso.conf.get("admin", "password") + + return username == 'admin' and password == admin_password + + def _latest(self, environ, request, mode): # get and check the limit bad_limit_msg = "Query parameter 'limit' is mandatory (integer, >0)" try: @@ -1575,7 +1624,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)