helpers.py 14 KB

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