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:
Benoît Latinier 2018-07-26 22:56:36 +02:00 committed by GitHub
commit 53d5ad441c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 269 additions and 81 deletions

View File

@ -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
------------------------------------------------------------ ------------------------------------------------------------

View File

@ -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.

View File

@ -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)

View File

@ -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 {

View File

@ -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

View File

@ -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))

View File

@ -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 admin:
if comment["website"]: if comment["website"]:
rv.write("User's URL: %s\n" % 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")
rv.write("---\n")
uri = self.general_host + "/id/%i" % comment["id"] if admin:
uri = self.public_endpoint + "/id/%i" % comment["id"]
key = self.isso.sign(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: if comment["mode"] == 2:
rv.write("Activate comment: %s\n" % (uri + "/activate/" + key)) 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)

View File

@ -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,

View File

@ -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) };

View File

@ -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",

View File

@ -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",

View File

@ -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();

View File

@ -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')

View File

@ -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,10 +97,10 @@ 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', '/')),
@ -107,6 +111,7 @@ class API(object):
('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>')),
('unsubscribe', ('GET', '/id/<int:id>/unsubscribe/<string:email>/<string:key>')),
('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')), ('moderate', ('GET', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')), ('moderate', ('POST', '/id/<int:id>/<any(edit,activate,delete):action>/<string:key>')),
('like', ('POST', '/id/<int:id>/like')), ('like', ('POST', '/id/<int:id>/like')),
@ -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:
&lt;!DOCTYPE html&gt;
&lt;html&gt;
&lt;head&gt;
&lt;script&gt;
if (confirm('Delete: Are you sure?')) {
xhr = new XMLHttpRequest;
xhr.open('POST', window.location.href);
xhr.send(null);
}
&lt;/script&gt;
@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()

View File

@ -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

View File

@ -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