From 08313c191c0cb1d87c76f190bdac1886fc63ca02 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Thu, 26 Dec 2013 19:19:15 +0100 Subject: [PATCH 01/18] Added reply notification for commenter --- isso/css/isso.scss | 6 ++++ isso/db/comments.py | 11 ++++--- isso/ext/notifications.py | 61 +++++++++++++++++++++-------------- isso/js/app/i18n/en.js | 1 + isso/js/app/i18n/fr.js | 1 + isso/js/app/isso.js | 4 ++- isso/js/app/text/postbox.html | 4 +++ isso/views/comments.py | 6 ++-- 8 files changed, 61 insertions(+), 33 deletions(-) diff --git a/isso/css/isso.scss b/isso/css/isso.scss index 94dd227..11f6350 100644 --- a/isso/css/isso.scss +++ b/isso/css/isso.scss @@ -214,5 +214,11 @@ a { } } } + + > .notification-section { + display: none; + padding-bottom: 10px; + } + } } diff --git a/isso/db/comments.py b/isso/db/comments.py index 6897c97..47ed6a7 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -20,7 +20,7 @@ class Comments: """ fields = ['tid', 'id', 'parent', 'created', 'modified', 'mode', 'remote_addr', - 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters'] + 'text', 'author', 'email', 'website', 'likes', 'dislikes', 'voters', 'notification'] def __init__(self, db): @@ -30,7 +30,8 @@ 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);']) def add(self, uri, c): """ @@ -41,16 +42,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', 0), uri) ) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 3283bd6..7e9ad27 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -58,10 +58,10 @@ class SMTP(object): def __enter__(self): klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP) + klass = smtplib.SMTP self.client = klass(host=self.conf.get('host'), port=self.conf.getint('port')) - - if self.conf.get('security') == 'starttls': - self.client.starttls(); + #if self.conf.get('security') == 'starttls': + # self.client.starttls(); if self.conf.get('username') and self.conf.get('password'): self.client.login(self.conf.get('username'), @@ -75,7 +75,7 @@ class SMTP(object): def __iter__(self): yield "comments.new:after-save", self.notify - def format(self, thread, comment): + def format(self, thread, comment, admin=False): rv = io.StringIO() @@ -88,39 +88,50 @@ 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("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) - rv.write("\n") + 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") - uri = local("host") + "/id/%i" % comment["id"] - key = self.isso.sign(comment["id"]) + uri = local("host") + "/id/%i" % comment["id"] + key = self.isso.sign(comment["id"]) - rv.write("---\n") - rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) + rv.write("---\n") + 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() def notify(self, thread, comment): - - body = self.format(thread, comment) - + if "parent" in comment: + comment_parent = self.isso.db.comments.get(comment["parent"]) + # Notify the author that a new comment is posted if requested + if "email" in comment_parent and comment_parent["notification"]: + body = self.format(thread, comment, admin=False) + subject = "Re: New comment posted on %s" % thread["title"] + self.sendmail(subject, body, thread, comment, to=comment_parent["email"]) + + body = self.format(thread, comment, admin=True) + self.sendmail(thread["title"], body, thread, comment) + + 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'] = "Ich schrei sonst! <%s>" % from_addr @@ -131,10 +142,10 @@ class SMTP(object): with self 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: diff --git a/isso/js/app/i18n/en.js b/isso/js/app/i18n/en.js index 0381a63..db73336 100644 --- a/isso/js/app/i18n/en.js +++ b/isso/js/app/i18n/en.js @@ -3,6 +3,7 @@ define({ "postbox-author": "Name (optional)", "postbox-email": "E-mail (optional)", "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 fbc8762..7434ef9 100644 --- a/isso/js/app/i18n/fr.js +++ b/isso/js/app/i18n/fr.js @@ -3,6 +3,7 @@ define({ "postbox-author": "Nom (optionel)", "postbox-email": "Courriel (optionel)", "postbox-submit": "Soumettre", + "postbox-notification": "S'abonner aux notifications de réponses", "num-comments": "Un commentaire\n{{ n }} commentaires", "no-comments": "Aucun commentaire pour l'instant", diff --git a/isso/js/app/isso.js b/isso/js/app/isso.js index b374614..a472e59 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -34,6 +34,7 @@ define(["app/text/html", "app/dom", "app/utils", "app/config", "app/api", "app/m .then(function(rv) { $(".avatar svg", el).replace(lib.identicons.generate(rv, 4, 48)); }); + $(".notification-section").style.display = "block"; }, 200); }, false); @@ -63,7 +64,8 @@ define(["app/text/html", "app/dom", "app/utils", "app/config", "app/api", "app/m author: $("[name=author]", el).value || null, email: $("[name=email]", el).value || null, text: $("textarea", el).value, - parent: parent || null + parent: parent || null, + notification: $("[name=notification]", el).checked ? 1 : 0, }).then(function(comment) { $("[name=author]", el).value = ""; $("[name=email]", el).value = ""; diff --git a/isso/js/app/text/postbox.html b/isso/js/app/text/postbox.html index a72f71f..18a418e 100644 --- a/isso/js/app/text/postbox.html +++ b/isso/js/app/text/postbox.html @@ -6,6 +6,7 @@
+

@@ -17,5 +18,8 @@

+
+ +
\ No newline at end of file diff --git a/isso/views/comments.py b/isso/views/comments.py index 492e99c..f0ecb00 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -52,10 +52,10 @@ def xhr(func): class API(object): FIELDS = set(['id', 'parent', 'text', 'author', 'website', 'email', - 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash']) + 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'notification']) # comment fields, that can be submitted - ACCEPT = set(['text', 'author', 'website', 'email', 'parent']) + ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'notification']) VIEWS = [ ('fetch', ('GET', '/')), @@ -123,6 +123,7 @@ class API(object): valid, reason = API.verify(data) if not valid: + print valid, "VALID" return BadRequest(reason) for field in ("author", "email"): @@ -134,6 +135,7 @@ class API(object): with self.isso.lock: if uri not in self.threads: + print "URI", uri, local('origin') with http.curl('GET', local("origin"), uri) as resp: if resp and resp.status == 200: uri, title = parse.thread(resp.read(), id=uri) From e50ecc78111dd4603d3a2adb614666a66b148388 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Thu, 26 Dec 2013 19:22:55 +0100 Subject: [PATCH 02/18] Removed debug info --- isso/ext/notifications.py | 5 ++--- isso/views/comments.py | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 7e9ad27..fce0a04 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -58,10 +58,9 @@ class SMTP(object): def __enter__(self): klass = (smtplib.SMTP_SSL if self.conf.get('security') == 'ssl' else smtplib.SMTP) - klass = smtplib.SMTP self.client = klass(host=self.conf.get('host'), port=self.conf.getint('port')) - #if self.conf.get('security') == 'starttls': - # self.client.starttls(); + if self.conf.get('security') == 'starttls': + self.client.starttls() if self.conf.get('username') and self.conf.get('password'): self.client.login(self.conf.get('username'), diff --git a/isso/views/comments.py b/isso/views/comments.py index f0ecb00..963e520 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -123,7 +123,6 @@ class API(object): valid, reason = API.verify(data) if not valid: - print valid, "VALID" return BadRequest(reason) for field in ("author", "email"): @@ -135,7 +134,6 @@ class API(object): with self.isso.lock: if uri not in self.threads: - print "URI", uri, local('origin') with http.curl('GET', local("origin"), uri) as resp: if resp and resp.status == 200: uri, title = parse.thread(resp.read(), id=uri) From a322cf673af0aad21cd05f5c0459cb4446b5cb90 Mon Sep 17 00:00:00 2001 From: Thomas Sileo Date: Thu, 26 Dec 2013 22:22:48 +0100 Subject: [PATCH 03/18] Bugfix --- isso/ext/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index fce0a04..decd13c 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -111,7 +111,7 @@ class SMTP(object): if "parent" in comment: comment_parent = self.isso.db.comments.get(comment["parent"]) # Notify the author that a new comment is posted if requested - if "email" in comment_parent and comment_parent["notification"]: + if comment_parent and "email" in comment_parent and comment_parent["notification"]: body = self.format(thread, comment, admin=False) subject = "Re: New comment posted on %s" % thread["title"] self.sendmail(subject, body, thread, comment, to=comment_parent["email"]) From 107b9be0030d57e2e0b8a5920da99d7771f59813 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 18 Feb 2018 14:00:18 +0100 Subject: [PATCH 04/18] Add notification column to database if needed --- isso/db/comments.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/isso/db/comments.py b/isso/db/comments.py index 3110d25..924a028 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -35,6 +35,10 @@ class Comments: ' text VARCHAR, author VARCHAR, email VARCHAR, website VARCHAR,', ' 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): """ From 2e85ec653f03741dab953b0845281535ec8abd86 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 18 Feb 2018 16:48:08 +0100 Subject: [PATCH 05/18] Make SMTP connections thread safe --- isso/ext/notifications.py | 55 +++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 38fa173..0f71539 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -31,31 +31,10 @@ else: from _thread import start_new_thread -class SMTP(object): - - def __init__(self, isso): +class SMTPConnection(object): - self.isso = isso - self.conf = isso.conf.section("smtp") - - # 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") - - 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( @@ -85,6 +64,32 @@ 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") + + # 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 @@ -153,7 +158,7 @@ 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, to): From bc4bc55025b7b156187bc2c0cadb4fbab6a9a866 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Tue, 20 Feb 2018 20:03:52 +0100 Subject: [PATCH 06/18] Include link to comment in email notifications --- isso/ext/notifications.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 0f71539..a075d80 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -111,10 +111,12 @@ class SMTP(object): rv.write("User's URL: %s\n" % comment["website"]) 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("Link to comment: %s\n" % + (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) + rv.write("\n") + + if admin: uri = local("host") + "/id/%i" % comment["id"] key = self.isso.sign(comment["id"]) From c9045f5b1fe412ab0e33a791187f538fe464e866 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Wed, 21 Feb 2018 21:39:35 +0100 Subject: [PATCH 07/18] Implement opt-out for email notifications --- isso/db/comments.py | 9 ++++ isso/ext/notifications.py | 14 ++++-- isso/views/comments.py | 91 ++++++++++++++++++++++++++++++++------- 3 files changed, 95 insertions(+), 19 deletions(-) diff --git a/isso/db/comments.py b/isso/db/comments.py index 924a028..c23ad3c 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -81,6 +81,15 @@ class Comments: ' mode=1', 'WHERE id=? AND mode=2'], (id, )) + def unsubscribe(self, id): + """ + Turn off email notifications for replies to this comment. + """ + self.db.execute([ + 'UPDATE comments SET', + ' notification=0', + 'WHERE id=?'], (id, )) + def update(self, id, data): """ Update comment :param:`id` with values from :param:`data` and return diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index a075d80..1efd451 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -93,7 +93,7 @@ class SMTP(object): def __iter__(self): yield "comments.new:after-save", self.notify - def format(self, thread, comment, admin=False): + def format(self, thread, comment, comment_parent, admin=False): rv = io.StringIO() @@ -115,17 +115,23 @@ class SMTP(object): rv.write("Link to comment: %s\n" % (local("origin") + thread["uri"] + "#isso-%i" % comment["id"])) rv.write("\n") + rv.write("---\n") if admin: uri = local("host") + "/id/%i" % comment["id"] key = self.isso.sign(comment["id"]) - rv.write("---\n") rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) if comment["mode"] == 2: rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) + else: + uri = local("host") + "/id/%i" % comment_parent["id"] + key = self.isso.sign(('unsubscribe', comment_parent["id"])) + + rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key)) + rv.seek(0) return rv.read() @@ -134,11 +140,11 @@ class SMTP(object): comment_parent = self.isso.db.comments.get(comment["parent"]) # Notify the author that a new comment is posted if requested if comment_parent and "email" in comment_parent and comment_parent["notification"]: - body = self.format(thread, comment, admin=False) + body = self.format(thread, comment, comment_parent, admin=False) subject = "Re: New comment posted on %s" % thread["title"] self.sendmail(subject, body, thread, comment, to=comment_parent["email"]) - body = self.format(thread, comment, admin=True) + body = self.format(thread, comment, None, admin=True) self.sendmail(thread["title"], body, thread, comment) def sendmail(self, subject, body, thread, comment, to=None): diff --git a/isso/views/comments.py b/isso/views/comments.py index c95026e..9096925 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -87,21 +87,22 @@ class API(object): ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) VIEWS = [ - ('fetch', ('GET', '/')), - ('new', ('POST', '/new')), - ('count', ('GET', '/count')), - ('counts', ('POST', '/count')), - ('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')), + ('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): @@ -477,6 +478,66 @@ class API(object): resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id)) return resp + """ + @api {get} /id/:id/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} key + The key to authenticate the subscriber. + + @apiExample {curl} Unsubscribe from replies to comment with id 13: + curl -X GET 'https://comments.example.com/id/13/unsubscribe/TODO-COMPUTE-HASH' + + @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, key): + try: + rv = self.isso.unsign(key, max_age=2**32) + except (BadSignature, SignatureExpired): + raise Forbidden + + if rv[0] != 'unsubscribe' or rv[1] != id: + raise Forbidden + + item = self.comments.get(id) + + if item is None: + raise NotFound + + with self.isso.lock: + self.comments.unsubscribe(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 From da6bb0cec78974a8b4c07b93e020227f74be318a Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 15 Apr 2018 19:26:44 +0200 Subject: [PATCH 08/18] Fix faulty check for parent comment --- isso/ext/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 1efd451..5a2f1a3 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -136,7 +136,7 @@ class SMTP(object): return rv.read() def notify(self, thread, comment): - if "parent" in comment: + if "parent" in comment and comment["parent"] is not None: comment_parent = self.isso.db.comments.get(comment["parent"]) # Notify the author that a new comment is posted if requested if comment_parent and "email" in comment_parent and comment_parent["notification"]: From 22a36bdb7c43ffe0b9a77aa1550ad21c0b27ebc6 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 15 Apr 2018 19:42:31 +0200 Subject: [PATCH 09/18] Support notifications also for replies --- isso/ext/notifications.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index 5a2f1a3..f14a9af 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -137,12 +137,19 @@ class SMTP(object): def notify(self, thread, comment): if "parent" in comment and comment["parent"] is not None: - comment_parent = self.isso.db.comments.get(comment["parent"]) - # Notify the author that a new comment is posted if requested - if comment_parent and "email" in comment_parent and comment_parent["notification"]: - body = self.format(thread, comment, comment_parent, admin=False) - subject = "Re: New comment posted on %s" % thread["title"] - self.sendmail(subject, body, thread, comment, to=comment_parent["email"]) + # 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"]: + body = self.format(thread, comment, comment_to_notify, admin=False) + subject = "Re: New comment posted on %s" % thread["title"] + self.sendmail(subject, body, thread, comment, to=email) + notified += email body = self.format(thread, comment, None, admin=True) self.sendmail(thread["title"], body, thread, comment) From 36d4ceb2d91355f2ec4a8f6a5b64c714bc9765f2 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Thu, 19 Apr 2018 20:48:13 +0200 Subject: [PATCH 10/18] Don't send notification when someone responds to his/her own comment --- isso/ext/notifications.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index f14a9af..6865551 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -145,7 +145,7 @@ class SMTP(object): 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 comment_to_notify["id"] != comment["id"] and email != comment["email"]: body = self.format(thread, comment, comment_to_notify, admin=False) subject = "Re: New comment posted on %s" % thread["title"] self.sendmail(subject, body, thread, comment, to=email) From 0063fd6e88c6574d857fa09bef58f22e0e6d9cc7 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Thu, 10 May 2018 20:48:13 +0200 Subject: [PATCH 11/18] Make unsubscribe work with notifications for replies --- isso/db/comments.py | 4 ++-- isso/ext/notifications.py | 17 +++++++++++------ isso/views/comments.py | 22 +++++++++++++++------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/isso/db/comments.py b/isso/db/comments.py index 0ba3964..d2181f3 100644 --- a/isso/db/comments.py +++ b/isso/db/comments.py @@ -81,14 +81,14 @@ class Comments: ' mode=1', 'WHERE id=? AND mode=2'], (id, )) - def unsubscribe(self, id): + def unsubscribe(self, email, id): """ Turn off email notifications for replies to this comment. """ self.db.execute([ 'UPDATE comments SET', ' notification=0', - 'WHERE id=?'], (id, )) + 'WHERE email=? AND (id=? OR parent=?);'], (email, id, id)) def update(self, id, data): """ diff --git a/isso/ext/notifications.py b/isso/ext/notifications.py index ccea39b..00f0a02 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") @@ -99,7 +104,7 @@ class SMTP(object): def __iter__(self): yield "comments.new:after-save", self.notify - def format(self, thread, comment, comment_parent, admin=False): + def format(self, thread, comment, parent_comment, recipient=None, admin=False): rv = io.StringIO() @@ -133,10 +138,10 @@ class SMTP(object): rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) else: - uri = self.general_host + "/id/%i" % comment_parent["id"] - key = self.isso.sign(('unsubscribe', comment_parent["id"])) + uri = self.general_host + "/id/%i" % parent_comment["id"] + key = self.isso.sign(('unsubscribe', recipient)) - rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + key)) + rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key)) rv.seek(0) return rv.read() @@ -152,10 +157,10 @@ class SMTP(object): 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, comment_to_notify, admin=False) + 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 += email + notified.append(email) body = self.format(thread, comment, None, admin=True) self.sendmail(thread["title"], body, thread, comment) diff --git a/isso/views/comments.py b/isso/views/comments.py index 4c12820..175c346 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: @@ -107,7 +111,7 @@ class API(object): ('view', ('GET', '/id/')), ('edit', ('PUT', '/id/')), ('delete', ('DELETE', '/id/')), - ('unsubscribe', ('GET', '/id//unsubscribe/')), + ('unsubscribe', ('GET', '/id//unsubscribe//')), ('moderate', ('GET', '/id///')), ('moderate', ('POST', '/id///')), ('like', ('POST', '/id//like')), @@ -494,18 +498,20 @@ class API(object): return resp """ - @api {get} /id/:id/key unsubscribe + @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 from replies to comment with id 13: - curl -X GET 'https://comments.example.com/id/13/unsubscribe/TODO-COMPUTE-HASH' + @apiExample {curl} Unsubscribe Alice from replies to comment with id 13: + curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/TODO-COMPUTE-HASH' @apiSuccessExample {html} Using GET: <!DOCTYPE html> @@ -523,13 +529,15 @@ class API(object): Yo """ - def unsubscribe(self, environ, request, id, key): + 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] != id: + if rv[0] != 'unsubscribe' or rv[1] != email: raise Forbidden item = self.comments.get(id) @@ -538,7 +546,7 @@ class API(object): raise NotFound with self.isso.lock: - self.comments.unsubscribe(id) + self.comments.unsubscribe(email, id) modal = ( "" From 717837b35af06f2267c5d8b733d29c9356eafa41 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Thu, 10 May 2018 20:59:27 +0200 Subject: [PATCH 12/18] Correct hash in 'unsubscribe' API example --- 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 175c346..366736f 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -511,7 +511,7 @@ class API(object): 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/TODO-COMPUTE-HASH' + curl -X GET 'https://comments.example.com/id/13/unsubscribe/alice@example.com/WyJ1bnN1YnNjcmliZSIsImFsaWNlQGV4YW1wbGUuY29tIl0.DdcH9w.Wxou-l22ySLFkKUs7RUHnoM8Kos' @apiSuccessExample {html} Using GET: <!DOCTYPE html> From 9b2a56e467ce44ffbf859d3e1d69c98143fd033e Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 3 Jun 2018 19:55:31 +0200 Subject: [PATCH 13/18] Introduce public-endpoint setting --- docs/docs/configuration/server.rst | 6 ++++++ isso/ext/notifications.py | 11 +++-------- share/isso.conf | 5 +++++ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/docs/configuration/server.rst b/docs/docs/configuration/server.rst index b856003..888f9c6 100644 --- a/docs/docs/configuration/server.rst +++ b/docs/docs/configuration/server.rst @@ -156,6 +156,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/ext/notifications.py b/isso/ext/notifications.py index 00f0a02..78516bb 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -75,12 +75,7 @@ class SMTP(object): 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] + self.public_endpoint = isso.conf.get("server", "public-endpoint") or local("host") # test SMTP connectivity try: @@ -129,7 +124,7 @@ class SMTP(object): rv.write("---\n") if admin: - uri = self.general_host + "/id/%i" % comment["id"] + uri = self.public_endpoint + "/id/%i" % comment["id"] key = self.isso.sign(comment["id"]) rv.write("Delete comment: %s\n" % (uri + "/delete/" + key)) @@ -138,7 +133,7 @@ class SMTP(object): rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) else: - uri = self.general_host + "/id/%i" % parent_comment["id"] + uri = self.public_endpoint + "/id/%i" % parent_comment["id"] key = self.isso.sign(('unsubscribe', recipient)) rv.write("Unsubscribe from this conversation: %s\n" % (uri + "/unsubscribe/" + quote(recipient) + "/" + key)) diff --git a/share/isso.conf b/share/isso.conf index 57a1155..16afce1 100644 --- a/share/isso.conf +++ b/share/isso.conf @@ -78,6 +78,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 From 3e45ccb7e590dd08ed235e908c97f52a98a4d958 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Sun, 3 Jun 2018 19:59:05 +0200 Subject: [PATCH 14/18] Fix whitespace issue --- 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 366736f..15b16e8 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -100,7 +100,7 @@ class API(object): 'mode', 'created', 'modified', 'likes', 'dislikes', 'hash', 'gravatar_image', 'notification']) # comment fields, that can be submitted - ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) + ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification']) VIEWS = [ ('fetch', ('GET', '/')), From 171fcfab7271cd1e89ed3ce4cf12b461ad33eba7 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Wed, 18 Jul 2018 21:24:21 +0200 Subject: [PATCH 15/18] Postpone notifications to users until comment has been approved by moderator --- isso/db/threads.py | 3 +++ isso/ext/notifications.py | 18 +++++++++++++----- isso/views/comments.py | 5 ++++- 3 files changed, 20 insertions(+), 6 deletions(-) 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 78516bb..dc521dc 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -97,7 +97,8 @@ class SMTP(object): 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, parent_comment, recipient=None, admin=False): @@ -141,7 +142,17 @@ class SMTP(object): rv.seek(0) return rv.read() - def notify(self, thread, comment): + def notify_new(self, thread, comment): + 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 "parent" in comment and comment["parent"] is not None: # Notify interested authors that a new comment is posted notified = [] @@ -157,9 +168,6 @@ class SMTP(object): self.sendmail(subject, body, thread, comment, to=email) notified.append(email) - body = self.format(thread, comment, None, admin=True) - self.sendmail(thread["title"], body, thread, comment) - def sendmail(self, subject, body, thread, comment, to=None): if uwsgi: uwsgi.spool({b"subject": subject.encode("utf-8"), diff --git a/isso/views/comments.py b/isso/views/comments.py index 15b16e8..3899fa1 100644 --- a/isso/views/comments.py +++ b/isso/views/comments.py @@ -623,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() From 1dd95d5aad6c9cc53db7e8ed5d7764ec497e62f2 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Tue, 24 Jul 2018 18:17:41 +0200 Subject: [PATCH 16/18] New setting general.reply-notifications --- isso/__init__.py | 5 ++++- isso/ext/notifications.py | 13 ++++++++----- share/isso-dev.conf | 1 + share/isso.conf | 5 +++++ 4 files changed, 18 insertions(+), 6 deletions(-) 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/ext/notifications.py b/isso/ext/notifications.py index dc521dc..0bf1ac1 100644 --- a/isso/ext/notifications.py +++ b/isso/ext/notifications.py @@ -76,6 +76,8 @@ class SMTP(object): 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: @@ -143,8 +145,9 @@ class SMTP(object): return rv.read() def notify_new(self, thread, comment): - body = self.format(thread, comment, None, admin=True) - self.sendmail(thread["title"], body, 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) @@ -153,7 +156,7 @@ class SMTP(object): self.notify_users(thread, comment) def notify_users(self, thread, comment): - if "parent" in comment and comment["parent"] is not None: + 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"]) @@ -226,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/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 16afce1..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 = From 18b1d1100768473609ae90d2864a647e1bb42557 Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Tue, 24 Jul 2018 19:54:04 +0200 Subject: [PATCH 17/18] Add client-side configuration setting reply-notifications --- isso/js/app/config.js | 1 + isso/js/app/isso.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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/isso.js b/isso/js/app/isso.js index e243349..b45cf01 100644 --- a/isso/js/app/isso.js +++ b/isso/js/app/isso.js @@ -42,7 +42,7 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n", // only display notification checkbox if email is filled in var email_edit = function() { - if ($("[name='email']", el).value.length > 0) { + if (config["reply-notifications"] && $("[name='email']", el).value.length > 0) { $(".notification-section", el).show(); } else { $(".notification-section", el).hide(); From d80f7c4224dd3c5cd00dbfe1300b25a72dcc866e Mon Sep 17 00:00:00 2001 From: Pelle Nilsson Date: Tue, 24 Jul 2018 20:01:19 +0200 Subject: [PATCH 18/18] Documentation for reply notifications --- docs/docs/configuration/client.rst | 5 +++++ docs/docs/configuration/server.rst | 8 ++++++++ 2 files changed, 13 insertions(+) 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 888f9c6..259d9fc 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.