Reply notification (#443)
Merging @pellenilsson reply notification PR * Added reply notification for commenter * Removed debug info * Bugfix * Add notification column to database if needed * Make SMTP connections thread safe * Include link to comment in email notifications * Implement opt-out for email notifications * Fix faulty check for parent comment * Support notifications also for replies * Don't send notification when someone responds to his/her own comment * Make unsubscribe work with notifications for replies * Correct hash in 'unsubscribe' API example * Introduce public-endpoint setting * Fix whitespace issue * Postpone notifications to users until comment has been approved by moderator * New setting general.reply-notifications * Add client-side configuration setting reply-notifications * Documentation for reply notifications
This commit is contained in:
commit
53d5ad441c
@ -77,6 +77,11 @@ data-isso-require-email
|
|||||||
|
|
||||||
Set to `true` when spam guard is configured with `require-email = true`.
|
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
|
data-isso-max-comments-top and data-isso-max-comments-nested
|
||||||
------------------------------------------------------------
|
------------------------------------------------------------
|
||||||
|
|
||||||
|
@ -88,6 +88,14 @@ notify
|
|||||||
Send notifications via SMTP on new comments with activation (if
|
Send notifications via SMTP on new comments with activation (if
|
||||||
moderated) and deletion links.
|
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-file
|
||||||
Log console messages to file instead of standard out.
|
Log console messages to file instead of standard out.
|
||||||
|
|
||||||
@ -156,6 +164,12 @@ listen
|
|||||||
|
|
||||||
Does not apply for `uWSGI`.
|
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
|
||||||
reload application, when the source code has changed. Useful for
|
reload application, when the source code has changed. Useful for
|
||||||
development. Only works with the internal webserver.
|
development. Only works with the internal webserver.
|
||||||
|
@ -96,13 +96,16 @@ class Isso(object):
|
|||||||
super(Isso, self).__init__(conf)
|
super(Isso, self).__init__(conf)
|
||||||
|
|
||||||
subscribers = []
|
subscribers = []
|
||||||
|
smtp_backend = False
|
||||||
for backend in conf.getlist("general", "notify"):
|
for backend in conf.getlist("general", "notify"):
|
||||||
if backend == "stdout":
|
if backend == "stdout":
|
||||||
subscribers.append(Stdout(None))
|
subscribers.append(Stdout(None))
|
||||||
elif backend in ("smtp", "SMTP"):
|
elif backend in ("smtp", "SMTP"):
|
||||||
subscribers.append(SMTP(self))
|
smtp_backend = True
|
||||||
else:
|
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))
|
||||||
|
|
||||||
self.signal = ext.Signal(*subscribers)
|
self.signal = ext.Signal(*subscribers)
|
||||||
|
|
||||||
|
@ -239,6 +239,9 @@
|
|||||||
#fff 10px,
|
#fff 10px,
|
||||||
#fff 20px
|
#fff 20px
|
||||||
);
|
);
|
||||||
|
.isso-postbox > .form-wrapper > .notification-section {
|
||||||
|
display: none;
|
||||||
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
@media screen and (max-width:600px) {
|
@media screen and (max-width:600px) {
|
||||||
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {
|
.isso-postbox > .form-wrapper > .auth-section .input-wrapper {
|
||||||
|
@ -23,7 +23,7 @@ class Comments:
|
|||||||
'mode', # status of the comment 1 = valid, 2 = pending,
|
'mode', # status of the comment 1 = valid, 2 = pending,
|
||||||
# 4 = soft-deleted (cannot hard delete because of replies)
|
# 4 = soft-deleted (cannot hard delete because of replies)
|
||||||
'remote_addr', 'text', 'author', 'email', 'website',
|
'remote_addr', 'text', 'author', 'email', 'website',
|
||||||
'likes', 'dislikes', 'voters']
|
'likes', 'dislikes', 'voters', 'notification']
|
||||||
|
|
||||||
def __init__(self, db):
|
def __init__(self, db):
|
||||||
|
|
||||||
@ -33,7 +33,12 @@ class Comments:
|
|||||||
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
|
' tid REFERENCES threads(id), id INTEGER PRIMARY KEY, parent INTEGER,',
|
||||||
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
|
' created FLOAT NOT NULL, modified FLOAT, mode INTEGER, remote_addr VARCHAR,',
|
||||||
' text VARCHAR, author VARCHAR, email VARCHAR, website 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):
|
def add(self, uri, c):
|
||||||
"""
|
"""
|
||||||
@ -50,16 +55,16 @@ class Comments:
|
|||||||
'INSERT INTO comments (',
|
'INSERT INTO comments (',
|
||||||
' tid, parent,'
|
' tid, parent,'
|
||||||
' created, modified, mode, remote_addr,',
|
' created, modified, mode, remote_addr,',
|
||||||
' text, author, email, website, voters )',
|
' text, author, email, website, voters, notification)',
|
||||||
'SELECT',
|
'SELECT',
|
||||||
' threads.id, ?,',
|
' threads.id, ?,',
|
||||||
' ?, ?, ?, ?,',
|
' ?, ?, ?, ?,',
|
||||||
' ?, ?, ?, ?, ?',
|
' ?, ?, ?, ?, ?, ?',
|
||||||
'FROM threads WHERE threads.uri = ?;'], (
|
'FROM threads WHERE threads.uri = ?;'], (
|
||||||
c.get('parent'),
|
c.get('parent'),
|
||||||
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
|
c.get('created') or time.time(), None, c["mode"], c['remote_addr'],
|
||||||
c['text'], c.get('author'), c.get('email'), c.get('website'), buffer(
|
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)
|
uri)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -76,6 +81,15 @@ class Comments:
|
|||||||
' mode=1',
|
' mode=1',
|
||||||
'WHERE id=? AND mode=2'], (id, ))
|
'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):
|
def update(self, id, data):
|
||||||
"""
|
"""
|
||||||
Update comment :param:`id` with values from :param:`data` and return
|
Update comment :param:`id` with values from :param:`data` and return
|
||||||
|
@ -25,6 +25,9 @@ class Threads(object):
|
|||||||
def __getitem__(self, uri):
|
def __getitem__(self, uri):
|
||||||
return Thread(*self.db.execute("SELECT * FROM threads WHERE uri=?", (uri, )).fetchone())
|
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):
|
def new(self, uri, title):
|
||||||
self.db.execute(
|
self.db.execute(
|
||||||
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
|
"INSERT INTO threads (uri, title) VALUES (?, ?)", (uri, title))
|
||||||
|
@ -14,6 +14,11 @@ from email.utils import formatdate
|
|||||||
from email.header import Header
|
from email.header import Header
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
try:
|
||||||
|
from urllib.parse import quote
|
||||||
|
except ImportError:
|
||||||
|
from urllib import quote
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
logger = logging.getLogger("isso")
|
logger = logging.getLogger("isso")
|
||||||
|
|
||||||
@ -31,37 +36,10 @@ else:
|
|||||||
from _thread import start_new_thread
|
from _thread import start_new_thread
|
||||||
|
|
||||||
|
|
||||||
class SMTP(object):
|
class SMTPConnection(object):
|
||||||
|
|
||||||
def __init__(self, isso):
|
def __init__(self, conf):
|
||||||
|
self.conf = conf
|
||||||
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")
|
|
||||||
|
|
||||||
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 __enter__(self):
|
def __enter__(self):
|
||||||
klass = (smtplib.SMTP_SSL if self.conf.get(
|
klass = (smtplib.SMTP_SSL if self.conf.get(
|
||||||
@ -91,10 +69,40 @@ class SMTP(object):
|
|||||||
def __exit__(self, exc_type, exc_value, traceback):
|
def __exit__(self, exc_type, exc_value, traceback):
|
||||||
self.client.quit()
|
self.client.quit()
|
||||||
|
|
||||||
def __iter__(self):
|
class SMTP(object):
|
||||||
yield "comments.new:after-save", self.notify
|
|
||||||
|
|
||||||
def format(self, thread, comment):
|
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_new
|
||||||
|
yield "comments.activate", self.notify_activated
|
||||||
|
|
||||||
|
def format(self, thread, comment, parent_comment, recipient=None, admin=False):
|
||||||
|
|
||||||
rv = io.StringIO()
|
rv = io.StringIO()
|
||||||
|
|
||||||
@ -107,40 +115,74 @@ class SMTP(object):
|
|||||||
rv.write(comment["text"] + "\n")
|
rv.write(comment["text"] + "\n")
|
||||||
rv.write("\n")
|
rv.write("\n")
|
||||||
|
|
||||||
if comment["website"]:
|
if admin:
|
||||||
rv.write("User's URL: %s\n" % comment["website"])
|
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" %
|
rv.write("Link to comment: %s\n" %
|
||||||
(local("origin") + thread["uri"] + "#isso-%i" % comment["id"]))
|
(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"])
|
|
||||||
|
|
||||||
rv.write("---\n")
|
rv.write("---\n")
|
||||||
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
|
|
||||||
|
|
||||||
if comment["mode"] == 2:
|
if admin:
|
||||||
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
|
uri = self.public_endpoint + "/id/%i" % comment["id"]
|
||||||
|
key = self.isso.sign(comment["id"])
|
||||||
|
|
||||||
|
rv.write("Delete comment: %s\n" % (uri + "/delete/" + key))
|
||||||
|
|
||||||
|
if comment["mode"] == 2:
|
||||||
|
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key))
|
||||||
|
|
||||||
|
else:
|
||||||
|
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))
|
||||||
|
|
||||||
rv.seek(0)
|
rv.seek(0)
|
||||||
return rv.read()
|
return rv.read()
|
||||||
|
|
||||||
def notify(self, thread, comment):
|
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)
|
||||||
|
|
||||||
body = self.format(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:
|
if uwsgi:
|
||||||
uwsgi.spool({b"subject": thread["title"].encode("utf-8"),
|
uwsgi.spool({b"subject": subject.encode("utf-8"),
|
||||||
b"body": body.encode("utf-8")})
|
b"body": body.encode("utf-8"),
|
||||||
|
b"to": to})
|
||||||
else:
|
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")
|
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 = MIMEText(body, 'plain', 'utf-8')
|
||||||
msg['From'] = from_addr
|
msg['From'] = from_addr
|
||||||
@ -148,13 +190,13 @@ class SMTP(object):
|
|||||||
msg['Date'] = formatdate(localtime=True)
|
msg['Date'] = formatdate(localtime=True)
|
||||||
msg['Subject'] = Header(subject, 'utf-8')
|
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())
|
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):
|
for x in range(5):
|
||||||
try:
|
try:
|
||||||
self._sendmail(subject, body)
|
self._sendmail(subject, body, to)
|
||||||
except smtplib.SMTPConnectError:
|
except smtplib.SMTPConnectError:
|
||||||
time.sleep(60)
|
time.sleep(60)
|
||||||
else:
|
else:
|
||||||
@ -187,5 +229,5 @@ class Stdout(object):
|
|||||||
def _delete_comment(self, id):
|
def _delete_comment(self, id):
|
||||||
logger.info('comment %i deleted', id)
|
logger.info('comment %i deleted', id)
|
||||||
|
|
||||||
def _activate_comment(self, id):
|
def _activate_comment(self, thread, comment):
|
||||||
logger.info("comment %s activated" % id)
|
logger.info("comment %(id)s activated" % thread)
|
||||||
|
@ -7,6 +7,7 @@ define(function() {
|
|||||||
"reply-to-self": false,
|
"reply-to-self": false,
|
||||||
"require-email": false,
|
"require-email": false,
|
||||||
"require-author": false,
|
"require-author": false,
|
||||||
|
"reply-notifications": false,
|
||||||
"max-comments-top": "inf",
|
"max-comments-top": "inf",
|
||||||
"max-comments-nested": 5,
|
"max-comments-nested": 5,
|
||||||
"reveal-on-click": 5,
|
"reveal-on-click": 5,
|
||||||
|
@ -90,6 +90,8 @@ define(function() {
|
|||||||
this.focus = function() { node.focus() };
|
this.focus = function() { node.focus() };
|
||||||
this.scrollIntoView = function(args) { node.scrollIntoView(args) };
|
this.scrollIntoView = function(args) { node.scrollIntoView(args) };
|
||||||
|
|
||||||
|
this.checked = function() { return node.checked; };
|
||||||
|
|
||||||
this.setAttribute = function(key, value) { node.setAttribute(key, value) };
|
this.setAttribute = function(key, value) { node.setAttribute(key, value) };
|
||||||
this.getAttribute = function(key) { return node.getAttribute(key) };
|
this.getAttribute = function(key) { return node.getAttribute(key) };
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ define({
|
|||||||
"postbox-preview": "Preview",
|
"postbox-preview": "Preview",
|
||||||
"postbox-edit": "Edit",
|
"postbox-edit": "Edit",
|
||||||
"postbox-submit": "Submit",
|
"postbox-submit": "Submit",
|
||||||
|
"postbox-notification": "Subscribe to email notification of replies",
|
||||||
|
|
||||||
"num-comments": "One Comment\n{{ n }} Comments",
|
"num-comments": "One Comment\n{{ n }} Comments",
|
||||||
"no-comments": "No Comments Yet",
|
"no-comments": "No Comments Yet",
|
||||||
|
@ -6,6 +6,7 @@ define({
|
|||||||
"postbox-preview": "Aperçu",
|
"postbox-preview": "Aperçu",
|
||||||
"postbox-edit": "Éditer",
|
"postbox-edit": "Éditer",
|
||||||
"postbox-submit": "Soumettre",
|
"postbox-submit": "Soumettre",
|
||||||
|
"postbox-notification": "S'abonner aux notifications de réponses",
|
||||||
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
|
"num-comments": "{{ n }} commentaire\n{{ n }} commentaires",
|
||||||
"no-comments": "Aucun commentaire pour l'instant",
|
"no-comments": "Aucun commentaire pour l'instant",
|
||||||
"atom-feed": "Flux Atom",
|
"atom-feed": "Flux Atom",
|
||||||
|
@ -40,6 +40,17 @@ define(["app/dom", "app/utils", "app/config", "app/api", "app/jade", "app/i18n",
|
|||||||
return true;
|
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
|
// email is not optional if this config parameter is set
|
||||||
if (config["require-email"]) {
|
if (config["require-email"]) {
|
||||||
$("[name='email']", el).setAttribute("placeholder",
|
$("[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,
|
author: author, email: email, website: website,
|
||||||
text: utils.text($(".textarea", el).innerHTML),
|
text: utils.text($(".textarea", el).innerHTML),
|
||||||
parent: parent || null,
|
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) {
|
}).then(function(comment) {
|
||||||
$(".textarea", el).innerHTML = "";
|
$(".textarea", el).innerHTML = "";
|
||||||
$(".textarea", el).blur();
|
$(".textarea", el).blur();
|
||||||
|
@ -25,3 +25,7 @@ div(class='isso-postbox')
|
|||||||
p(class='post-action')
|
p(class='post-action')
|
||||||
input(type='button' name='edit'
|
input(type='button' name='edit'
|
||||||
value=i18n('postbox-edit'))
|
value=i18n('postbox-edit'))
|
||||||
|
section(class='notification-section')
|
||||||
|
label
|
||||||
|
input(type='checkbox' name='notification')
|
||||||
|
= i18n('postbox-notification')
|
||||||
|
@ -32,6 +32,10 @@ try:
|
|||||||
from urlparse import urlparse
|
from urlparse import urlparse
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
try:
|
||||||
|
from urllib import unquote
|
||||||
|
except ImportError:
|
||||||
|
from urllib.parse import unquote
|
||||||
try:
|
try:
|
||||||
from StringIO import StringIO
|
from StringIO import StringIO
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@ -93,28 +97,29 @@ def xhr(func):
|
|||||||
class API(object):
|
class API(object):
|
||||||
|
|
||||||
FIELDS = set(['id', 'parent', 'text', 'author', 'website',
|
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
|
# comment fields, that can be submitted
|
||||||
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title'])
|
ACCEPT = set(['text', 'author', 'website', 'email', 'parent', 'title', 'notification'])
|
||||||
|
|
||||||
VIEWS = [
|
VIEWS = [
|
||||||
('fetch', ('GET', '/')),
|
('fetch', ('GET', '/')),
|
||||||
('new', ('POST', '/new')),
|
('new', ('POST', '/new')),
|
||||||
('count', ('GET', '/count')),
|
('count', ('GET', '/count')),
|
||||||
('counts', ('POST', '/count')),
|
('counts', ('POST', '/count')),
|
||||||
('feed', ('GET', '/feed')),
|
('feed', ('GET', '/feed')),
|
||||||
('view', ('GET', '/id/<int:id>')),
|
('view', ('GET', '/id/<int:id>')),
|
||||||
('edit', ('PUT', '/id/<int:id>')),
|
('edit', ('PUT', '/id/<int:id>')),
|
||||||
('delete', ('DELETE', '/id/<int:id>')),
|
('delete', ('DELETE', '/id/<int:id>')),
|
||||||
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
|
||||||
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
||||||
('like', ('POST', '/id/<int:id>/like')),
|
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
|
||||||
('dislike', ('POST', '/id/<int:id>/dislike')),
|
('like', ('POST', '/id/<int:id>/like')),
|
||||||
('demo', ('GET', '/demo')),
|
('dislike', ('POST', '/id/<int:id>/dislike')),
|
||||||
('preview', ('POST', '/preview')),
|
('demo', ('GET', '/demo')),
|
||||||
('login', ('POST', '/login')),
|
('preview', ('POST', '/preview')),
|
||||||
('admin', ('GET', '/admin'))
|
('login', ('POST', '/login')),
|
||||||
|
('admin', ('GET', '/admin'))
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, isso, hasher):
|
def __init__(self, isso, hasher):
|
||||||
@ -492,6 +497,70 @@ class API(object):
|
|||||||
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
|
resp.headers.add("X-Set-Cookie", cookie("isso-%i" % id))
|
||||||
return resp
|
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 = (
|
||||||
|
"<!DOCTYPE html>"
|
||||||
|
"<html>"
|
||||||
|
"<head>"
|
||||||
|
" <title>Successfully unsubscribed</title>"
|
||||||
|
"</head>"
|
||||||
|
"<body>"
|
||||||
|
" <p>You have been unsubscribed from replies in the given conversation.</p>"
|
||||||
|
"</body>"
|
||||||
|
"</html>")
|
||||||
|
|
||||||
|
return Response(modal, 200, content_type="text/html")
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@api {post} /id/:id/:action/key moderate
|
@api {post} /id/:id/:action/key moderate
|
||||||
@apiGroup Comment
|
@apiGroup Comment
|
||||||
@ -554,9 +623,12 @@ class API(object):
|
|||||||
return Response(modal, 200, content_type="text/html")
|
return Response(modal, 200, content_type="text/html")
|
||||||
|
|
||||||
if action == "activate":
|
if action == "activate":
|
||||||
|
if item['mode'] == 1:
|
||||||
|
return Response("Already activated", 200)
|
||||||
with self.isso.lock:
|
with self.isso.lock:
|
||||||
self.comments.activate(id)
|
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)
|
return Response("Yo", 200)
|
||||||
elif action == "edit":
|
elif action == "edit":
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
@ -9,6 +9,7 @@ dbpath = /var/isso/comments.db
|
|||||||
host = http://isso-dev.local/
|
host = http://isso-dev.local/
|
||||||
max-age = 15m
|
max-age = 15m
|
||||||
notify = stdout
|
notify = stdout
|
||||||
|
reply-notifications = false
|
||||||
log-file = /var/log/isso.log
|
log-file = /var/log/isso.log
|
||||||
admin_password = strong_default_password_for_isso_admin
|
admin_password = strong_default_password_for_isso_admin
|
||||||
|
|
||||||
|
@ -43,6 +43,11 @@ max-age = 15m
|
|||||||
# moderated) and deletion links.
|
# moderated) and deletion links.
|
||||||
notify = stdout
|
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 console messages to file instead of standard output.
|
||||||
log-file =
|
log-file =
|
||||||
|
|
||||||
@ -78,6 +83,11 @@ purge-after = 30d
|
|||||||
# for details). Does not apply for uWSGI.
|
# for details). Does not apply for uWSGI.
|
||||||
listen = http://localhost:8080
|
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.
|
# reload application, when the source code has changed. Useful for development.
|
||||||
# Only works with the internal webserver.
|
# Only works with the internal webserver.
|
||||||
reload = off
|
reload = off
|
||||||
|
Loading…
Reference in New Issue
Block a user