A Python content management system designed for kirsle.net featuring a blog, comments and photo albums. https://rophako.kirsle.net/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

comment.py 10KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, absolute_import
  3. """Commenting models."""
  4. from flask import url_for, session
  5. from itsdangerous import URLSafeSerializer
  6. import time
  7. import hashlib
  8. import urllib
  9. import random
  10. import sys
  11. import uuid
  12. from rophako.settings import Config
  13. import rophako.jsondb as JsonDB
  14. import rophako.model.user as User
  15. import rophako.model.emoticons as Emoticons
  16. from rophako.utils import send_email, render_markdown
  17. from rophako.log import logger
  18. def deletion_token():
  19. """Retrieves the comment deletion token for the current user's session.
  20. Deletion tokens are random strings saved with a comment's data that allows
  21. its original commenter to delete or modify their comment on their own,
  22. within a window of time configurable by the site owner
  23. (in ``comment.edit_period``).
  24. If the current session doesn't have a deletion token yet, this function
  25. will generate and set one. Otherwise it returns the one set last time.
  26. All comments posted by the same session would share the same deletion
  27. token.
  28. """
  29. if not "comment_token" in session:
  30. session["comment_token"] = str(uuid.uuid4())
  31. return session.get("comment_token")
  32. def add_comment(thread, uid, name, subject, message, url, time, ip,
  33. token=None, image=None):
  34. """Add a comment to a comment thread.
  35. Parameters:
  36. thread (str): the unique comment thread name.
  37. uid (int): 0 for guest posts, otherwise the UID of the logged-in user.
  38. name (str): the commenter's name (if a guest)
  39. subject (str)
  40. message (str)
  41. url (str): the URL where the comment can be read (i.e. the blog post)
  42. time (int): epoch time of the comment.
  43. ip (str): the user's IP address.
  44. token (str): the user's session's comment deletion token.
  45. image (str): the URL to a Gravatar image, if any.
  46. """
  47. # Get the comments for this thread.
  48. comments = get_comments(thread)
  49. # Make up a unique ID for the comment.
  50. cid = random_hash()
  51. while cid in comments:
  52. cid = random_hash()
  53. # Add the comment.
  54. comments[cid] = dict(
  55. uid=uid,
  56. name=name or "Anonymous",
  57. image=image or "",
  58. message=message,
  59. time=time or int(time.time()),
  60. ip=ip,
  61. token=token,
  62. )
  63. write_comments(thread, comments)
  64. # Get info about the commenter.
  65. if uid > 0:
  66. user = User.get_user(uid=uid)
  67. if user:
  68. name = user["name"]
  69. # Send the e-mail to the site admins.
  70. send_email(
  71. to=Config.site.notify_address,
  72. subject="Comment Added: {}".format(subject),
  73. message="""{name} has left a comment on: {subject}
  74. {message}
  75. -----
  76. To view this comment, please go to <{url}>
  77. Was this comment spam? [Delete it]({deletion_link}).""".format(
  78. name=name,
  79. subject=subject,
  80. message=message,
  81. url=url,
  82. deletion_link=url_for("comment.quick_delete",
  83. token=make_quick_delete_token(thread, cid),
  84. url=url,
  85. _external=True,
  86. )
  87. ),
  88. )
  89. # Notify any subscribers.
  90. subs = get_subscribers(thread)
  91. for sub in subs.keys():
  92. # Make the unsubscribe link.
  93. unsub = url_for("comment.unsubscribe", thread=thread, who=sub, _external=True)
  94. send_email(
  95. to=sub,
  96. subject="New Comment: {}".format(subject),
  97. message="""Hello,
  98. {name} has left a comment on: {subject}
  99. {message}
  100. -----
  101. To view this comment, please go to <{url}>""".format(
  102. thread=thread,
  103. name=name,
  104. subject=subject,
  105. message=message,
  106. url=url,
  107. unsub=unsub,
  108. ),
  109. footer="You received this e-mail because you subscribed to the "
  110. "comment thread that this comment was added to. You may "
  111. "[**unsubscribe**]({unsub}) if you like.".format(
  112. unsub=unsub,
  113. ),
  114. )
  115. def get_comment(thread, cid):
  116. """Look up a specific comment."""
  117. comments = get_comments(thread)
  118. return comments.get(cid, None)
  119. def update_comment(thread, cid, data):
  120. """Update the data for a comment."""
  121. comments = get_comments(thread)
  122. if cid in comments:
  123. comments[cid].update(data)
  124. write_comments(thread, comments)
  125. def delete_comment(thread, cid):
  126. """Delete a comment from a thread."""
  127. comments = get_comments(thread)
  128. if cid in comments:
  129. del comments[cid]
  130. write_comments(thread, comments)
  131. def make_quick_delete_token(thread, cid):
  132. """Generate a unique tamper-proof token for quickly deleting comments.
  133. This allows for an instant 'delete' link to be included in the notification
  134. e-mail sent to the site admins, to delete obviously spammy comments
  135. quickly.
  136. It uses ``itsdangerous`` to create a unique token signed by the site's
  137. secret key so that users can't forge their own tokens.
  138. Parameters:
  139. thread (str): comment thread name.
  140. cid (str): unique comment ID.
  141. Returns:
  142. str
  143. """
  144. s = URLSafeSerializer(Config.security.secret_key)
  145. return s.dumps(dict(
  146. t=thread,
  147. c=cid,
  148. ))
  149. def validate_quick_delete_token(token):
  150. """Validate and decode a quick delete token.
  151. If the token is valid, returns a dict of the thread name and comment ID,
  152. as keys ``t`` and ``c`` respectively.
  153. If not valid, returns ``None``.
  154. """
  155. s = URLSafeSerializer(Config.security.secret_key)
  156. try:
  157. return s.loads(token)
  158. except:
  159. logger.exception("Failed to validate quick-delete token {}".format(token))
  160. return None
  161. def is_editable(thread, cid, comment=None):
  162. """Determine if the comment is editable by the end user.
  163. A comment is editable to its own author (even guests) for a window defined
  164. by the site owner. In this event, the user's session has their
  165. 'comment deletion token' that matches the comment's saved token, and the
  166. comment was posted recently.
  167. Site admins (any logged-in user) can always edit all comments.
  168. Parameters:
  169. thread (str): the unique comment thread name.
  170. cid (str): the comment ID.
  171. comment (dict): if you already have the comment object, you can provide
  172. it here and save an extra DB lookup.
  173. Returns:
  174. bool: True if the user is logged in *OR* has a valid deletion token and
  175. the comment is relatively new. Otherwise returns False.
  176. """
  177. # Logged in users can always do it.
  178. if session["login"]:
  179. return True
  180. # Get the comment, or bail if not found.
  181. if comment is None:
  182. comment = get_comment(thread, cid)
  183. if not comment:
  184. return False
  185. # Make sure the comment's token matches the user's, or bail.
  186. if comment.get("token", "x") != deletion_token():
  187. return False
  188. # And finally, make sure the comment is new enough.
  189. return time.time() - comment["time"] < 60*60*Config.comment.edit_period
  190. def count_comments(thread):
  191. """Count the comments on a thread."""
  192. comments = get_comments(thread)
  193. return len(comments.keys())
  194. def add_subscriber(thread, email):
  195. """Add a subscriber to a thread."""
  196. if not "@" in email:
  197. return
  198. # Sanity check: only subscribe to threads that exist.
  199. if not JsonDB.exists("comments/threads/{}".format(thread)):
  200. return
  201. logger.info("Subscribe e-mail {} to thread {}".format(email, thread))
  202. subs = get_subscribers(thread)
  203. subs[email] = int(time.time())
  204. write_subscribers(thread, subs)
  205. def unsubscribe(thread, email):
  206. """Unsubscribe an e-mail address from a thread.
  207. If `thread` is `*`, the e-mail is unsubscribed from all threads."""
  208. # Which threads to unsubscribe from?
  209. threads = []
  210. if thread == "*":
  211. threads = JsonDB.list_docs("comments/subscribers")
  212. else:
  213. threads = [thread]
  214. # Remove them as a subscriber.
  215. for thread in threads:
  216. if JsonDB.exists("comments/subscribers/{}".format(thread)):
  217. logger.info("Unsubscribe e-mail address {} from comment thread {}".format(email, thread))
  218. db = get_subscribers(thread)
  219. del db[email]
  220. write_subscribers(thread, db)
  221. def format_message(message):
  222. """HTML sanitize the message and format it for display."""
  223. # Comments use Markdown formatting, and HTML tags are escaped by default.
  224. message = render_markdown(message)
  225. # Process emoticons.
  226. message = Emoticons.render(message)
  227. return message
  228. def get_comments(thread):
  229. """Get the comment thread."""
  230. doc = "comments/threads/{}".format(thread)
  231. if JsonDB.exists(doc):
  232. return JsonDB.get(doc)
  233. return {}
  234. def write_comments(thread, comments):
  235. """Save the comments DB."""
  236. if len(comments.keys()) == 0:
  237. return JsonDB.delete("comments/threads/{}".format(thread))
  238. return JsonDB.commit("comments/threads/{}".format(thread), comments)
  239. def get_subscribers(thread):
  240. """Get the subscribers to a comment thread."""
  241. doc = "comments/subscribers/{}".format(thread)
  242. if JsonDB.exists(doc):
  243. return JsonDB.get(doc)
  244. return {}
  245. def write_subscribers(thread, subs):
  246. """Save the subscribers to the DB."""
  247. if len(subs.keys()) == 0:
  248. return JsonDB.delete("comments/subscribers/{}".format(thread))
  249. return JsonDB.commit("comments/subscribers/{}".format(thread), subs)
  250. def random_hash():
  251. """Get a short random hash to use as the ID for a comment."""
  252. md5 = hashlib.md5()
  253. md5.update(str(random.randint(0, 1000000)).encode("utf-8"))
  254. return md5.hexdigest()
  255. def gravatar(email):
  256. """Generate a Gravatar link for an email address."""
  257. if "@" in email:
  258. # Default avatar?
  259. default = Config.comment.default_avatar
  260. # Construct the URL.
  261. params = {
  262. "s": "96", # size
  263. }
  264. if default:
  265. params["d"] = default
  266. url = "//www.gravatar.com/avatar/" + hashlib.md5(email.lower().encode("utf-8")).hexdigest() + "?"
  267. # URL encode the params, the Python 2 & Python 3 way.
  268. if sys.version_info[0] < 3:
  269. url += urllib.urlencode(params)
  270. else:
  271. url += urllib.parse.urlencode(params)
  272. return url
  273. return ""