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