diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b2db2c67..4ab8dc8d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -117,6 +117,9 @@ In chronological order: * Update Polish translation * Redirect to comment after moderation +* Julien Moura @Guts + * Notify through web hooks + * fliiiix * Import disqus posts without Email * Import disqus post without IP diff --git a/contrib/webhook_template_slack.json b/contrib/webhook_template_slack.json new file mode 100644 index 00000000..57339e7e --- /dev/null +++ b/contrib/webhook_template_slack.json @@ -0,0 +1,70 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": ":speech_balloon: New comment posted", + "emoji": true + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Author:* $AUTHOR_NAME $AUTHOR_EMAIL $AUTHOR_WEBSITE" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*IP:* $COMMENT_IP_ADDRESS" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Comment:*\n$COMMENT_TEXT" + } + }, + { + "type": "divider" + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": ":eye-in-speech-bubble: View comment" + }, + "url": "$COMMENT_URL_VIEW" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": ":white_check_mark: Approve" + }, + "style": "primary", + "url": "$COMMENT_URL_ACTIVATE" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "emoji": true, + "text": ":wastebasket: Deny" + }, + "style": "danger", + "url": "$COMMENT_URL_DELETE" + } + ] + } + ] +} diff --git a/isso/__init__.py b/isso/__init__.py index 6c7476b3..e6e86a70 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -69,7 +69,7 @@ from isso.utils import http, JSONRequest, JSONResponse, hash from isso.views import comments -from isso.ext.notifications import Stdout, SMTP +from isso.ext.notifications import Stdout, SMTP, WebHook LOG_FORMAT = "%(asctime)s:%(levelname)s: %(message)s" logging.getLogger("werkzeug").setLevel(logging.WARN) @@ -107,10 +107,12 @@ def __init__(self, conf): subscribers = [] smtp_backend = False for backend in conf.getlist("general", "notify"): - if backend == "stdout": + if backend.lower() == "stdout": subscribers.append(Stdout(self)) - elif backend in ("smtp", "SMTP"): + elif backend.lower() == "smtp": smtp_backend = True + elif backend.lower() == "webhook": + subscribers.append(WebHook(self)) else: logger.warning("unknown notification backend '%s'", backend) if smtp_backend or conf.getboolean("general", "reply-notifications"): diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 89953521..9566bc8f 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -9,23 +9,30 @@ from _thread import start_new_thread from email.message import EmailMessage from email.utils import formatdate +from pathlib import Path +from string import Template from urllib.parse import quote import logging -logger = logging.getLogger("isso") try: import uwsgi except ImportError: uwsgi = None -from isso import local +from isso import dist, local +from isso.views.comments import isurl def create_comment_action_url(uri, action, key): return uri + "/" + action + "/" + key +from requests import HTTPError, Session + +# Globals +logger = logging.getLogger("isso") + class SMTPConnection(object): def __init__(self, conf): @@ -250,3 +257,183 @@ def _delete_comment(self, id): def _activate_comment(self, thread, comment): logger.info("comment %(id)s activated" % thread) + + +class WebHook(object): + """Notification handler for web hook. + + :param isso_instance: Isso application instance. Used to get moderation key. + :type isso_instance: object + + :raises ValueError: if the provided URL is not valid + :raises FileExistsError: if the provided JSON template doesn't exist + :raises TypeError: if the provided template file is not a JSON + """ + + def __init__(self, isso_instance: object): + """Instanciate class.""" + # store isso instance + self.isso_instance = isso_instance + # retrieve relevant configuration + self.public_endpoint = isso_instance.conf.get( + section="server", option="public-endpoint" + ) or local("host") + webhook_conf_section = isso_instance.conf.section("webhook") + self.wh_url = webhook_conf_section.get("url") + self.wh_template = webhook_conf_section.get("template") + + # check required settings + if not isurl(self.wh_url): + raise ValueError( + "Web hook requires a valid URL. " + "The provided one is not correct: {}".format(self.wh_url) + ) + + # check optional template + if not len(self.wh_template): + self.wh_template = None + logger.debug("No template provided.") + elif not Path(self.wh_template).is_file(): + raise FileExistsError( + "Invalid web hook template path: {}".format(self.wh_template) + ) + elif not Path(self.wh_template).suffix == ".json": + raise TypeError()( + "Template must be a JSON file: {}".format(self.wh_template) + ) + else: + self.wh_template = Path(self.wh_template) + + def __iter__(self): + + yield "comments.new:after-save", self.new_comment + + def new_comment(self, thread: dict, comment: dict) -> bool: + """Triggered when a new comment is saved. + + :param thread: comment thread + :type thread: dict + :param comment: comment object + :type comment: dict + + :return: True if eveythring went fine. False if not. + :rtype: bool + """ + + try: + # get moderation URLs + moderation_urls = self.moderation_urls(thread, comment) + + if self.wh_template: + post_data = self.render_template(thread, comment, moderation_urls) + else: + post_data = { + "author_name": comment.get("author", "Anonymous"), + "author_email": comment.get("email"), + "author_website": comment.get("website"), + "comment_ip_address": comment.get("remote_addr"), + "comment_text": comment.get("text"), + "comment_url_activate": moderation_urls[0], + "comment_url_delete": moderation_urls[1], + "comment_url_view": moderation_urls[2], + } + + self.send(post_data) + except Exception as err: + logger.error(err) + return False + + return True + + def moderation_urls(self, thread: dict, comment: dict) -> tuple: + """Helper to build comment related URLs (deletion, activation, etc.). + + :param thread: comment thread + :type thread: dict + :param comment: comment object + :type comment: dict + + :return: tuple of URS in alpha order (activate, admin, delete, view) + :rtype: tuple + """ + uri = "{}/id/{}".format(self.public_endpoint, comment.get("id")) + key = self.isso_instance.sign(comment.get("id")) + + url_activate = "{}/activate/{}".format(uri, key) + url_delete = "{}/delete/{}".format(uri, key) + url_view = "{}#isso-{}".format( + local("origin") + thread.get("uri"), comment.get("id") + ) + + return url_activate, url_delete, url_view + + def render_template( + self, thread: dict, comment: dict, moderation_urls: tuple + ) -> str: + """Format comment information as webhook payload filling the specified template. + + :param thread: isso thread + :type thread: dict + :param comment: isso comment + :type comment: dict + :param moderation_urls: comment moderation URLs + :type comment: tuple + + :return: formatted message from template + :rtype: str + """ + # load template + with self.wh_template.open("r") as in_file: + tpl_json_data = json.load(in_file) + tpl_str = Template(json.dumps(tpl_json_data)) + + # substitute + out_msg = tpl_str.substitute( + AUTHOR_NAME=comment.get("author", "Anonymous"), + AUTHOR_EMAIL="<{}>".format(comment.get("email", "")), + AUTHOR_WEBSITE=comment.get("website", ""), + COMMENT_IP_ADDRESS=comment.get("remote_addr"), + COMMENT_TEXT=comment.get("text"), + COMMENT_URL_ACTIVATE=moderation_urls[0], + COMMENT_URL_DELETE=moderation_urls[1], + COMMENT_URL_VIEW=moderation_urls[2], + ) + + return out_msg + + def send(self, structured_msg: str) -> bool: + """Send the structured message as a notification to the class webhook URL. + + :param str structured_msg: structured message to send + + :rtype: bool + """ + # load the message to ensure encoding + msg_json = json.loads(structured_msg) + + with Session() as requests_session: + + # send requests + response = requests_session.post( + url=self.wh_url, + json=json.dumps(msg_json), + headers={ + "Content-Type": "application/json", + "User-Agent": "Isso/{0} (+https://posativ.org/isso)".format( + dist.version + ), + }, + ) + + try: + response.raise_for_status() + logger.info("Web hook sent to %s" % self.wh_url) + except HTTPError as err: + logger.error( + "Something went wrong during POST request to the web hook. Trace: %s" + % err + ) + return False + + # if no error occurred + return True diff --git a/isso/isso.cfg b/isso/isso.cfg index 9c927ce4..edcb9b70 100644 --- a/isso/isso.cfg +++ b/isso/isso.cfg @@ -271,3 +271,15 @@ base = # Limit the number of elements to return for each thread. limit = 100 + +[webhook] +# Isso can notify you on new comments via web hook. +# By default, it sends a POST data with new comment metadata: author, author email, author website, text, moderation URLs (activation, deletion, view). +# It's also possible to add a JSON template (using Python string.Template) to customize the POST data. Useful to fit some tools abilities ike Slack, Matrix, Teams, etc. +# An example for Slack with block builder is prodived in the contrib folder. + +# webhook URL +url = + +# path to the JSON template. Optional. +template = diff --git a/isso/tests/test_vote.py b/isso/tests/test_vote.py index 72cb54e5..a01d31a7 100644 --- a/isso/tests/test_vote.py +++ b/isso/tests/test_vote.py @@ -1,4 +1,4 @@ -# -*- encoding: utf-8 -*- +from __future__ import unicode_literals import json import tempfile @@ -11,6 +11,7 @@ from fixtures import curl, loads, FakeIP, JSONClient + http.curl = curl @@ -19,7 +20,8 @@ def setUp(self): self.path = tempfile.NamedTemporaryFile().name def makeClient(self, ip): - conf = config.load(config.default_file()) + + conf = config.load(pkg_resources.resource_filename("isso", "defaults.ini")) conf.set("general", "dbpath", self.path) conf.set("guard", "enabled", "off") conf.set("hash", "algorithm", "none") @@ -33,12 +35,18 @@ class App(Isso, core.Mixin): return JSONClient(app, Response) def testZeroLikes(self): - rv = self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + + rv = self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."}) + ) self.assertEqual(loads(rv.data)["likes"], 0) self.assertEqual(loads(rv.data)["dislikes"], 0) def testSingleLike(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."}) + ) rv = self.makeClient("0.0.0.0").post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -48,12 +56,16 @@ def testSelfLike(self): bob = self.makeClient("127.0.0.1") bob.post("/new?uri=test", data=json.dumps({"text": "..."})) rv = bob.post("/id/1/like") + rv = bob.post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], 0) def testMultipleLikes(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(15): rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -65,7 +77,10 @@ def testVoteOnNonexistentComment(self): self.assertEqual(loads(rv.data), None) def testTooManyLikes(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(256): rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -76,7 +91,9 @@ def testTooManyLikes(self): self.assertEqual(loads(rv.data)["likes"], num + 1) def testDislike(self): - self.makeClient("127.0.0.1").post("/new?uri=test", data=json.dumps({"text": "..."})) + self.makeClient("127.0.0.1").post( + "/new?uri=test", data=json.dumps({"text": "..."}) + ) rv = self.makeClient("1.2.3.4").post("/id/1/dislike") self.assertEqual(rv.status_code, 200) diff --git a/tox.ini b/tox.ini index 2382932f..9d499ab4 100755 --- a/tox.ini +++ b/tox.ini @@ -11,11 +11,13 @@ commands = [testenv:debian] deps= + bleach + flask-caching>=1.9,<1.11 + html5lib + ipaddr==2.1.10 itsdangerous Jinja2 misaka>=2.0,<3.0 - mistune>=3.1,<4.0 - html5lib + passlib==1.5.3 + requests>=2.25,<2.26 werkzeug>=1.0 - bleach - flask-caching>=1.9