helpers.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.utils.helpers
  4. ~~~~~~~~~~~~~~~~~~~~
  5. A few helpers that are used by flaskbb
  6. :copyright: (c) 2014 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import re
  10. import time
  11. import itertools
  12. import operator
  13. import struct
  14. from io import BytesIO
  15. from datetime import datetime, timedelta
  16. import requests
  17. from flask import session, url_for
  18. from babel.dates import format_timedelta
  19. from flask_themes2 import render_theme_template
  20. from flask_login import current_user
  21. from postmarkup import render_bbcode
  22. from markdown2 import markdown as render_markdown
  23. import unidecode
  24. from flaskbb._compat import range_method, text_type
  25. from flaskbb.extensions import redis_store
  26. from flaskbb.utils.settings import flaskbb_config
  27. _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
  28. def slugify(text, delim=u'-'):
  29. """Generates an slightly worse ASCII-only slug.
  30. Taken from the Flask Snippets page.
  31. :param text: The text which should be slugified
  32. :param delim: Default "-". The delimeter for whitespace
  33. """
  34. text = unidecode.unidecode(text)
  35. result = []
  36. for word in _punct_re.split(text.lower()):
  37. if word:
  38. result.append(word)
  39. return text_type(delim.join(result))
  40. def render_template(template, **context): # pragma: no cover
  41. """A helper function that uses the `render_theme_template` function
  42. without needing to edit all the views
  43. """
  44. if current_user.is_authenticated() and current_user.theme:
  45. theme = current_user.theme
  46. else:
  47. theme = session.get('theme', flaskbb_config['DEFAULT_THEME'])
  48. return render_theme_template(theme, template, **context)
  49. def get_categories_and_forums(query_result, user):
  50. """Returns a list with categories. Every category has a list for all
  51. their associated forums.
  52. The structure looks like this::
  53. [(<Category 1>,
  54. [(<Forum 1>, None),
  55. (<Forum 2>, <flaskbb.forum.models.ForumsRead at 0x38fdb50>)]),
  56. (<Category 2>,
  57. [(<Forum 3>, None),
  58. (<Forum 4>, None)])]
  59. and to unpack the values you can do this::
  60. In [110]: for category, forums in x:
  61. .....: print category
  62. .....: for forum, forumsread in forums:
  63. .....: print "\t", forum, forumsread
  64. This will print something like this:
  65. <Category 1>
  66. <Forum 1> None
  67. <Forum 2> <flaskbb.forum.models.ForumsRead object at 0x38fdb50>
  68. <Category 2>
  69. <Forum 3> None
  70. <Forum 4> None
  71. :param query_result: A tuple (KeyedTuple) with all categories and forums
  72. :param user: The user object is needed because a signed out user does not
  73. have the ForumsRead relation joined.
  74. """
  75. it = itertools.groupby(query_result, operator.itemgetter(0))
  76. forums = []
  77. if user.is_authenticated():
  78. for key, value in it:
  79. forums.append((key, [(item[1], item[2]) for item in value]))
  80. else:
  81. for key, value in it:
  82. forums.append((key, [(item[1], None) for item in value]))
  83. return forums
  84. def get_forums(query_result, user):
  85. """Returns a tuple which contains the category and the forums as list.
  86. This is the counterpart for get_categories_and_forums and especially
  87. usefull when you just need the forums for one category.
  88. For example::
  89. (<Category 2>,
  90. [(<Forum 3>, None),
  91. (<Forum 4>, None)])
  92. :param query_result: A tuple (KeyedTuple) with all categories and forums
  93. :param user: The user object is needed because a signed out user does not
  94. have the ForumsRead relation joined.
  95. """
  96. it = itertools.groupby(query_result, operator.itemgetter(0))
  97. if user.is_authenticated():
  98. for key, value in it:
  99. forums = key, [(item[1], item[2]) for item in value]
  100. else:
  101. for key, value in it:
  102. forums = key, [(item[1], None) for item in value]
  103. return forums
  104. def forum_is_unread(forum, forumsread, user):
  105. """Checks if a forum is unread
  106. :param forum: The forum that should be checked if it is unread
  107. :param forumsread: The forumsread object for the forum
  108. :param user: The user who should be checked if he has read the forum
  109. """
  110. # If the user is not signed in, every forum is marked as read
  111. if not user.is_authenticated():
  112. return False
  113. read_cutoff = datetime.utcnow() - timedelta(
  114. days=flaskbb_config["TRACKER_LENGTH"])
  115. # disable tracker if TRACKER_LENGTH is set to 0
  116. if flaskbb_config["TRACKER_LENGTH"] == 0:
  117. return False
  118. # If there are no topics in the forum, mark it as read
  119. if forum and forum.topic_count == 0:
  120. return False
  121. # If the user hasn't visited a topic in the forum - therefore,
  122. # forumsread is None and we need to check if it is still unread
  123. if forum and not forumsread:
  124. return forum.last_post_created > read_cutoff
  125. try:
  126. # check if the forum has been cleared and if there is a new post
  127. # since it have been cleared
  128. if forum.last_post_created > forumsread.cleared:
  129. if forum.last_post_created < forumsread.last_read:
  130. return False
  131. except TypeError:
  132. pass
  133. # else just check if the user has read the last post
  134. return forum.last_post_created > forumsread.last_read
  135. def topic_is_unread(topic, topicsread, user, forumsread=None):
  136. """Checks if a topic is unread.
  137. :param topic: The topic that should be checked if it is unread
  138. :param topicsread: The topicsread object for the topic
  139. :param user: The user who should be checked if he has read the last post
  140. in the topic
  141. :param forumsread: The forumsread object in which the topic is. If you
  142. also want to check if the user has marked the forum as
  143. read, than you will also need to pass an forumsread
  144. object.
  145. """
  146. if not user.is_authenticated():
  147. return False
  148. read_cutoff = datetime.utcnow() - timedelta(
  149. days=flaskbb_config["TRACKER_LENGTH"])
  150. # disable tracker if read_cutoff is set to 0
  151. if flaskbb_config["TRACKER_LENGTH"] == 0:
  152. return False
  153. # check read_cutoff
  154. if topic.last_post.date_created < read_cutoff:
  155. return False
  156. # topicsread is none if the user has marked the forum as read
  157. # or if he hasn't visited yet
  158. if topicsread is None:
  159. # user has cleared the forum sometime ago - check if there is a new post
  160. if forumsread and forumsread.cleared is not None:
  161. return forumsread.cleared < topic.last_post.date_created
  162. # user hasn't read the topic yet, or there is a new post since the user
  163. # has marked the forum as read
  164. return True
  165. # check if there is a new post since the user's last topic visit
  166. return topicsread.last_read < topic.last_post.date_created
  167. def mark_online(user_id, guest=False): # pragma: no cover
  168. """Marks a user as online
  169. :param user_id: The id from the user who should be marked as online
  170. :param guest: If set to True, it will add the user to the guest activity
  171. instead of the user activity.
  172. Ref: http://flask.pocoo.org/snippets/71/
  173. """
  174. now = int(time.time())
  175. expires = now + (flaskbb_config['ONLINE_LAST_MINUTES'] * 60) + 10
  176. if guest:
  177. all_users_key = 'online-guests/%d' % (now // 60)
  178. user_key = 'guest-activity/%s' % user_id
  179. else:
  180. all_users_key = 'online-users/%d' % (now // 60)
  181. user_key = 'user-activity/%s' % user_id
  182. p = redis_store.pipeline()
  183. p.sadd(all_users_key, user_id)
  184. p.set(user_key, now)
  185. p.expireat(all_users_key, expires)
  186. p.expireat(user_key, expires)
  187. p.execute()
  188. def get_online_users(guest=False): # pragma: no cover
  189. """Returns all online users within a specified time range
  190. :param guest: If True, it will return the online guests
  191. """
  192. current = int(time.time()) // 60
  193. minutes = range_method(flaskbb_config['ONLINE_LAST_MINUTES'])
  194. if guest:
  195. return redis_store.sunion(['online-guests/%d' % (current - x)
  196. for x in minutes])
  197. return redis_store.sunion(['online-users/%d' % (current - x)
  198. for x in minutes])
  199. def crop_title(title, suffix="..."):
  200. """Crops the title to a specified length
  201. :param title: The title that should be cropped
  202. :param suffix: The suffix which should be appended at the
  203. end of the title.
  204. """
  205. length = flaskbb_config['TITLE_LENGTH']
  206. if len(title) <= length:
  207. return title
  208. return title[:length].rsplit(' ', 1)[0] + suffix
  209. def render_markup(text):
  210. """Renders the given text as bbcode
  211. :param text: The text that should be rendered as bbcode
  212. """
  213. if flaskbb_config['MARKUP_TYPE'] == 'bbcode':
  214. return render_bbcode(text)
  215. elif flaskbb_config['MARKUP_TYPE'] == 'markdown':
  216. return render_markdown(text, extras=['tables'])
  217. return text
  218. def is_online(user):
  219. """A simple check to see if the user was online within a specified
  220. time range
  221. :param user: The user who needs to be checked
  222. """
  223. return user.lastseen >= time_diff()
  224. def time_diff():
  225. """Calculates the time difference between now and the ONLINE_LAST_MINUTES
  226. variable from the configuration.
  227. """
  228. now = datetime.utcnow()
  229. diff = now - timedelta(minutes=flaskbb_config['ONLINE_LAST_MINUTES'])
  230. return diff
  231. def format_date(value, format='%Y-%m-%d'):
  232. """Returns a formatted time string
  233. :param value: The datetime object that should be formatted
  234. :param format: How the result should look like. A full list of available
  235. directives is here: http://goo.gl/gNxMHE
  236. """
  237. return value.strftime(format)
  238. def time_since(time): # pragma: no cover
  239. """Returns a string representing time since e.g.
  240. 3 days ago, 5 hours ago.
  241. :param time: A datetime object
  242. """
  243. delta = time - datetime.utcnow()
  244. locale = "en"
  245. if current_user.is_authenticated() and current_user.language is not None:
  246. locale = current_user.language
  247. return format_timedelta(delta, add_direction=True, locale=locale)
  248. def format_quote(post):
  249. """Returns a formatted quote depending on the markup language.
  250. :param post: The quoted post.
  251. """
  252. if flaskbb_config['MARKUP_TYPE'] == 'markdown':
  253. profile_url = url_for('user.profile', username=post.username)
  254. content = "\n> ".join(post.content.strip().split('\n'))
  255. quote = "**[{post.username}]({profile_url}) wrote:**\n> {content}\n".\
  256. format(post=post, profile_url=profile_url, content=content)
  257. return quote
  258. else:
  259. profile_url = url_for('user.profile', username=post.username,
  260. _external=True)
  261. # just ignore this long line :P
  262. quote = '[b][url={profile_url}]{post.username}[/url] wrote:[/b][quote]{post.content}[/quote]\n'.\
  263. format(post=post, profile_url=profile_url)
  264. return quote
  265. def get_image_info(url):
  266. """Returns the content-type, image size (kb), height and width of a image
  267. without fully downloading it. It will just download the first 1024 bytes.
  268. LICENSE: New BSD License (taken from the start page of the repository)
  269. https://code.google.com/p/bfg-pages/source/browse/trunk/pages/getimageinfo.py
  270. """
  271. r = requests.get(url, stream=True)
  272. image_size = r.headers.get("content-length")
  273. image_size = float(image_size) / 1000 # in kilobyte
  274. data = r.raw.read(1024)
  275. size = len(data)
  276. height = -1
  277. width = -1
  278. content_type = ''
  279. if size:
  280. size = int(size)
  281. # handle GIFs
  282. if (size >= 10) and data[:6] in (b'GIF87a', b'GIF89a'):
  283. # Check to see if content_type is correct
  284. content_type = 'image/gif'
  285. w, h = struct.unpack(b'<HH', data[6:10])
  286. width = int(w)
  287. height = int(h)
  288. # See PNG 2. Edition spec (http://www.w3.org/TR/PNG/)
  289. # Bytes 0-7 are below, 4-byte chunk length, then 'IHDR'
  290. # and finally the 4-byte width, height
  291. elif ((size >= 24) and data.startswith(b'\211PNG\r\n\032\n') and
  292. (data[12:16] == b'IHDR')):
  293. content_type = 'image/png'
  294. w, h = struct.unpack(b">LL", data[16:24])
  295. width = int(w)
  296. height = int(h)
  297. # Maybe this is for an older PNG version.
  298. elif (size >= 16) and data.startswith(b'\211PNG\r\n\032\n'):
  299. # Check to see if we have the right content type
  300. content_type = 'image/png'
  301. w, h = struct.unpack(b">LL", data[8:16])
  302. width = int(w)
  303. height = int(h)
  304. # handle JPEGs
  305. elif (size >= 2) and data.startswith(b'\377\330'):
  306. content_type = 'image/jpeg'
  307. jpeg = BytesIO(data)
  308. jpeg.read(2)
  309. b = jpeg.read(1)
  310. try:
  311. while (b and ord(b) != 0xDA):
  312. while (ord(b) != 0xFF):
  313. b = jpeg.read(1)
  314. while (ord(b) == 0xFF):
  315. b = jpeg.read(1)
  316. if (ord(b) >= 0xC0 and ord(b) <= 0xC3):
  317. jpeg.read(3)
  318. h, w = struct.unpack(b">HH", jpeg.read(4))
  319. break
  320. else:
  321. jpeg.read(int(struct.unpack(b">H", jpeg.read(2))[0])-2)
  322. b = jpeg.read(1)
  323. width = int(w)
  324. height = int(h)
  325. except struct.error:
  326. pass
  327. except ValueError:
  328. pass
  329. return {"content-type": content_type, "size": image_size,
  330. "width": width, "height": height}
  331. def check_image(url):
  332. """A little wrapper for the :func:`get_image_info` function.
  333. If the image doesn't match the ``flaskbb_config`` settings it will
  334. return a tuple with a the first value is the custom error message and
  335. the second value ``False`` for not passing the check.
  336. If the check is successful, it will return ``None`` for the error message
  337. and ``True`` for the passed check.
  338. :param url: The image url to be checked.
  339. """
  340. img_info = get_image_info(url)
  341. error = None
  342. if not img_info["content-type"] in flaskbb_config["AVATAR_TYPES"]:
  343. error = "Image type is not allowed. Allowed types are: {}".format(
  344. ", ".join(flaskbb_config["AVATAR_TYPES"])
  345. )
  346. return error, False
  347. if img_info["width"] > flaskbb_config["AVATAR_WIDTH"]:
  348. error = "Image is too wide! {}px width is allowed.".format(
  349. flaskbb_config["AVATAR_WIDTH"]
  350. )
  351. return error, False
  352. if img_info["height"] > flaskbb_config["AVATAR_HEIGHT"]:
  353. error = "Image is too high! {}px height is allowed.".format(
  354. flaskbb_config["AVATAR_HEIGHT"]
  355. )
  356. return error, False
  357. if img_info["size"] > flaskbb_config["AVATAR_SIZE"]:
  358. error = "Image is too big! {}kb are allowed.".format(
  359. flaskbb_config["AVATAR_SIZE"]
  360. )
  361. return error, False
  362. return error, True