diff --git a/docs/docs/configuration/client.rst b/docs/docs/configuration/client.rst index 7b074b4..ddd09d5 100644 --- a/docs/docs/configuration/client.rst +++ b/docs/docs/configuration/client.rst @@ -77,6 +77,11 @@ data-isso-require-email Set to `true` when spam guard is configured with `require-email = true`. +data-isso-reply-notifications +------------------- + +Set to `true` when reply notifications is configured with `reply-notifications = true`. + data-isso-max-comments-top and data-isso-max-comments-nested ------------------------------------------------------------ diff --git a/docs/docs/configuration/server.rst b/docs/docs/configuration/server.rst index c3475b0..f579036 100644 --- a/docs/docs/configuration/server.rst +++ b/docs/docs/configuration/server.rst @@ -88,6 +88,14 @@ notify Send notifications via SMTP on new comments with activation (if moderated) and deletion links. +reply-notifications + Allow users to request E-mail notifications for replies to their post. + + It is highly recommended to also turn on moderation when enabling this + setting, as Isso can otherwise be easily exploited for sending spam. + + Do not forget to configure the client accordingly. + log-file Log console messages to file instead of standard out. @@ -156,6 +164,12 @@ listen Does not apply for `uWSGI`. +public-endpoint + public URL that Isso is accessed from by end users. Should always be + a http:// or https:// absolute address. If left blank, automatic + detection is attempted. Normally only needs to be specified if + different than the `listen` setting. + reload reload application, when the source code has changed. Useful for development. Only works with the internal webserver. diff --git a/isso/__init__.py b/isso/__init__.py index 0af5b43..a3da85b 100644 --- a/isso/__init__.py +++ b/isso/__init__.py @@ -96,13 +96,16 @@ class Isso(object): super(Isso, self).__init__(conf) subscribers = [] + smtp_backend = False for backend in conf.getlist("general", "notify"): if backend == "stdout": subscribers.append(Stdout(None)) elif backend in ("smtp", "SMTP"): - subscribers.append(SMTP(self)) + smtp_backend = True else: logger.warn("unknown notification backend '%s'", backend) + if smtp_backend or conf.getboolean("general", "reply-notifications"): + subscribers.append(SMTP(self)) self.signal = ext.Signal(*subscribers) diff --git a/isso/css/isso.css b/isso/css/isso.css index c0716f8..63737b2 100644 --- a/isso/css/isso.css +++ b/isso/css/isso.css @@ -239,6 +239,9 @@ #fff 10px, #fff 20px ); +.isso-postbox > .form-wrapper > .notification-section { + display: none; + padding-bottom: 10px; } @media screen and (max-width:600px) { .isso-postbox > .form-wrapper > .auth-section .input-wrapper { diff --git a/isso/db/comments.py b/isso/db/comments.py index 71a4aa5..d2181f3 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -23,7 +23,7 @@ class Comments: 'mode', # status of the comment 1 = valid, 2 = pending, # 4 = soft-deleted (cannot hard delete because of replies) 'remote_addr', 'text', 'author', 'email', 'website', - 'likes', 'dislikes', 'voters'] + 'likes', 'dislikes', 'voters', 'notification'] def __init__(self, db): @@ -33,7 +33,12 @@ class Comments: ' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,', ' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,', ' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,', - ' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL);']) + ' likes INTEGER DEFAULT 0, dislikes INTEGER DEFAULT 0, voters BLOB NOT NULL,', + ' notification INTEGER NOT NULL DEFAULT 0);']) + try: + self.db.execute(['ALTER TABLE comments ADD COLUMN notification INTEGER NOT NULL DEFAULT 0;']) + except: + pass def add(self, uri, c): """ @@ -50,16 +55,16 @@ class Comments: 'INSERT INTO comments (', ' tid, parent,' ' created, modified, mode, remote_addr,', - ' text, author, email, website, voters )', + ' text, author, email, website, voters, notification)', 'SELECT', ' threads.id, ?,', ' ?, ?, ?, ?,', - ' ?, ?, ?, ?, ?', + ' ?, ?, ?, ?, ?, ?', 'FROM threads WHERE threads.uri = ?;'], ( c.get('parent'), c.get('created') or time.time(), None, c["mode"], c['remote_addr'], c['text'], c.get('author'), c.get('email'), c.get('website'), buffer( - Bloomfilter(iterable=[c['remote_addr']]).array), + Bloomfilter(iterable=[c['remote_addr']]).array), c.get('notification'), uri) ) @@ -76,6 +81,15 @@ class Comments: ' mode=1', 'WHERE id=? AND mode=2'], (id, )) + def unsubscribe(self, email, id): + """ + Turn off email notifications for replies to this comment. + """ + self.db.execute([ + 'UPDATE comments SET', + ' notification=0', + 'WHERE email=? AND (id=? OR parent=?);'], (email, id, id)) + def update(self, id, data): """ Update comment :param:`id` with values from :param:`data` and return diff --git a/isso/db/threads.py b/isso/db/threads.py index 4f0b476..060f074 100644 --- a/isso/db/threads.py +++ b/isso/db/threads.py @@ -25,6 +25,9 @@ class Threads(object): def __getitem__(self, uri): return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone()) + def get(self, id): + return Thread(*self.db.execute("SELECT * FROM threads WHERE id=?", (id, )).fetchone()) + def new(self, uri, title): self.db.execute( "INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title)) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 2b4a81c..0bf1ac1 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -14,6 +14,11 @@ from email.utils import formatdate from email.header import Header from email.mime.text import MIMEText +try: + from urllib.parse import quote +except ImportError: + from urllib import quote + import logging logger = logging.getLogger("isso") @@ -31,37 +36,10 @@ else: from _thread import start_new_thread -class SMTP(object): - - def __init__(self, isso): - - self.isso = isso - self.conf = isso.conf.section("smtp") - gh = isso.conf.get("general", "host") - if type(gh) == str: - self.general_host = gh - #if gh is not a string then gh is a list - else: - self.general_host = gh[0] - - # test SMTP connectivity - try: - with self: - logger.info("connected to SMTP server") - except (socket.error, smtplib.SMTPException): - logger.exception("unable to connect to SMTP server") +class SMTPConnection(object): - if uwsgi: - def spooler(args): - try: - self._sendmail(args[b"subject"].decode("utf-8"), - args["body"].decode("utf-8")) - except smtplib.SMTPConnectError: - return uwsgi.SPOOL_RETRY - else: - return uwsgi.SPOOL_OK - - uwsgi.spooler = spooler + def __init__(self, conf): + self.conf = conf def __enter__(self): klass = (smtplib.SMTP_SSL if self.conf.get( @@ -91,10 +69,40 @@ class SMTP(object): def __exit__(self, exc_type, exc_value, traceback): self.client.quit() +class SMTP(object): + + def __init__(self, isso): + + self.isso = isso + self.conf = isso.conf.section("smtp") + self.public_endpoint = isso.conf.get("server", "public-endpoint") or local("host") + self.admin_notify = any((n in ("smtp", "SMTP")) for n in isso.conf.getlist("general", "notify")) + self.reply_notify = isso.conf.getboolean("general", "reply-notifications") + + # test SMTP connectivity + try: + with SMTPConnection(self.conf): + logger.info("connected to SMTP server") + except (socket.error, smtplib.SMTPException): + logger.exception("unable to connect to SMTP server") + + if uwsgi: + def spooler(args): + try: + self._sendmail(args[b"subject"].decode("utf-8"), + args["body"].decode("utf-8")) + except smtplib.SMTPConnectError: + return uwsgi.SPOOL_RETRY + else: + return uwsgi.SPOOL_OK + + uwsgi.spooler = spooler + def __iter__(self): - yield "comments.new:after-save", self.notify + yield "comments.new:after-save", self.notify_new + yield "comments.activate", self.notify_activated - def format(self, thread, comment): + def format(self, thread, comment, parent_comment, recipient=None, admin=False): rv = io.StringIO() @@ -107,40 +115,74 @@ class SMTP(object): rv.write(comment["text"] + "\n") rv.write("\n") - if comment["website"]: - rv.write("User's URL: %s\n" % comment["website"]) + if admin: + if comment["website"]: + rv.write("User's URL: %s\n" % comment["website"]) + + rv.write("IP address: %s\n" % comment["remote_addr"]) - rv.write("IP address: %s\n" % comment["remote_addr"]) rv.write("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) rv.write("\n") + rv.write("---\n") - uri = self.general_host + "/id/%i" % comment["id"] - key = self.isso.sign(comment["id"]) + if admin: + uri = self.public_endpoint + "/id/%i" % comment["id"] + key = self.isso.sign(comment["id"]) - rv.write("---\n") - rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) + rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) - if comment["mode"] == 2: - rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) + if comment["mode"] == 2: + rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) - rv.seek(0) - return rv.read() + else: + uri = self.public_endpoint + "/id/%i" % parent_comment["id"] + key = self.isso.sign(('unsubscribe', recipient)) - def notify(self, thread, comment): + rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key)) - body = self.format(thread, comment) + rv.seek(0) + return rv.read() + def notify_new(self, thread, comment): + if self.admin_notify: + body = self.format(thread, comment, None, admin=True) + self.sendmail(thread["title"], body, thread, comment) + + if comment["mode"] == 1: + self.notify_users(thread, comment) + + def notify_activated(self, thread, comment): + self.notify_users(thread, comment) + + def notify_users(self, thread, comment): + if self.reply_notify and "parent" in comment and comment["parent"] is not None: + # Notify interested authors that a new comment is posted + notified = [] + parent_comment = self.isso.db.comments.get(comment["parent"]) + comments_to_notify = [parent_comment] if parent_comment is not None else [] + comments_to_notify += self.isso.db.comments.fetch(thread["uri"], mode=1, parent=comment["parent"]) + for comment_to_notify in comments_to_notify: + email = comment_to_notify["email"] + if "email" in comment_to_notify and comment_to_notify["notification"] and email not in notified \ + and comment_to_notify["id"] != comment["id"] and email != comment["email"]: + body = self.format(thread, comment, parent_comment, email, admin=False) + subject = "Re: New comment posted on %s" % thread["title"] + self.sendmail(subject, body, thread, comment, to=email) + notified.append(email) + + def sendmail(self, subject, body, thread, comment, to=None): if uwsgi: - uwsgi.spool({b"subject": thread["title"].encode("utf-8"), - b"body": body.encode("utf-8")}) + uwsgi.spool({b"subject": subject.encode("utf-8"), + b"body": body.encode("utf-8"), + b"to": to}) else: - start_new_thread(self._retry, (thread["title"], body)) + start_new_thread(self._retry, (subject, body, to)) - def _sendmail(self, subject, body): + def _sendmail(self, subject, body, to=None): from_addr = self.conf.get("from") - to_addr = self.conf.get("to") + to_addr = to or self.conf.get("to") msg = MIMEText(body, 'plain', 'utf-8') msg['From'] = from_addr @@ -148,13 +190,13 @@ class SMTP(object): msg['Date'] = formatdate(localtime=True) msg['Subject'] = Header(subject, 'utf-8') - with self as con: + with SMTPConnection(self.conf) as con: con.sendmail(from_addr, to_addr, msg.as_string()) - def _retry(self, subject, body): + def _retry(self, subject, body, to): for x in range(5): try: - self._sendmail(subject, body) + self._sendmail(subject, body, to) except smtplib.SMTPConnectError: time.sleep(60) else: @@ -187,5 +229,5 @@ class Stdout(object): def _delete_comment(self, id): logger.info('comment %i deleted', id) - def _activate_comment(self, id): - logger.info("comment %s activated" % id) + def _activate_comment(self, thread, comment): + logger.info("comment %(id)s activated" % thread) diff --git a/isso/js/app/config.js b/isso/js/app/config.js index 25af27c..ab1ba03 100644 --- a/isso/js/app/config.js +++ b/isso/js/app/config.js @@ -7,6 +7,7 @@ define(function() { "reply-to-self": false, "require-email": false, "require-author": false, + "reply-notifications": false, "max-comments-top": "inf", "max-comments-nested": 5, "reveal-on-click": 5, diff --git a/isso/js/app/dom.js b/isso/js/app/dom.js index ec76e13..05363e3 100644 --- a/isso/js/app/dom.js +++ b/isso/js/app/dom.js @@ -90,6 +90,8 @@ define(function() { this.focus = function() { node.focus() }; this.scrollIntoView = function(args) { node.scrollIntoView(args) }; + this.checked = function() { return node.checked; }; + this.setAttribute = function(key, value) { node.setAttribute(key, value) }; this.getAttribute = function(key) { return node.getAttribute(key) }; diff --git a/isso/js/app/i18n/en.js b/isso/js/app/i18n/en.js index fd110fe..b72e813 100644 --- a/isso/js/app/i18n/en.js +++ b/isso/js/app/i18n/en.js @@ -6,6 +6,7 @@ define({ "postbox-preview": "Preview", "postbox-edit": "Edit", "postbox-submit": "Submit", + "postbox-notification": "Subscribe to email notification of replies", "num-comments": "One Comment\n{{ n }} Comments", "no-comments": "No Comments Yet", diff --git a/isso/js/app/i18n/fr.js b/isso/js/app/i18n/fr.js index 32e0ed6..449ac1e 100644 --- a/isso/js/app/i18n/fr.js +++ b/isso/js/app/i18n/fr.js @@ -6,6 +6,7 @@ define({ "postbox-preview": "Aperçu", "postbox-edit": "Éditer", "postbox-submit": "Soumettre", + "postbox-notification": "S'abonner aux notifications de réponses", "num-comments": "{{ n }} commentaire\n{{ n }} commentaires", "no-comments": "Aucun commentaire pour l'instant", "atom-feed": "Flux Atom", diff --git a/isso/js/app/isso.js b/isso/js/app/isso.js index 54889e9..b45cf01 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -40,6 +40,17 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n", return true; }; + // only display notification checkbox if email is filled in + var email_edit = function() { + if (config["reply-notifications"] && $("[name='email']", el).value.length > 0) { + $(".notification-section", el).show(); + } else { + $(".notification-section", el).hide(); + } + }; + $("[name='email']", el).on("input", email_edit); + email_edit(); + // email is not optional if this config parameter is set if (config["require-email"]) { $("[name='email']", el).setAttribute("placeholder", @@ -89,7 +100,8 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n", author: author, email: email, website: website, text: utils.text($(".textarea", el).innerHTML), parent: parent || null, - title: $("#isso-thread").getAttribute("data-title") || null + title: $("#isso-thread").getAttribute("data-title") || null, + notification: $("[name=notification]", el).checked() ? 1 : 0, }).then(function(comment) { $(".textarea", el).innerHTML = ""; $(".textarea", el).blur(); diff --git a/isso/js/app/text/postbox.jade b/isso/js/app/text/postbox.jade index 908326b..5209daa 100644 --- a/isso/js/app/text/postbox.jade +++ b/isso/js/app/text/postbox.jade @@ -25,3 +25,7 @@ div(class='isso-postbox') p(class='post-action') input(type='button' name='edit' value=i18n('postbox-edit')) + section(class='notification-section') + label + input(type='checkbox' name='notification') + = i18n('postbox-notification') diff --git a/isso/views/comments.py b/isso/views/comments.py index b76be46..3899fa1 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -32,6 +32,10 @@ try: from urlparse import urlparse except ImportError: from urllib.parse import urlparse +try: + from urllib import unquote +except ImportError: + from urllib.parse import unquote try: from StringIO import StringIO except ImportError: @@ -93,28 +97,29 @@ def xhr(func): class API(object): FIELDS = set(['id', 'parent', 'text', 'author', 'website', - 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image']) + 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification']) # comment fields, that can be submitted - ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title']) + ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) VIEWS = [ - ('fetch', ('GET', '/')), - ('new', ('POST', '/new')), - ('count', ('GET', '/count')), - ('counts', ('POST', '/count')), - ('feed', ('GET', '/feed')), - ('view', ('GET', '/id/')), - ('edit', ('PUT', '/id/')), - ('delete', ('DELETE', '/id/')), - ('moderate', ('GET', '/id///')), - ('moderate', ('POST', '/id///')), - ('like', ('POST', '/id//like')), - ('dislike', ('POST', '/id//dislike')), - ('demo', ('GET', '/demo')), - ('preview', ('POST', '/preview')), - ('login', ('POST', '/login')), - ('admin', ('GET', '/admin')) + ('fetch', ('GET', '/')), + ('new', ('POST', '/new')), + ('count', ('GET', '/count')), + ('counts', ('POST', '/count')), + ('feed', ('GET', '/feed')), + ('view', ('GET', '/id/')), + ('edit', ('PUT', '/id/')), + ('delete', ('DELETE', '/id/')), + ('unsubscribe', ('GET', '/id//unsubscribe//')), + ('moderate', ('GET', '/id///')), + ('moderate', ('POST', '/id///')), + ('like', ('POST', '/id//like')), + ('dislike', ('POST', '/id//dislike')), + ('demo', ('GET', '/demo')), + ('preview', ('POST', '/preview')), + ('login', ('POST', '/login')), + ('admin', ('GET', '/admin')) ] def __init__(self, isso, hasher): @@ -492,6 +497,70 @@ class API(object): resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id)) return resp + """ + @api {get} /id/:id/:email/key unsubscribe + @apiGroup Comment + @apiDescription + Opt out from getting any further email notifications about replies to a particular comment. In order to use this endpoint, the requestor needs a `key` that is usually obtained from an email sent out by isso. + + @apiParam {number} id + The id of the comment to unsubscribe from replies to. + @apiParam {string} email + The email address of the subscriber. + @apiParam {string} key + The key to authenticate the subscriber. + + @apiExample {curl} Unsubscribe Alice from replies to comment with id 13: + curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos' + + @apiSuccessExample {html} Using GET: + <!DOCTYPE html> + <html> + <head> + <script> + if (confirm('Delete: Are you sure?')) { + xhr = new XMLHttpRequest; + xhr.open('POST', window.location.href); + xhr.send(null); + } + </script> + + @apiSuccessExample Using POST: + Yo + """ + + def unsubscribe(self, environ, request, id, email, key): + email = unquote(email) + + try: + rv = self.isso.unsign(key, max_age=2**32) + except (BadSignature, SignatureExpired): + raise Forbidden + + if rv[0] != 'unsubscribe' or rv[1] != email: + raise Forbidden + + item = self.comments.get(id) + + if item is None: + raise NotFound + + with self.isso.lock: + self.comments.unsubscribe(email, id) + + modal = ( + "" + "" + "" + " Successfully unsubscribed" + "" + "" + "

You have been unsubscribed from replies in the given conversation.

" + "" + "") + + return Response(modal, 200, content_type="text/html") + """ @api {post} /id/:id/:action/key moderate @apiGroup Comment @@ -554,9 +623,12 @@ class API(object): return Response(modal, 200, content_type="text/html") if action == "activate": + if item['mode'] == 1: + return Response("Already activated", 200) with self.isso.lock: self.comments.activate(id) - self.signal("comments.activate", id) + thread = self.threads.get(item['tid']) + self.signal("comments.activate", thread, item) return Response("Yo", 200) elif action == "edit": data = request.get_json() diff --git a/share/isso-dev.conf b/share/isso-dev.conf index 970c1b0..18400ee 100644 --- a/share/isso-dev.conf +++ b/share/isso-dev.conf @@ -9,6 +9,7 @@ dbpath = /var/isso/comments.db host = http://isso-dev.local/ max-age = 15m notify = stdout +reply-notifications = false log-file = /var/log/isso.log admin_password = strong_default_password_for_isso_admin diff --git a/share/isso.conf b/share/isso.conf index 57a1155..9828ca4 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -43,6 +43,11 @@ max-age = 15m # moderated) and deletion links. notify = stdout +# Allow users to request E-mail notifications for replies to their post. +# WARNING: It is highly recommended to also turn on moderation when enabling +# this setting, as Isso can otherwise be easily exploited for sending spam. +reply-notifications=false + # Log console messages to file instead of standard output. log-file = @@ -78,6 +83,11 @@ purge-after = 30d # for details). Does not apply for uWSGI. listen = http://localhost:8080 +# public URL that Isso is accessed from by end users. Should always be a +# http:// or https:// absolute address. If left blank, automatic detection is +# attempted. +public-endpoint = + # reload application, when the source code has changed. Useful for development. # Only works with the internal webserver. reload = off