Merge pull request #4 from kirsle/feature/comment-deletion

User-editable Comments, HTML Emails and Admin Quick-delete
This commit is contained in:
Noah 2017-01-02 19:59:17 -08:00 committed by GitHub
commit 84d5b4a2cd
9 changed files with 289 additions and 48 deletions

View File

@ -74,6 +74,9 @@ rophako:
# key. Do NOT use that one, it was just an example. Make your own! # key. Do NOT use that one, it was just an example. Make your own!
secret_key: 'for the love of Arceus, change this key!' 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. # Password strength: number of iterations for bcrypt password.
bcrypt_iterations: 12 bcrypt_iterations: 12
@ -141,10 +144,15 @@ rophako:
comment: comment:
time_format: *DATE_FORMAT time_format: *DATE_FORMAT
# We use Gravatar for comments if the user provides an e-mail address. # 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 # Specify the URL to a fallback image to use in case they don't have
# a gravatar. # 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: wiki:
default_page: Main Page default_page: Main Page

View File

@ -80,6 +80,10 @@ Emoticons.load_theme()
def before_request(): def before_request():
"""Called before all requests. Initialize global template variables.""" """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. # Default template vars.
g.info = rophako.utils.default_vars() g.info = rophako.utils.default_vars()

View File

@ -3,13 +3,14 @@ from __future__ import unicode_literals, absolute_import
"""Commenting models.""" """Commenting models."""
from flask import url_for from flask import url_for, session
from itsdangerous import URLSafeSerializer
import time import time
import hashlib import hashlib
import urllib import urllib
import random import random
import re
import sys import sys
import uuid
from rophako.settings import Config from rophako.settings import Config
import rophako.jsondb as JsonDB 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.utils import send_email, render_markdown
from rophako.log import logger 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. """Add a comment to a comment thread.
* uid is 0 if it's a guest post, otherwise the UID of the user. Parameters:
* name is the commenter's name (if a guest) thread (str): the unique comment thread name.
* subject is for the e-mails that are sent out uid (int): 0 for guest posts, otherwise the UID of the logged-in user.
* message is self explanatory. name (str): the commenter's name (if a guest)
* url is the URL where the comment can be read. subject (str)
* time, epoch time of comment. message (str)
* ip is the IP address of the commenter. url (str): the URL where the comment can be read (i.e. the blog post)
* image is a Gravatar image URL etc. 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. # 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, message=message,
time=time or int(time.time()), time=time or int(time.time()),
ip=ip, ip=ip,
token=token,
) )
write_comments(thread, comments) 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 the e-mail to the site admins.
send_email( send_email(
to=Config.site.notify_address, to=Config.site.notify_address,
subject="New comment: {}".format(subject), subject="Comment Added: {}".format(subject),
message="""{name} has left a comment on: {subject} message="""{name} has left a comment on: {subject}
{message} {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, name=name,
subject=subject, subject=subject,
message=message, message=message,
url=url, 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), subject="New Comment: {}".format(subject),
message="""Hello, 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} {name} has left a comment on: {subject}
{message} {message}
To view this comment, please go to {url} -----
===================== To view this comment, please go to <{url}>""".format(
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(
thread=thread, thread=thread,
name=name, name=name,
subject=subject, subject=subject,
message=message, message=message,
url=url, url=url,
unsub=unsub, 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) 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): def count_comments(thread):
"""Count the comments on a thread.""" """Count the comments on a thread."""
comments = get_comments(thread) comments = get_comments(thread)

View File

@ -8,8 +8,7 @@ import time
import rophako.model.user as User import rophako.model.user as User
import rophako.model.comment as Comment import rophako.model.comment as Comment
from rophako.utils import (template, pretty_time, login_required, sanitize_name, from rophako.utils import (template, pretty_time, sanitize_name, remote_addr)
remote_addr)
from rophako.plugin import load_plugin from rophako.plugin import load_plugin
from rophako.settings import Config from rophako.settings import Config
@ -42,9 +41,18 @@ def preview():
# Gravatar? # Gravatar?
gravatar = Comment.gravatar(form["contact"]) 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? # Are they submitting?
if form["action"] == "submit": if form["action"] == "submit":
# Make sure they have a deletion token in their session.
token = Comment.deletion_token()
Comment.add_comment( Comment.add_comment(
thread=thread, thread=thread,
uid=g.info["session"]["uid"], uid=g.info["session"]["uid"],
@ -55,6 +63,7 @@ def preview():
subject=form["subject"], subject=form["subject"],
message=form["message"], message=form["message"],
url=form["url"], url=form["url"],
token=token,
) )
# Are we subscribing to the thread? # Are we subscribing to the thread?
@ -77,19 +86,45 @@ def preview():
@mod.route("/delete/<thread>/<cid>") @mod.route("/delete/<thread>/<cid>")
@login_required
def delete(thread, cid): def delete(thread, cid):
"""Delete a comment.""" """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") url = request.args.get("url")
Comment.delete_comment(thread, cid) Comment.delete_comment(thread, cid)
flash("Comment deleted!") flash("Comment deleted!")
return redirect(url or url_for("index")) return redirect(url or url_for("index"))
@mod.route("/quickdelete/<token>")
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/<thread>/<cid>", methods=["GET", "POST"]) @mod.route("/edit/<thread>/<cid>", methods=["GET", "POST"])
@login_required
def edit(thread, cid): def edit(thread, cid):
"""Edit an existing comment.""" """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") url = request.args.get("url")
comment = Comment.get_comment(thread, cid) comment = Comment.get_comment(thread, cid)
if not comment: if not comment:
@ -172,11 +207,14 @@ def unsubscribe():
def partial_index(thread, subject, header=True, addable=True): def partial_index(thread, subject, header=True, addable=True):
"""Partial template for including the index view of a comment thread. """Partial template for including the index view of a comment thread.
* thread: unique name for the comment thread Parameters:
* subject: subject name for the comment thread thread (str): the unique name for the comment thread.
* header: show the Comments h1 header subject (str): subject name for the comment thread.
* addable: boolean, can new comments be added to the 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) comments = Comment.get_comments(thread)
# Sort the comments by most recent on bottom. # 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. # Format the message for display.
comment["formatted_message"] = Comment.format_message(comment["message"]) 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) sorted_comments.append(comment)
g.info["header"] = header g.info["header"] = header

View File

@ -25,12 +25,18 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
{{ comment["formatted_message"]|safe }} {{ comment["formatted_message"]|safe }}
<div class="clear"> <div class="clear">
{% if session["login"] or comment["editable"] %}
[
{% if session["login"] %} {% if session["login"] %}
[IP: {{ comment["ip"] }} IP: {{ comment["ip"] }}
{% else %}
<em class="comment-editable">You recently posted this</em>
{% endif %}
| |
<a href="{{ url_for('comment.edit', thread=thread, cid=comment['id'], url=url) }}">Edit</a> <a href="{{ url_for('comment.edit', thread=thread, cid=comment['id'], url=url) }}">Edit</a>
| |
<a href="{{ url_for('comment.delete', thread=thread, cid=comment['id'], url=url) }}" onclick="return window.confirm('Are you sure?')">Delete</a>] <a href="{{ url_for('comment.delete', thread=thread, cid=comment['id'], url=url) }}" onclick="return window.confirm('Are you sure?')">Delete</a>
]
{% endif %} {% endif %}
</div> </div>
</div><p> </div><p>

View File

@ -8,7 +8,7 @@ This is a preview of what your comment is going to look like once posted.<p>
<div class="comment"> <div class="comment">
<div class="comment-author"> <div class="comment-author">
{% if contact %} {% if gravatar %}
<img src="{{ gravatar }}" alt="Avatar" width="96" height="96"> <img src="{{ gravatar }}" alt="Avatar" width="96" height="96">
{% else %} {% else %}
<img src="/static/avatars/default.png" alt="guest" width="96" height="96"> <img src="/static/avatars/default.png" alt="guest" width="96" height="96">

View File

@ -48,12 +48,12 @@ def send():
subject="Contact Form on {}: {}".format(Config.site.site_name, subject), subject="Contact Form on {}: {}".format(Config.site.site_name, subject),
message="""A visitor to {site_name} has sent you a message! message="""A visitor to {site_name} has sent you a message!
IP Address: {ip} * IP Address: `{ip}`
User Agent: {ua} * User Agent: `{ua}`
Referrer: {referer} * Referrer: <{referer}>
Name: {name} * Name: {name}
E-mail: {email} * E-mail: <{email}>
Subject: {subject} * Subject: {subject}
{message}""".format( {message}""".format(
site_name=Config.site.site_name, site_name=Config.site.site_name,

View File

@ -236,14 +236,47 @@ def parse_anchors(html):
return toc return toc
def send_email(to, subject, message, sender=None, reply_to=None): def send_email(to, subject, message, header=None, footer=None, sender=None,
"""Send an e-mail out.""" 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: if sender is None:
sender = Config.mail.sender sender = Config.mail.sender
if type(to) != list: if type(to) != list:
to = [to] 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)) logger.info("Send email to {}".format(to))
if Config.mail.method == "smtp": if Config.mail.method == "smtp":
# Send mail with 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") text = MIMEText(message, "plain", "utf-8")
msg.attach(text) msg.attach(text)
html = MIMEText(html_message, "html", "utf-8")
msg.attach(html)
# Send the e-mail. # Send the e-mail.
try: try:
server = smtplib.SMTP(Config.mail.server, Config.mail.port) server = smtplib.SMTP(Config.mail.server, Config.mail.port)

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
<title>{{ title }}</title>
</head>
<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
<center>
<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
<b>{{ header }}</b>
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#FEFEFE">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
{{ message|safe }}
</font>
</td>
</tr>
<tr>
<td align="left" valign="top" bgcolor="#C0C0C0">
<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
{% if footer %}
{{ footer|safe }}
{% else %}
This e-mail was automatically generated; do not reply to it.
{% endif %}
</font>
</td>
</tr>
</table>
</center>
</body>
</html>