helpers.py 20 KB

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