From f2f6aa07e6facb6d06c362982cd231605369f974 Mon Sep 17 00:00:00 2001 From: Guido Flohr Date: Fri, 31 May 2024 10:15:01 +0300 Subject: [PATCH 1/4] views, comments: add /pending endpoint It works exactly like `/latest` but returns only posts waiting moderation. The endpoint has to be explicitly enabled and requests must be authorized with the admin password. The admin interface also has to be enabled to make sure that people have changed the password. --- contrib/isso-dev.cfg | 1 + isso/isso.cfg | 4 ++ isso/tests/test_comments.py | 78 ++++++++++++++++++++++++++++ isso/views/comments.py | 101 +++++++++++++++++++++++++++++++++++- 4 files changed, 183 insertions(+), 1 deletion(-) 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..4ea3b3cd 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/')), @@ -1562,6 +1590,77 @@ 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.0 + @apiDescription + Get the latest comments from the system waiting moderation, no matter which thread. Only available if `[general] pending-enabled` is set to `true` in server config. + + @apiQuery {Number} limit + The quantity of last comments to retrieve + + @apiExample {curl} Get the latest 5 pending comments + curl '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 +1671,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) From 91fa27d05b73410d304da8e82e370b633edbb392 Mon Sep 17 00:00:00 2001 From: Guido Flohr Date: Fri, 31 May 2024 10:39:29 +0300 Subject: [PATCH 2/4] views, API docs: clarify /latest vs. /pending It should be mentioned that the endpoint /latest only returns accepted comments. --- isso/views/comments.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/isso/views/comments.py b/isso/views/comments.py index 4ea3b3cd..8f684419 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -1543,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 @@ -1605,13 +1605,15 @@ def check_auth(self, username, password): @apiName pending @apiVersion 0.13.0 @apiDescription - Get the latest comments from the system waiting moderation, no matter which thread. Only available if `[general] pending-enabled` is set to `true` in server config. + 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 'https://comments.example.com/pending?limit=5' + curl -u 'admin:ADMIN_PASSWORD' 'https://comments.example.com/pending?limit=5' @apiUse commentResponse From 2df0f367de5ef86d6abfcd0ee03dd5a3ac76f701 Mon Sep 17 00:00:00 2001 From: Guido Flohr Date: Fri, 31 May 2024 10:43:25 +0300 Subject: [PATCH 3/4] CHANGES.rst: Document new endpoint `/pending` --- CHANGES.rst | 1 + 1 file changed, 1 insertion(+) 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 From 07a9e4f21076e79b75dd3a8953896ec2d201c3a5 Mon Sep 17 00:00:00 2001 From: Guido Flohr Date: Fri, 31 May 2024 11:45:06 +0300 Subject: [PATCH 4/4] views, comments: fix api version for /pending Version 0.13.0 is already released. . Change to the probably next version 0.13.1. --- isso/views/comments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isso/views/comments.py b/isso/views/comments.py index 8f684419..74b68844 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -1603,7 +1603,7 @@ def check_auth(self, username, password): @api {get} /pending pending @apiGroup Comment @apiName pending - @apiVersion 0.13.0 + @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.