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.
 
 
 
 
 

561 lines
18 KiB

  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, absolute_import
  3. """Endpoints for the web blog."""
  4. from flask import Blueprint, g, request, redirect, url_for, flash, make_response
  5. import datetime
  6. import time
  7. import re
  8. from xml.dom.minidom import Document
  9. import rophako.model.user as User
  10. import rophako.model.blog as Blog
  11. import rophako.model.comment as Comment
  12. import rophako.model.emoticons as Emoticons
  13. from rophako.utils import (template, render_markdown, pretty_time,
  14. login_required, remote_addr)
  15. from rophako.plugin import load_plugin
  16. from rophako.settings import Config
  17. from rophako.log import logger
  18. import sys
  19. if sys.version_info[0] > 2:
  20. def unicode(s):
  21. return str(s)
  22. mod = Blueprint("blog", __name__, url_prefix="/blog")
  23. load_plugin("rophako.modules.comment")
  24. @mod.route("/")
  25. def index():
  26. return template("blog/index.html")
  27. @mod.route("/archive")
  28. def archive():
  29. """List all blog posts over time on one page."""
  30. index = Blog.get_index()
  31. # Group by calendar month, and keep track of friendly versions of months.
  32. groups = dict()
  33. friendly_months = dict()
  34. for post_id, data in index.items():
  35. ts = datetime.datetime.fromtimestamp(data["time"])
  36. date = ts.strftime("%Y-%m")
  37. if not date in groups:
  38. groups[date] = dict()
  39. friendly = ts.strftime("%B %Y")
  40. friendly_months[date] = friendly
  41. # Get author's profile && Pretty-print the time.
  42. data["profile"] = User.get_user(uid=data["author"])
  43. data["pretty_time"] = pretty_time(Config.blog.time_format, data["time"])
  44. groups[date][post_id] = data
  45. # Sort by calendar month.
  46. sort_months = sorted(groups.keys(), reverse=True)
  47. # Prepare the results.
  48. result = list()
  49. for month in sort_months:
  50. data = dict(
  51. month=month,
  52. month_friendly=friendly_months[month],
  53. posts=list()
  54. )
  55. # Sort the posts by time created, descending.
  56. for post_id in sorted(groups[month].keys(), key=lambda x: groups[month][x]["time"], reverse=True):
  57. data["posts"].append(groups[month][post_id])
  58. result.append(data)
  59. g.info["archive"] = result
  60. return template("blog/archive.html")
  61. @mod.route("/category/<category>")
  62. def category(category):
  63. g.info["url_category"] = category
  64. return template("blog/index.html")
  65. @mod.route("/drafts")
  66. @login_required
  67. def drafts():
  68. """View all of the draft blog posts."""
  69. return template("blog/drafts.html")
  70. @mod.route("/private")
  71. @login_required
  72. def private():
  73. """View all of the blog posts marked as private."""
  74. return template("blog/private.html")
  75. @mod.route("/entry/<fid>")
  76. def entry(fid):
  77. """Endpoint to view a specific blog entry."""
  78. # Resolve the friendly ID to a real ID.
  79. post_id = Blog.resolve_id(fid, drafts=True)
  80. if not post_id:
  81. # See if the friendly ID contains any extraneous dashes at the front
  82. # or end, and remove them and see if we have a match. This allows for
  83. # fixing blog fid's that allowed leading/trailing dashes and having the
  84. # old URL just redirect to the new one.
  85. fid = fid.strip("-")
  86. post_id = Blog.resolve_id(fid, drafts=True)
  87. # If still nothing, try consolidating extra dashes into one.
  88. if not post_id:
  89. fid = re.sub(r'-+', '-', fid)
  90. post_id = Blog.resolve_id(fid, drafts=True)
  91. # Did we find one now?
  92. if post_id:
  93. return redirect(url_for(".entry", fid=fid))
  94. flash("That blog post wasn't found.")
  95. return redirect(url_for(".index"))
  96. # Look up the post.
  97. post = Blog.get_entry(post_id)
  98. post["post_id"] = post_id
  99. # Body has a snipped section?
  100. if "<snip>" in post["body"]:
  101. post["body"] = re.sub(r'\s*<snip>\s*', '\n\n', post["body"])
  102. # Render the body.
  103. if post["format"] == "markdown":
  104. post["rendered_body"] = render_markdown(post["body"])
  105. else:
  106. post["rendered_body"] = post["body"]
  107. # Render emoticons.
  108. if post["emoticons"]:
  109. post["rendered_body"] = Emoticons.render(post["rendered_body"])
  110. # Get the author's information.
  111. post["profile"] = User.get_user(uid=post["author"])
  112. post["photo"] = User.get_picture(uid=post["author"])
  113. post["photo_url"] = Config.photo.root_public
  114. # Pretty-print the time.
  115. post["pretty_time"] = pretty_time(Config.blog.time_format, post["time"])
  116. # Count the comments for this post
  117. post["comment_count"] = Comment.count_comments("blog-{}".format(post_id))
  118. # Inject information about this post's siblings.
  119. index = Blog.get_index()
  120. siblings = [None, None] # previous, next
  121. sorted_ids = list(map(lambda y: int(y), sorted(index.keys(), key=lambda x: index[x]["time"], reverse=True)))
  122. for i in range(0, len(sorted_ids)):
  123. if sorted_ids[i] == post_id:
  124. # Found us!
  125. if i > 0:
  126. # We have an older post.
  127. siblings[0] = index[ str(sorted_ids[i-1]) ]
  128. if i < len(sorted_ids) - 1:
  129. # We have a newer post.
  130. siblings[1] = index[ str(sorted_ids[i+1]) ]
  131. post["siblings"] = siblings
  132. g.info["post"] = post
  133. return template("blog/entry.html")
  134. @mod.route("/entry")
  135. @mod.route("/index")
  136. def dummy():
  137. return redirect(url_for(".index"))
  138. @mod.route("/update", methods=["GET", "POST"])
  139. @login_required
  140. def update():
  141. """Post/edit a blog entry."""
  142. # Get our available avatars.
  143. g.info["avatars"] = Blog.list_avatars()
  144. g.info["userpic"] = User.get_picture(uid=g.info["session"]["uid"])
  145. # Default vars.
  146. g.info.update(dict(
  147. post_id="",
  148. fid="",
  149. author=g.info["session"]["uid"],
  150. subject="",
  151. body="",
  152. format="markdown",
  153. avatar="",
  154. categories="",
  155. privacy=Config.blog.default_privacy,
  156. sticky=False,
  157. emoticons=True,
  158. comments=Config.blog.allow_comments,
  159. preview=False,
  160. ))
  161. # Editing an existing post?
  162. post_id = request.args.get("id", None)
  163. if post_id:
  164. post_id = Blog.resolve_id(post_id, drafts=True)
  165. if post_id:
  166. logger.info("Editing existing blog post {}".format(post_id))
  167. post = Blog.get_entry(post_id)
  168. g.info["post_id"] = post_id
  169. g.info["post"] = post
  170. # Copy fields.
  171. for field in ["author", "fid", "subject", "time", "format",
  172. "body", "avatar", "categories", "privacy",
  173. "sticky", "emoticons", "comments"]:
  174. g.info[field] = post[field]
  175. # Are we SUBMITTING the form?
  176. if request.method == "POST":
  177. action = request.form.get("action")
  178. # Get all the fields from the posted params.
  179. g.info["post_id"] = request.form.get("id")
  180. for field in ["fid", "subject", "format", "body", "avatar", "categories", "privacy"]:
  181. g.info[field] = request.form.get(field)
  182. for boolean in ["sticky", "emoticons", "comments"]:
  183. g.info[boolean] = True if request.form.get(boolean, None) == "true" else False
  184. g.info["author"] = int(g.info["author"])
  185. # What action are they doing?
  186. if action == "preview":
  187. g.info["preview"] = True
  188. # Render markdown?
  189. if g.info["format"] == "markdown":
  190. g.info["rendered_body"] = render_markdown(g.info["body"])
  191. else:
  192. g.info["rendered_body"] = g.info["body"]
  193. # Render emoticons.
  194. if g.info["emoticons"]:
  195. g.info["rendered_body"] = Emoticons.render(g.info["rendered_body"])
  196. elif action == "publish":
  197. # Publishing! Validate inputs first.
  198. invalid = False
  199. if len(g.info["body"]) == 0:
  200. invalid = True
  201. flash("You must enter a body for your blog post.")
  202. if len(g.info["subject"]) == 0:
  203. invalid = True
  204. flash("You must enter a subject for your blog post.")
  205. # Resetting the post's time stamp?
  206. if not request.form.get("id") or request.form.get("reset-time"):
  207. g.info["time"] = float(time.time())
  208. else:
  209. g.info["time"] = float(request.form.get("time", time.time()))
  210. # Format the categories.
  211. tags = []
  212. for tag in g.info["categories"].split(","):
  213. tags.append(tag.strip())
  214. # Okay to update?
  215. if invalid is False:
  216. new_id, new_fid = Blog.post_entry(
  217. post_id = g.info["post_id"],
  218. epoch = g.info["time"],
  219. author = g.info["author"],
  220. subject = g.info["subject"],
  221. fid = g.info["fid"],
  222. avatar = g.info["avatar"],
  223. categories = tags,
  224. privacy = g.info["privacy"],
  225. ip = remote_addr(),
  226. emoticons = g.info["emoticons"],
  227. sticky = g.info["sticky"],
  228. comments = g.info["comments"],
  229. format = g.info["format"],
  230. body = g.info["body"],
  231. )
  232. return redirect(url_for(".entry", fid=new_fid))
  233. if type(g.info["categories"]) is list:
  234. g.info["categories"] = ", ".join(g.info["categories"])
  235. return template("blog/update.html")
  236. @mod.route("/delete", methods=["GET", "POST"])
  237. @login_required
  238. def delete():
  239. """Delete a blog post."""
  240. post_id = request.args.get("id")
  241. # Resolve the post ID.
  242. post_id = Blog.resolve_id(post_id, drafts=True)
  243. if not post_id:
  244. flash("That blog post wasn't found.")
  245. return redirect(url_for(".index"))
  246. if request.method == "POST":
  247. confirm = request.form.get("confirm")
  248. if confirm == "true":
  249. Blog.delete_entry(post_id)
  250. flash("The blog entry has been deleted.")
  251. return redirect(url_for(".index"))
  252. # Get the entry's subject.
  253. post = Blog.get_entry(post_id)
  254. g.info["subject"] = post["subject"]
  255. g.info["post_id"] = post_id
  256. return template("blog/delete.html")
  257. @mod.route("/rss")
  258. def rss():
  259. """RSS feed for the blog."""
  260. doc = Document()
  261. rss = doc.createElement("rss")
  262. rss.setAttribute("version", "2.0")
  263. rss.setAttribute("xmlns:blogChannel", "http://backend.userland.com/blogChannelModule")
  264. doc.appendChild(rss)
  265. channel = doc.createElement("channel")
  266. rss.appendChild(channel)
  267. rss_time = "%a, %d %b %Y %H:%M:%S GMT"
  268. ######
  269. ## Channel Information
  270. ######
  271. today = time.strftime(rss_time, time.gmtime())
  272. xml_add_text_tags(doc, channel, [
  273. ["title", Config.blog.title],
  274. ["link", Config.blog.link],
  275. ["description", Config.blog.description],
  276. ["language", Config.blog.language],
  277. ["copyright", Config.blog.copyright],
  278. ["pubDate", today],
  279. ["lastBuildDate", today],
  280. ["webmaster", Config.blog.webmaster],
  281. ])
  282. ######
  283. ## Image Information
  284. ######
  285. image = doc.createElement("image")
  286. channel.appendChild(image)
  287. xml_add_text_tags(doc, image, [
  288. ["title", Config.blog.image_title],
  289. ["url", Config.blog.image_url],
  290. ["link", Config.blog.link],
  291. ["width", Config.blog.image_width],
  292. ["height", Config.blog.image_height],
  293. ["description", Config.blog.image_description],
  294. ])
  295. ######
  296. ## Add the blog posts
  297. ######
  298. index = Blog.get_index()
  299. posts = get_index_posts(index)
  300. for post_id in posts[:int(Config.blog.entries_per_feed)]:
  301. post = Blog.get_entry(post_id)
  302. item = doc.createElement("item")
  303. channel.appendChild(item)
  304. # Render the body.
  305. if post["format"] == "markdown":
  306. post["rendered_body"] = render_markdown(post["body"])
  307. else:
  308. post["rendered_body"] = post["body"]
  309. # Render emoticons.
  310. if post["emoticons"]:
  311. post["rendered_body"] = Emoticons.render(post["rendered_body"])
  312. xml_add_text_tags(doc, item, [
  313. ["title", post["subject"]],
  314. ["link", url_for("blog.entry", fid=post["fid"], _external=True)],
  315. ["description", post["rendered_body"]],
  316. ["pubDate", time.strftime(rss_time, time.gmtime(post["time"]))],
  317. ])
  318. resp = make_response(doc.toprettyxml(encoding="utf-8"))
  319. resp.headers["Content-Type"] = "application/rss+xml; charset=utf-8"
  320. return resp
  321. def xml_add_text_tags(doc, root_node, tags):
  322. """RSS feed helper function.
  323. Add a collection of simple tag/text pairs to a root XML element."""
  324. for pair in tags:
  325. name, value = pair
  326. channelTag = doc.createElement(name)
  327. channelTag.appendChild(doc.createTextNode(unicode(value)))
  328. root_node.appendChild(channelTag)
  329. def partial_index(template_name="blog/index.inc.html", mode="normal"):
  330. """Partial template for including the index view of the blog.
  331. Args:
  332. template_name (str): The name of the template to be rendered.
  333. mode (str): The view mode of the posts, one of:
  334. - normal: Only list public entries, or private posts for users
  335. who are logged in.
  336. - drafts: Only list draft entries for logged-in users.
  337. """
  338. # Get the blog index.
  339. if mode == "normal":
  340. index = Blog.get_index()
  341. elif mode == "drafts":
  342. index = Blog.get_drafts()
  343. elif mode == "private":
  344. index = Blog.get_private()
  345. else:
  346. return "Invalid partial_index mode."
  347. # Let the pages know what mode they're in.
  348. g.info["mode"] = mode
  349. pool = {} # The set of blog posts to show.
  350. category = g.info.get("url_category", None)
  351. if category == Config.blog.default_category:
  352. category = ""
  353. # Are we narrowing by category?
  354. if category is not None:
  355. # Narrow down the index to just those that match the category.
  356. for post_id, data in index.items():
  357. if not category in data["categories"]:
  358. continue
  359. pool[post_id] = data
  360. # No such category?
  361. if len(pool) == 0:
  362. flash("There are no posts with that category.")
  363. return redirect(url_for(".index"))
  364. else:
  365. pool = index
  366. # Get the posts we want.
  367. posts = get_index_posts(pool)
  368. # Handle pagination.
  369. offset = request.args.get("skip", 0)
  370. try: offset = int(offset)
  371. except: offset = 0
  372. # Handle the offsets, and get those for the "older" and "earlier" posts.
  373. # "earlier" posts count down (towards index 0), "older" counts up.
  374. g.info["offset"] = offset
  375. g.info["earlier"] = offset - int(Config.blog.entries_per_page) if offset > 0 else 0
  376. g.info["older"] = offset + int(Config.blog.entries_per_page)
  377. if g.info["earlier"] < 0:
  378. g.info["earlier"] = 0
  379. if g.info["older"] < 0 or g.info["older"] > len(posts) - 1:
  380. g.info["older"] = 0
  381. g.info["count"] = 0
  382. # Can we go to other pages?
  383. g.info["can_earlier"] = True if offset > 0 else False
  384. g.info["can_older"] = False if g.info["older"] == 0 else True
  385. # Load the selected posts.
  386. selected = []
  387. stop = offset + int(Config.blog.entries_per_page)
  388. if stop > len(posts): stop = len(posts)
  389. index = 1 # Let each post know its position on-page.
  390. for i in range(offset, stop):
  391. post_id = posts[i]
  392. post = Blog.get_entry(post_id)
  393. post["post_id"] = post_id
  394. # Body has a snipped section?
  395. if "<snip>" in post["body"]:
  396. post["body"] = post["body"].split("<snip>")[0]
  397. post["snipped"] = True
  398. # Render the body.
  399. if post["format"] == "markdown":
  400. post["rendered_body"] = render_markdown(post["body"])
  401. else:
  402. post["rendered_body"] = post["body"]
  403. # Render emoticons.
  404. if post["emoticons"]:
  405. post["rendered_body"] = Emoticons.render(post["rendered_body"])
  406. # Get the author's information.
  407. post["profile"] = User.get_user(uid=post["author"])
  408. post["photo"] = User.get_picture(uid=post["author"])
  409. post["photo_url"] = Config.photo.root_public
  410. post["pretty_time"] = pretty_time(Config.blog.time_format, post["time"])
  411. # Count the comments for this post
  412. post["comment_count"] = Comment.count_comments("blog-{}".format(post_id))
  413. post["position_index"] = index
  414. index += 1
  415. selected.append(post)
  416. g.info["count"] += 1
  417. g.info["category"] = category
  418. g.info["posts"] = selected
  419. return template(template_name)
  420. def get_index_posts(index):
  421. """Helper function to get data for the blog index page."""
  422. # Separate the sticky posts from the normal ones.
  423. sticky, normal = set(), set()
  424. for post_id, data in index.items():
  425. if data["sticky"]:
  426. sticky.add(post_id)
  427. else:
  428. normal.add(post_id)
  429. # Sort the blog IDs by published time.
  430. posts = []
  431. posts.extend(sorted(sticky, key=lambda x: index[x]["time"], reverse=True))
  432. posts.extend(sorted(normal, key=lambda x: index[x]["time"], reverse=True))
  433. return posts
  434. def partial_tags():
  435. """Get a listing of tags and their quantities for the nav bar."""
  436. tags = Blog.get_categories()
  437. # Sort the tags by popularity.
  438. sort_tags = [ tag for tag in sorted(tags.keys(), key=lambda y: tags[y], reverse=True) ]
  439. result = []
  440. has_small = False
  441. for tag in sort_tags:
  442. result.append(dict(
  443. category=tag if len(tag) else Config.blog.default_category,
  444. count=tags[tag],
  445. small=tags[tag] < 3, # TODO: make this configurable
  446. ))
  447. if tags[tag] < 3:
  448. has_small = True
  449. g.info["tags"] = result
  450. g.info["has_small"] = has_small
  451. return template("blog/categories.inc.html")