helpers.py 22 KB

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