From 1835300e3368fa503bf904b06db614415b40d76d Mon Sep 17 00:00:00 2001 From: Noah Petherbridge Date: Mon, 2 Jan 2017 19:49:47 -0800 Subject: [PATCH] Various improvements to commenting system --- defaults.yml | 10 +- rophako/app.py | 4 + rophako/model/comment.py | 155 +++++++++++++++--- rophako/modules/comment/__init__.py | 57 ++++++- .../comment/templates/comment/index.inc.html | 12 +- .../comment/templates/comment/preview.html | 4 +- rophako/modules/contact/__init__.py | 12 +- rophako/utils.py | 40 ++++- rophako/www/email.inc.html | 43 +++++ 9 files changed, 289 insertions(+), 48 deletions(-) create mode 100644 rophako/www/email.inc.html diff --git a/defaults.yml b/defaults.yml index ca04e82..16f6fe8 100644 --- a/defaults.yml +++ b/defaults.yml @@ -74,6 +74,9 @@ rophako: # key. Do NOT use that one, it was just an example. Make your own! secret_key: 'for the love of Arceus, change this key!' + # How long the session key should last for (in days). + session_lifetime: 30 + # Password strength: number of iterations for bcrypt password. bcrypt_iterations: 12 @@ -141,10 +144,15 @@ rophako: comment: time_format: *DATE_FORMAT + # We use Gravatar for comments if the user provides an e-mail address. # Specify the URL to a fallback image to use in case they don't have # a gravatar. - default_avatar: + default_avatar: "" + + # The grace period window that users are allowed to modify or delete their + # own comments (in hours) + edit_period: 2 wiki: default_page: Main Page diff --git a/rophako/app.py b/rophako/app.py index df28fe1..1db0984 100644 --- a/rophako/app.py +++ b/rophako/app.py @@ -80,6 +80,10 @@ Emoticons.load_theme() def before_request(): """Called before all requests. Initialize global template variables.""" + # Session lifetime. + app.permanent_session_lifetime = datetime.timedelta(days=Config.security.session_lifetime) + session.permanent = True + # Default template vars. g.info = rophako.utils.default_vars() diff --git a/rophako/model/comment.py b/rophako/model/comment.py index 36939be..a8fa05b 100644 --- a/rophako/model/comment.py +++ b/rophako/model/comment.py @@ -3,13 +3,14 @@ from __future__ import unicode_literals, absolute_import """Commenting models.""" -from flask import url_for +from flask import url_for, session +from itsdangerous import URLSafeSerializer import time import hashlib import urllib import random -import re import sys +import uuid from rophako.settings import Config import rophako.jsondb as JsonDB @@ -18,18 +19,39 @@ import rophako.model.emoticons as Emoticons from rophako.utils import send_email, render_markdown from rophako.log import logger +def deletion_token(): + """Retrieves the comment deletion token for the current user's session. -def add_comment(thread, uid, name, subject, message, url, time, ip, image=None): + Deletion tokens are random strings saved with a comment's data that allows + its original commenter to delete or modify their comment on their own, + within a window of time configurable by the site owner + (in ``comment.edit_period``). + + If the current session doesn't have a deletion token yet, this function + will generate and set one. Otherwise it returns the one set last time. + All comments posted by the same session would share the same deletion + token. + """ + if not "comment_token" in session: + session["comment_token"] = str(uuid.uuid4()) + return session.get("comment_token") + + +def add_comment(thread, uid, name, subject, message, url, time, ip, + token=None, image=None): """Add a comment to a comment thread. - * uid is 0 if it's a guest post, otherwise the UID of the user. - * name is the commenter's name (if a guest) - * subject is for the e-mails that are sent out - * message is self explanatory. - * url is the URL where the comment can be read. - * time, epoch time of comment. - * ip is the IP address of the commenter. - * image is a Gravatar image URL etc. + Parameters: + thread (str): the unique comment thread name. + uid (int): 0 for guest posts, otherwise the UID of the logged-in user. + name (str): the commenter's name (if a guest) + subject (str) + message (str) + url (str): the URL where the comment can be read (i.e. the blog post) + time (int): epoch time of the comment. + ip (str): the user's IP address. + token (str): the user's session's comment deletion token. + image (str): the URL to a Gravatar image, if any. """ # Get the comments for this thread. @@ -48,6 +70,7 @@ def add_comment(thread, uid, name, subject, message, url, time, ip, image=None): message=message, time=time or int(time.time()), ip=ip, + token=token, ) write_comments(thread, comments) @@ -60,20 +83,25 @@ def add_comment(thread, uid, name, subject, message, url, time, ip, image=None): # Send the e-mail to the site admins. send_email( to=Config.site.notify_address, - subject="New comment: {}".format(subject), + subject="Comment Added: {}".format(subject), message="""{name} has left a comment on: {subject} {message} -To view this comment, please go to {url} +----- -===================== +To view this comment, please go to <{url}> -This e-mail was automatically generated. Do not reply to it.""".format( +Was this comment spam? [Delete it]({deletion_link}).""".format( name=name, subject=subject, message=message, url=url, + deletion_link=url_for("comment.quick_delete", + token=make_quick_delete_token(thread, cid), + url=url, + _external=True, + ) ), ) @@ -88,28 +116,25 @@ This e-mail was automatically generated. Do not reply to it.""".format( subject="New Comment: {}".format(subject), message="""Hello, -You are currently subscribed to the comment thread '{thread}', and somebody has -just added a new comment! - {name} has left a comment on: {subject} {message} -To view this comment, please go to {url} +----- -===================== - -This e-mail was automatically generated. Do not reply to it. - -If you wish to unsubscribe from this comment thread, please visit the following -URL: {unsub}""".format( +To view this comment, please go to <{url}>""".format( thread=thread, name=name, subject=subject, message=message, url=url, unsub=unsub, - ) + ), + footer="You received this e-mail because you subscribed to the " + "comment thread that this comment was added to. You may " + "[**unsubscribe**]({unsub}) if you like.".format( + unsub=unsub, + ), ) @@ -134,6 +159,84 @@ def delete_comment(thread, cid): write_comments(thread, comments) +def make_quick_delete_token(thread, cid): + """Generate a unique tamper-proof token for quickly deleting comments. + + This allows for an instant 'delete' link to be included in the notification + e-mail sent to the site admins, to delete obviously spammy comments + quickly. + + It uses ``itsdangerous`` to create a unique token signed by the site's + secret key so that users can't forge their own tokens. + + Parameters: + thread (str): comment thread name. + cid (str): unique comment ID. + + Returns: + str + """ + s = URLSafeSerializer(Config.security.secret_key) + return s.dumps(dict( + t=thread, + c=cid, + )) + + +def validate_quick_delete_token(token): + """Validate and decode a quick delete token. + + If the token is valid, returns a dict of the thread name and comment ID, + as keys ``t`` and ``c`` respectively. + + If not valid, returns ``None``. + """ + s = URLSafeSerializer(Config.security.secret_key) + try: + return s.loads(token) + except: + logger.exception("Failed to validate quick-delete token {}".format(token)) + return None + + +def is_editable(thread, cid, comment=None): + """Determine if the comment is editable by the end user. + + A comment is editable to its own author (even guests) for a window defined + by the site owner. In this event, the user's session has their + 'comment deletion token' that matches the comment's saved token, and the + comment was posted recently. + + Site admins (any logged-in user) can always edit all comments. + + Parameters: + thread (str): the unique comment thread name. + cid (str): the comment ID. + comment (dict): if you already have the comment object, you can provide + it here and save an extra DB lookup. + + Returns: + bool: True if the user is logged in *OR* has a valid deletion token and + the comment is relatively new. Otherwise returns False. + """ + # Logged in users can always do it. + if session["login"]: + return True + + # Get the comment, or bail if not found. + if comment is None: + comment = get_comment(thread, cid) + if not comment: + return False + + # Make sure the comment's token matches the user's, or bail. + if comment.get("token", "x") != deletion_token(): + return False + + # And finally, make sure the comment is new enough. + return time.time() - comment["time"] < 60*60*Config.comment.edit_period + + def count_comments(thread): """Count the comments on a thread.""" comments = get_comments(thread) diff --git a/rophako/modules/comment/__init__.py b/rophako/modules/comment/__init__.py index 42bf4d8..147ece8 100644 --- a/rophako/modules/comment/__init__.py +++ b/rophako/modules/comment/__init__.py @@ -8,8 +8,7 @@ import time import rophako.model.user as User import rophako.model.comment as Comment -from rophako.utils import (template, pretty_time, login_required, sanitize_name, - remote_addr) +from rophako.utils import (template, pretty_time, sanitize_name, remote_addr) from rophako.plugin import load_plugin from rophako.settings import Config @@ -42,9 +41,18 @@ def preview(): # Gravatar? gravatar = Comment.gravatar(form["contact"]) + if g.info["session"]["login"]: + form["name"] = g.info["session"]["name"] + gravatar = "/".join([ + Config.photo.root_public, + User.get_picture(uid=g.info["session"]["uid"]), + ]) # Are they submitting? if form["action"] == "submit": + # Make sure they have a deletion token in their session. + token = Comment.deletion_token() + Comment.add_comment( thread=thread, uid=g.info["session"]["uid"], @@ -55,6 +63,7 @@ def preview(): subject=form["subject"], message=form["message"], url=form["url"], + token=token, ) # Are we subscribing to the thread? @@ -77,19 +86,45 @@ def preview(): @mod.route("/delete//") -@login_required def delete(thread, cid): """Delete a comment.""" + if not Comment.is_editable(thread, cid): + flash("Permission denied; maybe you need to log in?") + return redirect(url_for("account.login")) + url = request.args.get("url") Comment.delete_comment(thread, cid) flash("Comment deleted!") return redirect(url or url_for("index")) +@mod.route("/quickdelete/") +def quick_delete(token): + """Quick-delete a comment. + + This is for the site admins: when a comment is posted, the admins' version + of the email contains a quick deletion link in case of spam. The ``token`` + here is in relation to that. It's a signed hash via ``itsdangerous`` using + the site's secret key so that users can't forge their own tokens. + """ + data = Comment.validate_quick_delete_token(token) + if data is None: + flash("Permission denied: token not valid.") + return redirect(url_for("index")) + + url = request.args.get("url") + Comment.delete_comment(data["t"], data["c"]) + flash("Comment has been quick-deleted!") + return redirect(url or url_for("index")) + + @mod.route("/edit//", methods=["GET", "POST"]) -@login_required def edit(thread, cid): """Edit an existing comment.""" + if not Comment.is_editable(thread, cid): + flash("Permission denied; maybe you need to log in?") + return redirect(url_for("account.login")) + url = request.args.get("url") comment = Comment.get_comment(thread, cid) if not comment: @@ -172,11 +207,14 @@ def unsubscribe(): def partial_index(thread, subject, header=True, addable=True): """Partial template for including the index view of a comment thread. - * thread: unique name for the comment thread - * subject: subject name for the comment thread - * header: show the Comments h1 header - * addable: boolean, can new comments be added to the thread""" + Parameters: + thread (str): the unique name for the comment thread. + subject (str): subject name for the comment thread. + header (bool): show the 'Comments' H1 header. + addable (bool): can new comments be added to the thread? + """ + # Get all the comments on this thread. comments = Comment.get_comments(thread) # Sort the comments by most recent on bottom. @@ -200,6 +238,9 @@ def partial_index(thread, subject, header=True, addable=True): # Format the message for display. comment["formatted_message"] = Comment.format_message(comment["message"]) + # Was this comment posted by the current user viewing it? + comment["editable"] = Comment.is_editable(thread, cid, comment) + sorted_comments.append(comment) g.info["header"] = header diff --git a/rophako/modules/comment/templates/comment/index.inc.html b/rophako/modules/comment/templates/comment/index.inc.html index 2ded0db..60af4a4 100644 --- a/rophako/modules/comment/templates/comment/index.inc.html +++ b/rophako/modules/comment/templates/comment/index.inc.html @@ -25,12 +25,18 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %} {{ comment["formatted_message"]|safe }}
- {% if session["login"] %} - [IP: {{ comment["ip"] }} + {% if session["login"] or comment["editable"] %} + [ + {% if session["login"] %} + IP: {{ comment["ip"] }} + {% else %} + You recently posted this + {% endif %} | Edit | - Delete] + Delete + ] {% endif %}

diff --git a/rophako/modules/comment/templates/comment/preview.html b/rophako/modules/comment/templates/comment/preview.html index b4e14be..4d3e261 100644 --- a/rophako/modules/comment/templates/comment/preview.html +++ b/rophako/modules/comment/templates/comment/preview.html @@ -8,7 +8,7 @@ This is a preview of what your comment is going to look like once posted.

- {% if contact %} + {% if gravatar %} Avatar {% else %} guest @@ -33,4 +33,4 @@ This is a preview of what your comment is going to look like once posted.

{% include "comment/form.inc.html" %} -{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/rophako/modules/contact/__init__.py b/rophako/modules/contact/__init__.py index 590b2b5..73c9a14 100644 --- a/rophako/modules/contact/__init__.py +++ b/rophako/modules/contact/__init__.py @@ -48,12 +48,12 @@ def send(): subject="Contact Form on {}: {}".format(Config.site.site_name, subject), message="""A visitor to {site_name} has sent you a message! -IP Address: {ip} -User Agent: {ua} -Referrer: {referer} -Name: {name} -E-mail: {email} -Subject: {subject} +* IP Address: `{ip}` +* User Agent: `{ua}` +* Referrer: <{referer}> +* Name: {name} +* E-mail: <{email}> +* Subject: {subject} {message}""".format( site_name=Config.site.site_name, diff --git a/rophako/utils.py b/rophako/utils.py index 4c1240c..a452770 100644 --- a/rophako/utils.py +++ b/rophako/utils.py @@ -236,14 +236,47 @@ def parse_anchors(html): return toc -def send_email(to, subject, message, sender=None, reply_to=None): - """Send an e-mail out.""" +def send_email(to, subject, message, header=None, footer=None, sender=None, + reply_to=None): + """Send a (markdown-formatted) e-mail out. + + This will deliver an HTML-formatted e-mail (using the ``email.inc.html`` + template) using the rendered Markdown contents of ``message`` and + ``footer``. It will also send a plain text version using the raw Markdown + formatting in case the user can't accept HTML. + + Parameters: + to ([]str): list of addresses to send the message to. + subject (str): email subject and title. + message (str): the email body, in Markdown format. + header (str): the header text for the HTML email (plain text). + footer (str): optional email footer, in Markdown format. The default + footer is defined in the ``email.inc.html`` template. + sender (str): optional sender email address. Defaults to the one + specified in the site configuration. + reply_to (str): optional Reply-To address header. + """ if sender is None: sender = Config.mail.sender if type(to) != list: to = [to] + # Render the Markdown bits. + if footer: + footer = render_markdown(footer) + + # Default header matches the subject. + if not header: + header = subject + + html_message = render_template("email.inc.html", + title=subject, + header=header, + message=render_markdown(message), + footer=footer, + ) + logger.info("Send email to {}".format(to)) if Config.mail.method == "smtp": # Send mail with SMTP. @@ -260,6 +293,9 @@ def send_email(to, subject, message, sender=None, reply_to=None): text = MIMEText(message, "plain", "utf-8") msg.attach(text) + html = MIMEText(html_message, "html", "utf-8") + msg.attach(html) + # Send the e-mail. try: server = smtplib.SMTP(Config.mail.server, Config.mail.port) diff --git a/rophako/www/email.inc.html b/rophako/www/email.inc.html new file mode 100644 index 0000000..608bc7b --- /dev/null +++ b/rophako/www/email.inc.html @@ -0,0 +1,43 @@ + + + + + + + + {{ title }} + + + +

+ + + + + + + + + + +
+ + {{ header }} + +
+ + {{ message|safe }} + +
+ + {% if footer %} + {{ footer|safe }} + {% else %} + This e-mail was automatically generated; do not reply to it. + {% endif %} + +
+
+ + +