Browse Source

Various improvements to commenting system

pull/4/head
Noah Petherbridge 2 years ago
parent
commit
1835300e33

+ 9
- 1
defaults.yml View File

@@ -74,6 +74,9 @@ rophako:
74 74
     # key. Do NOT use that one, it was just an example. Make your own!
75 75
     secret_key: 'for the love of Arceus, change this key!'
76 76
 
77
+    # How long the session key should last for (in days).
78
+    session_lifetime: 30
79
+
77 80
     # Password strength: number of iterations for bcrypt password.
78 81
     bcrypt_iterations: 12
79 82
 
@@ -141,10 +144,15 @@ rophako:
141 144
 
142 145
   comment:
143 146
     time_format: *DATE_FORMAT
147
+
144 148
     # We use Gravatar for comments if the user provides an e-mail address.
145 149
     # Specify the URL to a fallback image to use in case they don't have
146 150
     # a gravatar.
147
-    default_avatar:
151
+    default_avatar: ""
152
+
153
+    # The grace period window that users are allowed to modify or delete their
154
+    # own comments (in hours)
155
+    edit_period: 2
148 156
 
149 157
   wiki:
150 158
     default_page: Main Page

+ 4
- 0
rophako/app.py View File

@@ -80,6 +80,10 @@ Emoticons.load_theme()
80 80
 def before_request():
81 81
     """Called before all requests. Initialize global template variables."""
82 82
 
83
+    # Session lifetime.
84
+    app.permanent_session_lifetime = datetime.timedelta(days=Config.security.session_lifetime)
85
+    session.permanent = True
86
+
83 87
     # Default template vars.
84 88
     g.info = rophako.utils.default_vars()
85 89
 

+ 129
- 26
rophako/model/comment.py View File

@@ -3,13 +3,14 @@ from __future__ import unicode_literals, absolute_import
3 3
 
4 4
 """Commenting models."""
5 5
 
6
-from flask import url_for
6
+from flask import url_for, session
7
+from itsdangerous import URLSafeSerializer
7 8
 import time
8 9
 import hashlib
9 10
 import urllib
10 11
 import random
11
-import re
12 12
 import sys
13
+import uuid
13 14
 
14 15
 from rophako.settings import Config
15 16
 import rophako.jsondb as JsonDB
@@ -18,18 +19,39 @@ import rophako.model.emoticons as Emoticons
18 19
 from rophako.utils import send_email, render_markdown
19 20
 from rophako.log import logger
20 21
 
22
+def deletion_token():
23
+    """Retrieves the comment deletion token for the current user's session.
21 24
 
22
-def add_comment(thread, uid, name, subject, message, url, time, ip, image=None):
25
+    Deletion tokens are random strings saved with a comment's data that allows
26
+    its original commenter to delete or modify their comment on their own,
27
+    within a window of time configurable by the site owner
28
+    (in ``comment.edit_period``).
29
+
30
+    If the current session doesn't have a deletion token yet, this function
31
+    will generate and set one. Otherwise it returns the one set last time.
32
+    All comments posted by the same session would share the same deletion
33
+    token.
34
+    """
35
+    if not "comment_token" in session:
36
+        session["comment_token"] = str(uuid.uuid4())
37
+    return session.get("comment_token")
38
+
39
+
40
+def add_comment(thread, uid, name, subject, message, url, time, ip,
41
+    token=None, image=None):
23 42
     """Add a comment to a comment thread.
24 43
 
25
-    * uid is 0 if it's a guest post, otherwise the UID of the user.
26
-    * name is the commenter's name (if a guest)
27
-    * subject is for the e-mails that are sent out
28
-    * message is self explanatory.
29
-    * url is the URL where the comment can be read.
30
-    * time, epoch time of comment.
31
-    * ip is the IP address of the commenter.
32
-    * image is a Gravatar image URL etc.
44
+    Parameters:
45
+        thread (str): the unique comment thread name.
46
+        uid (int): 0 for guest posts, otherwise the UID of the logged-in user.
47
+        name (str): the commenter's name (if a guest)
48
+        subject (str)
49
+        message (str)
50
+        url (str): the URL where the comment can be read (i.e. the blog post)
51
+        time (int): epoch time of the comment.
52
+        ip (str): the user's IP address.
53
+        token (str): the user's session's comment deletion token.
54
+        image (str): the URL to a Gravatar image, if any.
33 55
     """
34 56
 
35 57
     # Get the comments for this thread.
@@ -48,6 +70,7 @@ def add_comment(thread, uid, name, subject, message, url, time, ip, image=None):
48 70
         message=message,
49 71
         time=time or int(time.time()),
50 72
         ip=ip,
73
+        token=token,
51 74
     )
52 75
     write_comments(thread, comments)
53 76
 
@@ -60,20 +83,25 @@ def add_comment(thread, uid, name, subject, message, url, time, ip, image=None):
60 83
     # Send the e-mail to the site admins.
61 84
     send_email(
62 85
         to=Config.site.notify_address,
63
-        subject="New comment: {}".format(subject),
86
+        subject="Comment Added: {}".format(subject),
64 87
         message="""{name} has left a comment on: {subject}
65 88
 
66 89
 {message}
67 90
 
68
-To view this comment, please go to {url}
91
+-----
69 92
 
70
-=====================
93
+To view this comment, please go to <{url}>
71 94
 
72
-This e-mail was automatically generated. Do not reply to it.""".format(
95
+Was this comment spam? [Delete it]({deletion_link}).""".format(
73 96
             name=name,
74 97
             subject=subject,
75 98
             message=message,
76 99
             url=url,
100
+            deletion_link=url_for("comment.quick_delete",
101
+                token=make_quick_delete_token(thread, cid),
102
+                url=url,
103
+                _external=True,
104
+            )
77 105
         ),
78 106
     )
79 107
 
@@ -88,28 +116,25 @@ This e-mail was automatically generated. Do not reply to it.""".format(
88 116
             subject="New Comment: {}".format(subject),
89 117
             message="""Hello,
90 118
 
91
-You are currently subscribed to the comment thread '{thread}', and somebody has
92
-just added a new comment!
93
-
94 119
 {name} has left a comment on: {subject}
95 120
 
96 121
 {message}
97 122
 
98
-To view this comment, please go to {url}
99
-
100
-=====================
123
+-----
101 124
 
102
-This e-mail was automatically generated. Do not reply to it.
103
-
104
-If you wish to unsubscribe from this comment thread, please visit the following
105
-URL: {unsub}""".format(
125
+To view this comment, please go to <{url}>""".format(
106 126
                 thread=thread,
107 127
                 name=name,
108 128
                 subject=subject,
109 129
                 message=message,
110 130
                 url=url,
111 131
                 unsub=unsub,
112
-            )
132
+            ),
133
+            footer="You received this e-mail because you subscribed to the "
134
+                "comment thread that this comment was added to. You may "
135
+                "[**unsubscribe**]({unsub}) if you like.".format(
136
+                unsub=unsub,
137
+            ),
113 138
         )
114 139
 
115 140
 
@@ -134,6 +159,84 @@ def delete_comment(thread, cid):
134 159
     write_comments(thread, comments)
135 160
 
136 161
 
162
+def make_quick_delete_token(thread, cid):
163
+    """Generate a unique tamper-proof token for quickly deleting comments.
164
+
165
+    This allows for an instant 'delete' link to be included in the notification
166
+    e-mail sent to the site admins, to delete obviously spammy comments
167
+    quickly.
168
+
169
+    It uses ``itsdangerous`` to create a unique token signed by the site's
170
+    secret key so that users can't forge their own tokens.
171
+
172
+    Parameters:
173
+        thread (str): comment thread name.
174
+        cid (str): unique comment ID.
175
+
176
+    Returns:
177
+        str
178
+    """
179
+    s = URLSafeSerializer(Config.security.secret_key)
180
+    return s.dumps(dict(
181
+        t=thread,
182
+        c=cid,
183
+    ))
184
+
185
+
186
+def validate_quick_delete_token(token):
187
+    """Validate and decode a quick delete token.
188
+
189
+    If the token is valid, returns a dict of the thread name and comment ID,
190
+    as keys ``t`` and ``c`` respectively.
191
+
192
+    If not valid, returns ``None``.
193
+    """
194
+    s = URLSafeSerializer(Config.security.secret_key)
195
+    try:
196
+        return s.loads(token)
197
+    except:
198
+        logger.exception("Failed to validate quick-delete token {}".format(token))
199
+        return None
200
+
201
+
202
+def is_editable(thread, cid, comment=None):
203
+    """Determine if the comment is editable by the end user.
204
+
205
+    A comment is editable to its own author (even guests) for a window defined
206
+    by the site owner. In this event, the user's session has their
207
+    'comment deletion token' that matches the comment's saved token, and the
208
+    comment was posted recently.
209
+
210
+    Site admins (any logged-in user) can always edit all comments.
211
+
212
+    Parameters:
213
+        thread (str): the unique comment thread name.
214
+        cid (str): the comment ID.
215
+        comment (dict): if you already have the comment object, you can provide
216
+            it here and save an extra DB lookup.
217
+
218
+    Returns:
219
+        bool: True if the user is logged in *OR* has a valid deletion token and
220
+            the comment is relatively new. Otherwise returns False.
221
+    """
222
+    # Logged in users can always do it.
223
+    if session["login"]:
224
+        return True
225
+
226
+    # Get the comment, or bail if not found.
227
+    if comment is None:
228
+        comment = get_comment(thread, cid)
229
+        if not comment:
230
+            return False
231
+
232
+    # Make sure the comment's token matches the user's, or bail.
233
+    if comment.get("token", "x") != deletion_token():
234
+        return False
235
+
236
+    # And finally, make sure the comment is new enough.
237
+    return time.time() - comment["time"] < 60*60*Config.comment.edit_period
238
+
239
+
137 240
 def count_comments(thread):
138 241
     """Count the comments on a thread."""
139 242
     comments = get_comments(thread)

+ 49
- 8
rophako/modules/comment/__init__.py View File

@@ -8,8 +8,7 @@ import time
8 8
 
9 9
 import rophako.model.user as User
10 10
 import rophako.model.comment as Comment
11
-from rophako.utils import (template, pretty_time, login_required, sanitize_name,
12
-    remote_addr)
11
+from rophako.utils import (template, pretty_time, sanitize_name, remote_addr)
13 12
 from rophako.plugin import load_plugin
14 13
 from rophako.settings import Config
15 14
 
@@ -42,9 +41,18 @@ def preview():
42 41
 
43 42
     # Gravatar?
44 43
     gravatar = Comment.gravatar(form["contact"])
44
+    if g.info["session"]["login"]:
45
+        form["name"] = g.info["session"]["name"]
46
+        gravatar = "/".join([
47
+            Config.photo.root_public,
48
+            User.get_picture(uid=g.info["session"]["uid"]),
49
+        ])
45 50
 
46 51
     # Are they submitting?
47 52
     if form["action"] == "submit":
53
+        # Make sure they have a deletion token in their session.
54
+        token = Comment.deletion_token()
55
+
48 56
         Comment.add_comment(
49 57
             thread=thread,
50 58
             uid=g.info["session"]["uid"],
@@ -55,6 +63,7 @@ def preview():
55 63
             subject=form["subject"],
56 64
             message=form["message"],
57 65
             url=form["url"],
66
+            token=token,
58 67
         )
59 68
 
60 69
         # Are we subscribing to the thread?
@@ -77,19 +86,45 @@ def preview():
77 86
 
78 87
 
79 88
 @mod.route("/delete/<thread>/<cid>")
80
-@login_required
81 89
 def delete(thread, cid):
82 90
     """Delete a comment."""
91
+    if not Comment.is_editable(thread, cid):
92
+        flash("Permission denied; maybe you need to log in?")
93
+        return redirect(url_for("account.login"))
94
+
83 95
     url = request.args.get("url")
84 96
     Comment.delete_comment(thread, cid)
85 97
     flash("Comment deleted!")
86 98
     return redirect(url or url_for("index"))
87 99
 
88 100
 
101
+@mod.route("/quickdelete/<token>")
102
+def quick_delete(token):
103
+    """Quick-delete a comment.
104
+
105
+    This is for the site admins: when a comment is posted, the admins' version
106
+    of the email contains a quick deletion link in case of spam. The ``token``
107
+    here is in relation to that. It's a signed hash via ``itsdangerous`` using
108
+    the site's secret key so that users can't forge their own tokens.
109
+    """
110
+    data = Comment.validate_quick_delete_token(token)
111
+    if data is None:
112
+        flash("Permission denied: token not valid.")
113
+        return redirect(url_for("index"))
114
+
115
+    url = request.args.get("url")
116
+    Comment.delete_comment(data["t"], data["c"])
117
+    flash("Comment has been quick-deleted!")
118
+    return redirect(url or url_for("index"))
119
+
120
+
89 121
 @mod.route("/edit/<thread>/<cid>", methods=["GET", "POST"])
90
-@login_required
91 122
 def edit(thread, cid):
92 123
     """Edit an existing comment."""
124
+    if not Comment.is_editable(thread, cid):
125
+        flash("Permission denied; maybe you need to log in?")
126
+        return redirect(url_for("account.login"))
127
+
93 128
     url = request.args.get("url")
94 129
     comment = Comment.get_comment(thread, cid)
95 130
     if not comment:
@@ -172,11 +207,14 @@ def unsubscribe():
172 207
 def partial_index(thread, subject, header=True, addable=True):
173 208
     """Partial template for including the index view of a comment thread.
174 209
 
175
-    * thread: unique name for the comment thread
176
-    * subject: subject name for the comment thread
177
-    * header: show the Comments h1 header
178
-    * addable: boolean, can new comments be added to the thread"""
210
+    Parameters:
211
+        thread (str): the unique name for the comment thread.
212
+        subject (str): subject name for the comment thread.
213
+        header (bool): show the 'Comments' H1 header.
214
+        addable (bool): can new comments be added to the thread?
215
+    """
179 216
 
217
+    # Get all the comments on this thread.
180 218
     comments = Comment.get_comments(thread)
181 219
 
182 220
     # Sort the comments by most recent on bottom.
@@ -200,6 +238,9 @@ def partial_index(thread, subject, header=True, addable=True):
200 238
         # Format the message for display.
201 239
         comment["formatted_message"] = Comment.format_message(comment["message"])
202 240
 
241
+        # Was this comment posted by the current user viewing it?
242
+        comment["editable"] = Comment.is_editable(thread, cid, comment)
243
+
203 244
         sorted_comments.append(comment)
204 245
 
205 246
     g.info["header"] = header

+ 9
- 3
rophako/modules/comment/templates/comment/index.inc.html View File

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

+ 2
- 2
rophako/modules/comment/templates/comment/preview.html View File

@@ -8,7 +8,7 @@ This is a preview of what your comment is going to look like once posted.<p>
8 8
 
9 9
 <div class="comment">
10 10
 	<div class="comment-author">
11
-		{% if contact %}
11
+		{% if gravatar %}
12 12
 			<img src="{{ gravatar }}" alt="Avatar" width="96" height="96">
13 13
 		{% else %}
14 14
 			<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>
33 33
 
34 34
 {% include "comment/form.inc.html" %}
35 35
 
36
-{% endblock %}
36
+{% endblock %}

+ 6
- 6
rophako/modules/contact/__init__.py View File

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

+ 38
- 2
rophako/utils.py View File

@@ -236,14 +236,47 @@ def parse_anchors(html):
236 236
     return toc
237 237
 
238 238
 
239
-def send_email(to, subject, message, sender=None, reply_to=None):
240
-    """Send an e-mail out."""
239
+def send_email(to, subject, message, header=None, footer=None, sender=None,
240
+               reply_to=None):
241
+    """Send a (markdown-formatted) e-mail out.
242
+
243
+    This will deliver an HTML-formatted e-mail (using the ``email.inc.html``
244
+    template) using the rendered Markdown contents of ``message`` and
245
+    ``footer``. It will also send a plain text version using the raw Markdown
246
+    formatting in case the user can't accept HTML.
247
+
248
+    Parameters:
249
+        to ([]str): list of addresses to send the message to.
250
+        subject (str): email subject and title.
251
+        message (str): the email body, in Markdown format.
252
+        header (str): the header text for the HTML email (plain text).
253
+        footer (str): optional email footer, in Markdown format. The default
254
+            footer is defined in the ``email.inc.html`` template.
255
+        sender (str): optional sender email address. Defaults to the one
256
+            specified in the site configuration.
257
+        reply_to (str): optional Reply-To address header.
258
+    """
241 259
     if sender is None:
242 260
         sender = Config.mail.sender
243 261
 
244 262
     if type(to) != list:
245 263
         to = [to]
246 264
 
265
+    # Render the Markdown bits.
266
+    if footer:
267
+        footer = render_markdown(footer)
268
+
269
+    # Default header matches the subject.
270
+    if not header:
271
+        header = subject
272
+
273
+    html_message = render_template("email.inc.html",
274
+        title=subject,
275
+        header=header,
276
+        message=render_markdown(message),
277
+        footer=footer,
278
+    )
279
+
247 280
     logger.info("Send email to {}".format(to))
248 281
     if Config.mail.method == "smtp":
249 282
         # Send mail with SMTP.
@@ -260,6 +293,9 @@ def send_email(to, subject, message, sender=None, reply_to=None):
260 293
             text = MIMEText(message, "plain", "utf-8")
261 294
             msg.attach(text)
262 295
 
296
+            html = MIMEText(html_message, "html", "utf-8")
297
+            msg.attach(html)
298
+
263 299
             # Send the e-mail.
264 300
             try:
265 301
                 server = smtplib.SMTP(Config.mail.server, Config.mail.port)

+ 43
- 0
rophako/www/email.inc.html View File

@@ -0,0 +1,43 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+	<meta charset="utf-8">
5
+	<meta name="viewport" content="width=device-width">
6
+	<meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+	<meta name="x-apple-disable-message-reformatting"><!-- Disable auto-scale in iOS 10 Mail -->
8
+	<title>{{ title }}</title>
9
+</head>
10
+<body width="100%" bgcolor="#FFFFFF" color="#000000" style="margin: 0; mso-line-height-rule: exactly;">
11
+
12
+<center>
13
+	<table width="90%" cellspacing="0" cellpadding="8" style="border: 1px solid #000000">
14
+		<tr>
15
+			<td align="left" valign="top" bgcolor="#C0C0C0">
16
+				<font face="Helvetica,Arial,Verdana-sans-serif" size="6" color="#000000">
17
+					<b>{{ header }}</b>
18
+				</font>
19
+			</td>
20
+		</tr>
21
+		<tr>
22
+			<td align="left" valign="top" bgcolor="#FEFEFE">
23
+				<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
24
+					{{ message|safe }}
25
+				</font>
26
+			</td>
27
+		</tr>
28
+		<tr>
29
+			<td align="left" valign="top" bgcolor="#C0C0C0">
30
+				<font face="Helvetica,Arial,Verdana-sans-serif" size="3" color="#000000">
31
+					{% if footer %}
32
+						{{ footer|safe }}
33
+					{% else %}
34
+						This e-mail was automatically generated; do not reply to it.
35
+					{% endif %}
36
+				</font>
37
+			</td>
38
+		</tr>
39
+	</table>
40
+</center>
41
+
42
+</body>
43
+</html>

Loading…
Cancel
Save