Merge pull request #4 from kirsle/feature/comment-deletion
User-editable Comments, HTML Emails and Admin Quick-delete
This commit is contained in:
commit
84d5b4a2cd
10
defaults.yml
10
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
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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/<thread>/<cid>")
|
||||
@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/<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"])
|
||||
@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
|
||||
|
|
|
@ -25,12 +25,18 @@ There {% if comments|length == 1 %}is{% else %}are{% endif %}
|
|||
{{ comment["formatted_message"]|safe }}
|
||||
|
||||
<div class="clear">
|
||||
{% if session["login"] %}
|
||||
[IP: {{ comment["ip"] }}
|
||||
{% if session["login"] or comment["editable"] %}
|
||||
[
|
||||
{% if session["login"] %}
|
||||
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.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 %}
|
||||
</div>
|
||||
</div><p>
|
||||
|
|
|
@ -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-author">
|
||||
{% if contact %}
|
||||
{% if gravatar %}
|
||||
<img src="{{ gravatar }}" alt="Avatar" width="96" height="96">
|
||||
{% else %}
|
||||
<img src="/static/avatars/default.png" alt="guest" width="96" height="96">
|
||||
|
@ -33,4 +33,4 @@ This is a preview of what your comment is going to look like once posted.<p>
|
|||
|
||||
{% include "comment/form.inc.html" %}
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
43
rophako/www/email.inc.html
Normal file
43
rophako/www/email.inc.html
Normal 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>
|
Loading…
Reference in New Issue
Block a user