Skip to content
This repository has been archived by the owner on May 2, 2018. It is now read-only.

User-editable Comments, HTML Emails and Admin Quick-delete #4

Merged
merged 1 commit into from Jan 3, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 9 additions & 1 deletion defaults.yml
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions rophako/app.py
Expand Up @@ -80,6 +80,10 @@
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()

Expand Down
155 changes: 129 additions & 26 deletions rophako/model/comment.py
Expand Up @@ -3,13 +3,14 @@

"""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
Expand All @@ -18,18 +19,39 @@
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.
Expand All @@ -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)

Expand All @@ -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,
)
),
)

Expand All @@ -88,28 +116,25 @@ def add_comment(thread, uid, name, subject, message, url, time, ip, image=None):
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,
),
)


Expand All @@ -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)
Expand Down
57 changes: 49 additions & 8 deletions rophako/modules/comment/__init__.py
Expand Up @@ -8,8 +8,7 @@

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

Expand Down Expand Up @@ -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"],
Expand All @@ -55,6 +63,7 @@ def preview():
subject=form["subject"],
message=form["message"],
url=form["url"],
token=token,
)

# Are we subscribing to the thread?
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
12 changes: 9 additions & 3 deletions rophako/modules/comment/templates/comment/index.inc.html
Expand Up @@ -25,12 +25,18 @@ <h1>Comments</h1>
{{ 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>
Expand Down