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.

utils.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. # -*- coding: utf-8 -*-
  2. from __future__ import unicode_literals, print_function, absolute_import
  3. from flask import (g, session, request, render_template, flash, redirect,
  4. url_for, current_app)
  5. from functools import wraps
  6. import codecs
  7. import uuid
  8. import datetime
  9. import time
  10. import pytz
  11. import re
  12. import importlib
  13. import smtplib
  14. import markdown
  15. import json
  16. import sys
  17. try:
  18. import urlparse
  19. except ImportError:
  20. from urllib import parse as urlparse
  21. import traceback
  22. from email.mime.multipart import MIMEMultipart
  23. from email.mime.text import MIMEText
  24. from rophako import __version__
  25. from rophako.log import logger
  26. from rophako.settings import Config
  27. def login_required(f):
  28. """Wrapper for pages that require a logged-in user."""
  29. @wraps(f)
  30. def decorated_function(*args, **kwargs):
  31. if not g.info["session"]["login"]:
  32. session["redirect_url"] = request.url
  33. flash("You must be logged in to do that!")
  34. return redirect(url_for("account.login"))
  35. return f(*args, **kwargs)
  36. return decorated_function
  37. def admin_required(f):
  38. """Wrapper for admin-only pages. Implies login_required."""
  39. @wraps(f)
  40. def decorated_function(*args, **kwargs):
  41. if not g.info["session"]["login"]:
  42. # Not even logged in?
  43. session["redirect_url"] = request.url
  44. flash("You must be logged in to do that!")
  45. return redirect(url_for("account.login"))
  46. if g.info["session"]["role"] != "admin":
  47. logger.warning("User tried to access an Admin page, but wasn't allowed!")
  48. return redirect(url_for("index"))
  49. return f(*args, **kwargs)
  50. return decorated_function
  51. def ajax_response(status, msg):
  52. """Return a standard JSON response."""
  53. status = "ok" if status else "error"
  54. return json.dumps(dict(
  55. status=status,
  56. msg=msg,
  57. ))
  58. def default_vars():
  59. """Default template variables."""
  60. return {
  61. "time": time.time(),
  62. "app": {
  63. "name": "Rophako",
  64. "version": __version__,
  65. "python_version": "{}.{}".format(sys.version_info.major, sys.version_info.minor),
  66. "author": "Noah Petherbridge",
  67. "photo_url": Config.photo.root_public,
  68. "config": Config,
  69. },
  70. "uri": request.path,
  71. "session": {
  72. "login": False, # Not logged in, until proven otherwise.
  73. "username": "guest",
  74. "uid": 0,
  75. "name": "Guest",
  76. "role": "user",
  77. },
  78. #"tracking": Tracking.track_visit(request, session),
  79. }
  80. def template(name, **kwargs):
  81. """Render a template to the browser."""
  82. html = render_template(name, **kwargs)
  83. # Get the elapsed time for the request.
  84. time_elapsed = "%.03f" % (time.time() - g.info["time"])
  85. html = re.sub(r'\%time_elapsed\%', time_elapsed, html)
  86. return html
  87. def markdown_template(path):
  88. """Render a Markdown page to the browser.
  89. The first line in the Markdown page should be an H1 header beginning with
  90. the # sign. This will set the page's <title> to match the header value.
  91. Pages can include lines that begin with the keyword `:meta` to apply
  92. meta information to control the Markdown parser. Supported meta lines
  93. and examples:
  94. To 'blacklist' extensions, i.e. to turn off line breaks inside a paragraph
  95. getting translated into a <br> tag (the key is the minus sign):
  96. :meta extensions -nl2br
  97. To add an extension, i.e. the abbreviations from PHP Markdown Extra:
  98. :meta extensions abbr"""
  99. # The path is the absolute path to the Markdown file, so open it directly.
  100. fh = codecs.open(path, "r", "utf-8")
  101. body = fh.read()
  102. fh.close()
  103. # Look for meta information in the file.
  104. lines = body.split("\n")
  105. content = list() # New set of lines, without meta info.
  106. extensions = set()
  107. blacklist = set() # Blacklisted extensions
  108. toc = False # Render a table of contents
  109. for line in lines:
  110. if line.startswith(":meta"):
  111. parts = line.split(" ")
  112. if len(parts) >= 3:
  113. # Supported meta commands.
  114. if parts[1] == "extensions":
  115. # Extension toggles.
  116. for extension in parts[2:]:
  117. if extension.startswith("-"):
  118. extension = extension[1:]
  119. blacklist.add(extension)
  120. else:
  121. extensions.add(extension)
  122. elif parts[1] == "toc":
  123. # Table of contents.
  124. toc = parts[2] == "on"
  125. else:
  126. content.append(line)
  127. # Extract a title from the first line.
  128. first = content[0]
  129. if first.startswith("#"):
  130. first = first[1:].strip()
  131. rendered = render_markdown("\n".join(content),
  132. extensions=extensions,
  133. blacklist=blacklist,
  134. )
  135. # Including a table of contents?
  136. nav = None
  137. if toc:
  138. nav = parse_anchors(rendered)
  139. return template("markdown.inc.html",
  140. title=first,
  141. markdown=rendered,
  142. toc=nav,
  143. )
  144. def render_markdown(body, html_escape=True, extensions=None, blacklist=None):
  145. """Render a block of Markdown text.
  146. This will default to escaping literal HTML characters. Set
  147. `html_escape=False` to trust HTML.
  148. * extensions should be a set() of extensions to add.
  149. * blacklist should be a set() of extensions to blacklist."""
  150. args = dict(
  151. lazy_ol=False, # If a numbered list starts at e.g. 4, show the <ol> there
  152. extensions=[
  153. "fenced_code", # GitHub style code blocks
  154. "tables", # http://michelf.ca/projects/php-markdown/extra/#table
  155. "smart_strong", # Handles double__underscore better.
  156. "codehilite", # Code highlighting with Pygment!
  157. "nl2br", # Line breaks inside a paragraph become <br>
  158. "sane_lists", # Make lists less surprising
  159. ],
  160. extension_configs={
  161. "codehilite": {
  162. "linenums": False,
  163. }
  164. }
  165. )
  166. if html_escape:
  167. args["safe_mode"] = "escape"
  168. # Additional extensions?
  169. if extensions is not None:
  170. for ext in extensions:
  171. args["extensions"].append(ext)
  172. if blacklist is not None:
  173. for ext in blacklist:
  174. args["extensions"].remove(str(ext))
  175. return u'<div class="markdown">{}</div>'.format(
  176. markdown.markdown(body, **args)
  177. )
  178. def parse_anchors(html):
  179. """Parse HTML code and identify anchor tags for Table of Contents.
  180. Args:
  181. * str html: HTML code generated by `render_markdown()`
  182. Returns:
  183. * list of dicts containing the parsed table of contents, with keys
  184. including `id`, `level` (<h1> level as int) and `text`
  185. """
  186. toc = []
  187. regexp = re.compile(r'<h(\d) id="(.+?)">(.+?)</h\d>')
  188. for match in re.findall(regexp, html):
  189. toc.append(dict(
  190. id=match[1],
  191. level=int(match[0]),
  192. text=match[2],
  193. ))
  194. return toc
  195. def send_email(to, subject, message, header=None, footer=None, sender=None,
  196. reply_to=None):
  197. """Send a (markdown-formatted) e-mail out.
  198. This will deliver an HTML-formatted e-mail (using the ``email.inc.html``
  199. template) using the rendered Markdown contents of ``message`` and
  200. ``footer``. It will also send a plain text version using the raw Markdown
  201. formatting in case the user can't accept HTML.
  202. Parameters:
  203. to ([]str): list of addresses to send the message to.
  204. subject (str): email subject and title.
  205. message (str): the email body, in Markdown format.
  206. header (str): the header text for the HTML email (plain text).
  207. footer (str): optional email footer, in Markdown format. The default
  208. footer is defined in the ``email.inc.html`` template.
  209. sender (str): optional sender email address. Defaults to the one
  210. specified in the site configuration.
  211. reply_to (str): optional Reply-To address header.
  212. """
  213. if sender is None:
  214. sender = Config.mail.sender
  215. if type(to) != list:
  216. to = [to]
  217. # Render the Markdown bits.
  218. if footer:
  219. footer = render_markdown(footer)
  220. # Default header matches the subject.
  221. if not header:
  222. header = subject
  223. html_message = render_template("email.inc.html",
  224. title=subject,
  225. header=header,
  226. message=render_markdown(message),
  227. footer=footer,
  228. )
  229. logger.info("Send email to {}".format(to))
  230. if Config.mail.method == "smtp":
  231. # Send mail with SMTP.
  232. for email in to:
  233. msg = MIMEMultipart("alternative")
  234. msg.set_charset("utf-8")
  235. msg["Subject"] = subject
  236. msg["From"] = sender
  237. msg["To"] = email
  238. if reply_to is not None:
  239. msg["Reply-To"] = reply_to
  240. text = MIMEText(message, "plain", "utf-8")
  241. msg.attach(text)
  242. html = MIMEText(html_message, "html", "utf-8")
  243. msg.attach(html)
  244. # Send the e-mail.
  245. try:
  246. server = smtplib.SMTP(Config.mail.server, Config.mail.port)
  247. server.sendmail(sender, [email], msg.as_string())
  248. except:
  249. pass
  250. def handle_exception(error):
  251. """Send an e-mail to the site admin when an exception occurs."""
  252. if current_app.config.get("DEBUG"):
  253. print(traceback.format_exc())
  254. raise
  255. import rophako.jsondb as JsonDB
  256. # Don't spam too many e-mails in a short time frame.
  257. cache = JsonDB.get_cache("exception_catcher")
  258. if cache:
  259. last_exception = int(cache)
  260. if int(time.time()) - last_exception < 120:
  261. # Only one e-mail per 2 minutes, minimum
  262. logger.error("RAPID EXCEPTIONS, DROPPING")
  263. return
  264. JsonDB.set_cache("exception_catcher", int(time.time()))
  265. username = "anonymous"
  266. try:
  267. if hasattr(g, "info") and "session" in g.info and "username" in g.info["session"]:
  268. username = g.info["session"]["username"]
  269. except:
  270. pass
  271. # Get the timestamp.
  272. timestamp = time.ctime(time.time())
  273. # Exception's traceback.
  274. error = str(error.__class__.__name__) + ": " + str(error)
  275. stacktrace = error + "\n\n" \
  276. + "==== Start Traceback ====\n" \
  277. + traceback.format_exc() \
  278. + "==== End Traceback ====\n\n" \
  279. + "Request Information\n" \
  280. + "-------------------\n" \
  281. + "Address: " + remote_addr() + "\n" \
  282. + "User Agent: " + request.user_agent.string + "\n" \
  283. + "Referrer: " + request.referrer
  284. # Construct the subject and message
  285. subject = "Internal Server Error on {} - {} - {}".format(
  286. Config.site.site_name,
  287. username,
  288. timestamp,
  289. )
  290. message = "{} has experienced an exception on the route: {}".format(
  291. username,
  292. request.path,
  293. )
  294. message += "\n\n" + stacktrace
  295. # Send the e-mail.
  296. send_email(
  297. to=Config.site.notify_address,
  298. subject=subject,
  299. message=message,
  300. )
  301. def generate_csrf_token():
  302. """Generator for CSRF tokens."""
  303. if "_csrf" not in session:
  304. session["_csrf"] = str(uuid.uuid4())
  305. return session["_csrf"]
  306. def include(endpoint, *args, **kwargs):
  307. """Include another sub-page inside a template."""
  308. # The 'endpoint' should be in the format 'module.function', i.e. 'blog.index'.
  309. module, function = endpoint.split(".")
  310. # Dynamically import the module and call its function.
  311. m = importlib.import_module("rophako.modules.{}".format(module))
  312. html = getattr(m, function)(*args, **kwargs)
  313. return html
  314. def remote_addr():
  315. """Retrieve the end user's remote IP address. If the site is configured
  316. to honor X-Forwarded-For and this header is present, it's returned."""
  317. if Config.security.use_forwarded_for:
  318. return request.access_route[0]
  319. return request.remote_addr
  320. def server_name():
  321. """Get the server's hostname."""
  322. urlparts = list(urlparse.urlparse(request.url_root))
  323. return urlparts[1]
  324. def pretty_time(time_format, unix):
  325. """Pretty-print a time stamp."""
  326. tz = pytz.timezone(Config.site.timezone)
  327. date = datetime.datetime.fromtimestamp(unix, pytz.utc)
  328. return date.astimezone(tz).strftime(time_format)
  329. def sanitize_name(name):
  330. """Sanitize a name that may be used in the filesystem.
  331. Only allows numbers, letters, and some symbols."""
  332. return re.sub(r'[^A-Za-z0-9 .\-_]+', '', name)