helpers.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631
  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 ast
  10. import re
  11. import time
  12. import itertools
  13. import operator
  14. import os
  15. import glob
  16. from datetime import datetime, timedelta
  17. from pytz import UTC
  18. from PIL import ImageFile
  19. import requests
  20. import unidecode
  21. from flask import session, url_for, flash, redirect, request
  22. from jinja2 import Markup
  23. from babel.core import get_locale_identifier
  24. from babel.dates import format_timedelta as babel_format_timedelta
  25. from flask_babelplus import lazy_gettext as _
  26. from flask_themes2 import render_theme_template, get_themes_list
  27. from flask_login import current_user
  28. from werkzeug.local import LocalProxy
  29. from flaskbb._compat import range_method, text_type, iteritems, to_unicode, to_bytes
  30. from flaskbb.extensions import redis_store, babel
  31. from flaskbb.utils.settings import flaskbb_config
  32. from flaskbb.utils.markup import markdown
  33. from flask_allows import Permission
  34. _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+')
  35. def slugify(text, delim=u'-'):
  36. """Generates an slightly worse ASCII-only slug.
  37. Taken from the Flask Snippets page.
  38. :param text: The text which should be slugified
  39. :param delim: Default "-". The delimeter for whitespace
  40. """
  41. text = unidecode.unidecode(text)
  42. result = []
  43. for word in _punct_re.split(text.lower()):
  44. if word:
  45. result.append(word)
  46. return text_type(delim.join(result))
  47. def redirect_or_next(endpoint, **kwargs):
  48. """Redirects the user back to the page they were viewing or to a specified
  49. endpoint. Wraps Flasks :func:`Flask.redirect` function.
  50. :param endpoint: The fallback endpoint.
  51. """
  52. return redirect(
  53. request.args.get('next') or endpoint, **kwargs
  54. )
  55. def render_template(template, **context): # pragma: no cover
  56. """A helper function that uses the `render_theme_template` function
  57. without needing to edit all the views
  58. """
  59. if current_user.is_authenticated and current_user.theme:
  60. theme = current_user.theme
  61. else:
  62. theme = session.get('theme', flaskbb_config['DEFAULT_THEME'])
  63. return render_theme_template(theme, template, **context)
  64. def do_topic_action(topics, user, action, reverse):
  65. """Executes a specific action for topics. Returns a list with the modified
  66. topic objects.
  67. :param topics: A iterable with ``Topic`` objects.
  68. :param user: The user object which wants to perform the action.
  69. :param action: One of the following actions: locked, important and delete.
  70. :param reverse: If the action should be done in a reversed way.
  71. For example, to unlock a topic, ``reverse`` should be
  72. set to ``True``.
  73. """
  74. from flaskbb.utils.requirements import (IsAtleastModeratorInForum,
  75. CanDeleteTopic)
  76. from flaskbb.user.models import User
  77. from flaskbb.forum.models import Post
  78. if not Permission(IsAtleastModeratorInForum(forum=topics[0].forum)):
  79. flash(_("You do not have the permissions to execute this "
  80. "action."), "danger")
  81. return False
  82. modified_topics = 0
  83. if action != "delete":
  84. for topic in topics:
  85. if getattr(topic, action) and not reverse:
  86. continue
  87. setattr(topic, action, not reverse)
  88. modified_topics += 1
  89. topic.save()
  90. elif action == "delete":
  91. for topic in topics:
  92. if not Permission(CanDeleteTopic):
  93. flash(_("You do not have the permissions to delete this "
  94. "topic."), "danger")
  95. return False
  96. involved_users = User.query.filter(Post.topic_id == topic.id,
  97. User.id == Post.user_id).all()
  98. modified_topics += 1
  99. topic.delete(involved_users)
  100. return modified_topics
  101. def get_categories_and_forums(query_result, user):
  102. """Returns a list with categories. Every category has a list for all
  103. their associated forums.
  104. The structure looks like this::
  105. [(<Category 1>,
  106. [(<Forum 1>, None),
  107. (<Forum 2>, <flaskbb.forum.models.ForumsRead at 0x38fdb50>)]),
  108. (<Category 2>,
  109. [(<Forum 3>, None),
  110. (<Forum 4>, None)])]
  111. and to unpack the values you can do this::
  112. In [110]: for category, forums in x:
  113. .....: print category
  114. .....: for forum, forumsread in forums:
  115. .....: print "\t", forum, forumsread
  116. This will print something like this:
  117. <Category 1>
  118. <Forum 1> None
  119. <Forum 2> <flaskbb.forum.models.ForumsRead object at 0x38fdb50>
  120. <Category 2>
  121. <Forum 3> None
  122. <Forum 4> None
  123. :param query_result: A tuple (KeyedTuple) with all categories and forums
  124. :param user: The user object is needed because a signed out user does not
  125. have the ForumsRead relation joined.
  126. """
  127. it = itertools.groupby(query_result, operator.itemgetter(0))
  128. forums = []
  129. if user.is_authenticated:
  130. for key, value in it:
  131. forums.append((key, [(item[1], item[2]) for item in value]))
  132. else:
  133. for key, value in it:
  134. forums.append((key, [(item[1], None) for item in value]))
  135. return forums
  136. def get_forums(query_result, user):
  137. """Returns a tuple which contains the category and the forums as list.
  138. This is the counterpart for get_categories_and_forums and especially
  139. usefull when you just need the forums for one category.
  140. For example::
  141. (<Category 2>,
  142. [(<Forum 3>, None),
  143. (<Forum 4>, None)])
  144. :param query_result: A tuple (KeyedTuple) with all categories and forums
  145. :param user: The user object is needed because a signed out user does not
  146. have the ForumsRead relation joined.
  147. """
  148. it = itertools.groupby(query_result, operator.itemgetter(0))
  149. if user.is_authenticated:
  150. for key, value in it:
  151. forums = key, [(item[1], item[2]) for item in value]
  152. else:
  153. for key, value in it:
  154. forums = key, [(item[1], None) for item in value]
  155. return forums
  156. def forum_is_unread(forum, forumsread, user):
  157. """Checks if a forum is unread
  158. :param forum: The forum that should be checked if it is unread
  159. :param forumsread: The forumsread object for the forum
  160. :param user: The user who should be checked if he has read the forum
  161. """
  162. # If the user is not signed in, every forum is marked as read
  163. if not user.is_authenticated:
  164. return False
  165. read_cutoff = time_utcnow() - timedelta(
  166. days=flaskbb_config["TRACKER_LENGTH"])
  167. # disable tracker if TRACKER_LENGTH is set to 0
  168. if flaskbb_config["TRACKER_LENGTH"] == 0:
  169. return False
  170. # If there are no topics in the forum, mark it as read
  171. if forum and forum.topic_count == 0:
  172. return False
  173. # check if the last post is newer than the tracker length
  174. if forum.last_post_created < read_cutoff:
  175. return False
  176. # If the user hasn't visited a topic in the forum - therefore,
  177. # forumsread is None and we need to check if it is still unread
  178. if forum and not forumsread:
  179. return forum.last_post_created > read_cutoff
  180. try:
  181. # check if the forum has been cleared and if there is a new post
  182. # since it have been cleared
  183. if forum.last_post_created > forumsread.cleared:
  184. if forum.last_post_created < forumsread.last_read:
  185. return False
  186. except TypeError:
  187. pass
  188. # else just check if the user has read the last post
  189. return forum.last_post_created > forumsread.last_read
  190. def topic_is_unread(topic, topicsread, user, forumsread=None):
  191. """Checks if a topic is unread.
  192. :param topic: The topic that should be checked if it is unread
  193. :param topicsread: The topicsread object for the topic
  194. :param user: The user who should be checked if he has read the last post
  195. in the topic
  196. :param forumsread: The forumsread object in which the topic is. If you
  197. also want to check if the user has marked the forum as
  198. read, than you will also need to pass an forumsread
  199. object.
  200. """
  201. if not user.is_authenticated:
  202. return False
  203. read_cutoff = time_utcnow() - timedelta(
  204. days=flaskbb_config["TRACKER_LENGTH"])
  205. # disable tracker if read_cutoff is set to 0
  206. if flaskbb_config["TRACKER_LENGTH"] == 0:
  207. return False
  208. # check read_cutoff
  209. if topic.last_post.date_created < read_cutoff:
  210. return False
  211. # topicsread is none if the user has marked the forum as read
  212. # or if he hasn't visited the topic yet
  213. if topicsread is None:
  214. # user has cleared the forum - check if there is a new post
  215. if forumsread and forumsread.cleared is not None:
  216. return forumsread.cleared < topic.last_post.date_created
  217. # user hasn't read the topic yet, or there is a new post since the user
  218. # has marked the forum as read
  219. return True
  220. # check if there is a new post since the user's last topic visit
  221. return topicsread.last_read < topic.last_post.date_created
  222. def mark_online(user_id, guest=False): # pragma: no cover
  223. """Marks a user as online
  224. :param user_id: The id from the user who should be marked as online
  225. :param guest: If set to True, it will add the user to the guest activity
  226. instead of the user activity.
  227. Ref: http://flask.pocoo.org/snippets/71/
  228. """
  229. user_id = to_bytes(user_id)
  230. now = int(time.time())
  231. expires = now + (flaskbb_config['ONLINE_LAST_MINUTES'] * 60) + 10
  232. if guest:
  233. all_users_key = 'online-guests/%d' % (now // 60)
  234. user_key = 'guest-activity/%s' % user_id
  235. else:
  236. all_users_key = 'online-users/%d' % (now // 60)
  237. user_key = 'user-activity/%s' % user_id
  238. p = redis_store.pipeline()
  239. p.sadd(all_users_key, user_id)
  240. p.set(user_key, now)
  241. p.expireat(all_users_key, expires)
  242. p.expireat(user_key, expires)
  243. p.execute()
  244. def get_online_users(guest=False): # pragma: no cover
  245. """Returns all online users within a specified time range
  246. :param guest: If True, it will return the online guests
  247. """
  248. current = int(time.time()) // 60
  249. minutes = range_method(flaskbb_config['ONLINE_LAST_MINUTES'])
  250. if guest:
  251. users = redis_store.sunion(['online-guests/%d' % (current - x)
  252. for x in minutes])
  253. else:
  254. users = redis_store.sunion(['online-users/%d' % (current - x)
  255. for x in minutes])
  256. return [to_unicode(u) for u in users]
  257. def crop_title(title, length=None, suffix="..."):
  258. """Crops the title to a specified length
  259. :param title: The title that should be cropped
  260. :param suffix: The suffix which should be appended at the
  261. end of the title.
  262. """
  263. length = flaskbb_config['TITLE_LENGTH'] if length is None else length
  264. if len(title) <= length:
  265. return title
  266. return title[:length].rsplit(' ', 1)[0] + suffix
  267. def render_markup(text):
  268. """Renders the given text as markdown
  269. :param text: The text that should be rendered as markdown
  270. """
  271. return Markup(markdown.render(text))
  272. def is_online(user):
  273. """A simple check to see if the user was online within a specified
  274. time range
  275. :param user: The user who needs to be checked
  276. """
  277. return user.lastseen >= time_diff()
  278. def time_utcnow():
  279. """Returns a timezone aware utc timestamp."""
  280. return datetime.now(UTC)
  281. def time_diff():
  282. """Calculates the time difference between now and the ONLINE_LAST_MINUTES
  283. variable from the configuration.
  284. """
  285. now = time_utcnow()
  286. diff = now - timedelta(minutes=flaskbb_config['ONLINE_LAST_MINUTES'])
  287. return diff
  288. def format_date(value, format='%Y-%m-%d'):
  289. """Returns a formatted time string
  290. :param value: The datetime object that should be formatted
  291. :param format: How the result should look like. A full list of available
  292. directives is here: http://goo.gl/gNxMHE
  293. """
  294. return value.strftime(format)
  295. def format_timedelta(delta, **kwargs):
  296. """Wrapper around babel's format_timedelta to make it user language
  297. aware.
  298. """
  299. locale = flaskbb_config.get("DEFAULT_LANGUAGE", "en")
  300. if current_user.is_authenticated and current_user.language is not None:
  301. locale = current_user.language
  302. return babel_format_timedelta(delta, locale=locale, **kwargs)
  303. def time_since(time): # pragma: no cover
  304. """Returns a string representing time since e.g.
  305. 3 days ago, 5 hours ago.
  306. :param time: A datetime object
  307. """
  308. delta = time - time_utcnow()
  309. return format_timedelta(delta, add_direction=True)
  310. def format_quote(username, content):
  311. """Returns a formatted quote depending on the markup language.
  312. :param username: The username of a user.
  313. :param content: The content of the quote
  314. """
  315. profile_url = url_for('user.profile', username=username)
  316. content = "\n> ".join(content.strip().split('\n'))
  317. quote = u"**[{username}]({profile_url}) wrote:**\n> {content}\n".\
  318. format(username=username, profile_url=profile_url, content=content)
  319. return quote
  320. def get_image_info(url):
  321. """Returns the content-type, image size (kb), height and width of
  322. an image without fully downloading it.
  323. :param url: The URL of the image.
  324. """
  325. r = requests.get(url, stream=True)
  326. image_size = r.headers.get("content-length")
  327. image_size = float(image_size) / 1000 # in kilobyte
  328. image_max_size = 10000
  329. image_data = {
  330. "content_type": "",
  331. "size": image_size,
  332. "width": 0,
  333. "height": 0
  334. }
  335. # lets set a hard limit of 10MB
  336. if image_size > image_max_size:
  337. return image_data
  338. data = None
  339. parser = ImageFile.Parser()
  340. while True:
  341. data = r.raw.read(1024)
  342. if not data:
  343. break
  344. parser.feed(data)
  345. if parser.image:
  346. image_data["content_type"] = parser.image.format
  347. image_data["width"] = parser.image.size[0]
  348. image_data["height"] = parser.image.size[1]
  349. break
  350. return image_data
  351. def check_image(url):
  352. """A little wrapper for the :func:`get_image_info` function.
  353. If the image doesn't match the ``flaskbb_config`` settings it will
  354. return a tuple with a the first value is the custom error message and
  355. the second value ``False`` for not passing the check.
  356. If the check is successful, it will return ``None`` for the error message
  357. and ``True`` for the passed check.
  358. :param url: The image url to be checked.
  359. """
  360. img_info = get_image_info(url)
  361. error = None
  362. if img_info["size"] > flaskbb_config["AVATAR_SIZE"]:
  363. error = "Image is too big! {}kb are allowed.".format(
  364. flaskbb_config["AVATAR_SIZE"]
  365. )
  366. return error, False
  367. if not img_info["content_type"] in flaskbb_config["AVATAR_TYPES"]:
  368. error = "Image type {} is not allowed. Allowed types are: {}".format(
  369. img_info["content_type"],
  370. ", ".join(flaskbb_config["AVATAR_TYPES"])
  371. )
  372. return error, False
  373. if img_info["width"] > flaskbb_config["AVATAR_WIDTH"]:
  374. error = "Image is too wide! {}px width is allowed.".format(
  375. flaskbb_config["AVATAR_WIDTH"]
  376. )
  377. return error, False
  378. if img_info["height"] > flaskbb_config["AVATAR_HEIGHT"]:
  379. error = "Image is too high! {}px height is allowed.".format(
  380. flaskbb_config["AVATAR_HEIGHT"]
  381. )
  382. return error, False
  383. return error, True
  384. def get_alembic_branches():
  385. """Returns a tuple with (branchname, plugin_dir) combinations.
  386. The branchname is the name of plugin directory which should also be
  387. the unique identifier of the plugin.
  388. """
  389. basedir = os.path.dirname(os.path.dirname(__file__))
  390. plugin_migration_dirs = glob.glob(
  391. "{}/*/migrations".format(os.path.join(basedir, "plugins"))
  392. )
  393. branches_dirs = [
  394. tuple([os.path.basename(os.path.dirname(p)), p])
  395. for p in plugin_migration_dirs
  396. ]
  397. return branches_dirs
  398. def get_available_themes():
  399. """Returns a list that contains all available themes. The items in the
  400. list are tuples where the first item of the tuple is the identifier and
  401. the second one the name of the theme.
  402. For example::
  403. [('aurora_mod', 'Aurora Mod')]
  404. """
  405. return [(theme.identifier, theme.name) for theme in get_themes_list()]
  406. def get_available_languages():
  407. """Returns a list that contains all available languages. The items in the
  408. list are tuples where the first item of the tuple is the locale
  409. identifier (i.e. de_AT) and the second one the display name of the locale.
  410. For example::
  411. [('de_AT', 'Deutsch (Österreich)')]
  412. """
  413. return [
  414. (get_locale_identifier((l.language, l.territory)), l.display_name)
  415. for l in babel.list_translations()
  416. ]
  417. def app_config_from_env(app, prefix="FLASKBB_"):
  418. """Retrieves the configuration variables from the environment.
  419. Set your environment variables like this::
  420. export FLASKBB_SECRET_KEY="your-secret-key"
  421. and based on the prefix, it will set the actual config variable
  422. on the ``app.config`` object.
  423. :param app: The application object.
  424. :param prefix: The prefix of the environment variables.
  425. """
  426. for key, value in iteritems(os.environ):
  427. if key.startswith(prefix):
  428. key = key[len(prefix):]
  429. try:
  430. value = ast.literal_eval(value)
  431. except (ValueError, SyntaxError):
  432. pass
  433. app.config[key] = value
  434. return app
  435. class ReverseProxyPathFix(object):
  436. """Wrap the application in this middleware and configure the
  437. front-end server to add these headers, to let you quietly bind
  438. this to a URL other than / and to an HTTP scheme that is
  439. different than what is used locally.
  440. http://flask.pocoo.org/snippets/35/
  441. In wsgi.py::
  442. from flaskbb.utils.helpers import ReverseProxyPathFix
  443. flaskbb = create_app(config="flaskbb.cfg")
  444. flaskbb.wsgi_app = ReverseProxyPathFix(flaskbb.wsgi_app)
  445. and in nginx::
  446. location /forums {
  447. proxy_pass http://127.0.0.1:8000;
  448. proxy_set_header Host $host;
  449. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  450. proxy_set_header X-Scheme $scheme;
  451. proxy_set_header X-Script-Name /forums; # this part here
  452. }
  453. :param app: the WSGI application
  454. :param force_https: Force HTTPS on all routes. Defaults to ``False``.
  455. """
  456. def __init__(self, app, force_https=False):
  457. self.app = app
  458. self.force_https = force_https
  459. def __call__(self, environ, start_response):
  460. script_name = environ.get('HTTP_X_SCRIPT_NAME', '')
  461. if script_name:
  462. environ['SCRIPT_NAME'] = script_name
  463. path_info = environ.get('PATH_INFO', '')
  464. if path_info and path_info.startswith(script_name):
  465. environ['PATH_INFO'] = path_info[len(script_name):]
  466. server = environ.get('HTTP_X_FORWARDED_SERVER_CUSTOM',
  467. environ.get('HTTP_X_FORWARDED_SERVER', ''))
  468. if server:
  469. environ['HTTP_HOST'] = server
  470. scheme = environ.get('HTTP_X_SCHEME', '')
  471. if scheme:
  472. environ['wsgi.url_scheme'] = scheme
  473. if self.force_https:
  474. environ['wsgi.url_scheme'] = 'https'
  475. return self.app(environ, start_response)
  476. def real(obj):
  477. """
  478. Unwraps a werkzeug.local.LocalProxy object if given one, else returns the object
  479. """
  480. if isinstance(obj, LocalProxy):
  481. return obj._get_current_object()
  482. return obj