diff --git a/.travis.yml b/.travis.yml index c43d4c5dc..98cea3599 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,6 @@ language: python +cache: pip + matrix: include: - python: 3.5 diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index db1602e55..b195bc816 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -117,5 +117,8 @@ In chronological order: * Update Polish translation * Redirect to comment after moderation +* Julien Moura @Guts + * Notify through web hooks + * [Your name or handle] <[email or website]> * [Brief summary of your changes] diff --git a/contrib/webhook_template_slack.json b/contrib/webhook_template_slack.json new file mode 100644 index 000000000..57339e7e4 --- /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 4cc3fdd40..9f6dcec01 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -72,7 +72,7 @@ from isso.utils import http, JSONRequest, html, hash from isso.views import comments -from isso.ext.notifications import Stdout, SMTP +from isso.ext.notifications import Stdout, SMTP, WebHook logging.getLogger('werkzeug').setLevel(logging.WARN) logging.basicConfig( @@ -106,12 +106,14 @@ 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(None)) - elif backend in ("smtp", "SMTP"): + elif backend.lower() == "smtp": smtp_backend = True + elif backend.lower() == "webhook": + subscribers.append(WebHook(self)) else: - logger.warn("unknown notification backend '%s'", backend) + logger.warn("Unknown notification backend '%s'", backend) if smtp_backend or conf.getboolean("general", "reply-notifications"): subscribers.append(SMTP(self)) diff --git a/isso/dispatch.py b/isso/dispatch.py index 6b2867e7d..366eb1d08 100644 --- a/isso/dispatch.py +++ b/isso/dispatch.py @@ -12,7 +12,7 @@ from werkzeug.middleware.dispatcher import DispatcherMiddleware from werkzeug.wrappers import Response -from isso import dist, make_app, wsgi, config +from isso import make_app, wsgi, config logger = logging.getLogger("isso") diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 4b23525d0..d0bf6c32f 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -13,20 +13,27 @@ from email.header import Header from email.mime.text import MIMEText +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 from _thread import start_new_thread +from requests import HTTPError, Session + +# Globals +logger = logging.getLogger("isso") + class SMTPConnection(object): @@ -224,3 +231,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/run.py b/isso/run.py index 02299c4fb..4f36b9a52 100644 --- a/isso/run.py +++ b/isso/run.py @@ -5,8 +5,7 @@ import os import pkg_resources -from isso import make_app -from isso import dist, config +from isso import config, make_app application = make_app( config.load( diff --git a/isso/tests/test_guard.py b/isso/tests/test_guard.py index b71c2390d..673219690 100644 --- a/isso/tests/test_guard.py +++ b/isso/tests/test_guard.py @@ -11,7 +11,7 @@ from werkzeug.test import Client from werkzeug.wrappers import Response -from isso import Isso, config, core, dist +from isso import Isso, config, core from isso.utils import http from fixtures import curl, FakeIP diff --git a/isso/tests/test_vote.py b/isso/tests/test_vote.py index f034c48dd..b074cc6c3 100644 --- a/isso/tests/test_vote.py +++ b/isso/tests/test_vote.py @@ -1,7 +1,5 @@ - from __future__ import unicode_literals -import os import json import tempfile import pkg_resources @@ -9,21 +7,21 @@ from werkzeug.wrappers import Response -from isso import Isso, core, config, dist +from isso import Isso, core, config from isso.utils import http from fixtures import curl, loads, FakeIP, JSONClient + http.curl = curl class TestVote(unittest.TestCase): - def setUp(self): self.path = tempfile.NamedTemporaryFile().name def makeClient(self, ip): - conf = config.load(pkg_resources.resource_filename('isso', 'defaults.ini')) + 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") @@ -39,14 +37,16 @@ class App(Isso, core.Mixin): def testZeroLikes(self): 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) + "/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": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) rv = self.makeClient("0.0.0.0").post("/id/1/like") self.assertEqual(rv.status_code, 200) @@ -56,7 +56,7 @@ 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) @@ -64,23 +64,25 @@ def testSelfLike(self): def testMultipleLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(15): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data)["likes"], num + 1) def testVoteOnNonexistentComment(self): - rv = self.makeClient("1.2.3.4").post('/id/1/like') + rv = self.makeClient("1.2.3.4").post("/id/1/like") self.assertEqual(rv.status_code, 200) self.assertEqual(loads(rv.data), None) def testTooManyLikes(self): self.makeClient("127.0.0.1").post( - "/new?uri=test", data=json.dumps({"text": "..."})) + "/new?uri=test", data=json.dumps({"text": "..."}) + ) for num in range(256): - rv = self.makeClient("1.2.%i.0" % num).post('/id/1/like') + rv = self.makeClient("1.2.%i.0" % num).post("/id/1/like") self.assertEqual(rv.status_code, 200) if num >= 142: @@ -90,9 +92,10 @@ def testTooManyLikes(self): def testDislike(self): 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') + "/new?uri=test", data=json.dumps({"text": "..."}) + ) + rv = self.makeClient("1.2.3.4").post("/id/1/dislike") self.assertEqual(rv.status_code, 200) - self.assertEqual(loads(rv.data)['likes'], 0) - self.assertEqual(loads(rv.data)['dislikes'], 1) + self.assertEqual(loads(rv.data)["likes"], 0) + self.assertEqual(loads(rv.data)["dislikes"], 1) diff --git a/setup.cfg b/setup.cfg index a6e690270..53a901298 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,4 +6,4 @@ universal=1 [flake8] ignore = E501, E402 -exclude = docs/conf.py,node_modules,.tox,.eggs,.git +exclude = docs/conf.py,node_modules,.tox,.eggs,.git,.venv diff --git a/setup.py b/setup.py index 73038f6fd..e0aebb467 100755 --- a/setup.py +++ b/setup.py @@ -5,8 +5,16 @@ from setuptools import setup, find_packages -requires = ['itsdangerous', 'Jinja2', 'misaka>=2.0,<3.0', 'html5lib', - 'werkzeug>=1.0', 'bleach', 'flask-caching>=1.9'] +requires = [ + "bleach", + "flask-caching>=1.9", + "html5lib", + "itsdangerous", + "Jinja2", + "misaka>=2.0,<3.0", + "requests>=2.25,<2.26", + "werkzeug>=1.0", +] if sys.version_info < (3, ): raise SystemExit("Python 2 is not supported.") diff --git a/share/isso.conf b/share/isso.conf index 1dc8a0323..3607396a2 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -249,3 +249,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/tox.ini b/tox.ini index a25e64273..853e35937 100755 --- a/tox.ini +++ b/tox.ini @@ -11,9 +11,12 @@ commands = [testenv:debian] deps= bleach + flask-caching>=1.9,<1.11 html5lib ipaddr==2.1.10 - itsdangerous==0.22 - misaka==1.0.2 + itsdangerous + Jinja2 + misaka>=2.0,<3.0 passlib==1.5.3 - werkzeug==0.8.3 + requests>=2.25,<2.26 + werkzeug>=1.0