app.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. # -*- coding: utf-8 -*-
  2. """
  3. flaskbb.app
  4. ~~~~~~~~~~~
  5. manages the app creation and configuration process
  6. :copyright: (c) 2014 by the FlaskBB Team.
  7. :license: BSD, see LICENSE for more details.
  8. """
  9. import logging
  10. import logging.config
  11. import os
  12. import sys
  13. import time
  14. import warnings
  15. from datetime import datetime
  16. from flask import Flask, request
  17. from flask_login import current_user
  18. from sqlalchemy import event
  19. from sqlalchemy.engine import Engine
  20. from sqlalchemy.exc import OperationalError, ProgrammingError
  21. # extensions
  22. from flaskbb.extensions import (alembic, allows, babel, cache, celery, csrf,
  23. db, debugtoolbar, limiter, login_manager, mail,
  24. redis_store, themes, whooshee)
  25. from flaskbb.plugins import spec
  26. from flaskbb.plugins.manager import FlaskBBPluginManager
  27. from flaskbb.plugins.models import PluginRegistry
  28. from flaskbb.plugins.utils import remove_zombie_plugins_from_db, template_hook
  29. # models
  30. from flaskbb.user.models import Guest, User
  31. # various helpers
  32. from flaskbb.utils.helpers import (app_config_from_env, crop_title,
  33. format_date, format_datetime,
  34. forum_is_unread, get_alembic_locations,
  35. get_flaskbb_config, is_online, mark_online,
  36. render_template, time_since, time_utcnow,
  37. topic_is_unread)
  38. # permission checks (here they are used for the jinja filters)
  39. from flaskbb.utils.requirements import (CanBanUser, CanEditUser, IsAdmin,
  40. IsAtleastModerator, can_delete_topic,
  41. can_edit_post, can_moderate,
  42. can_post_reply, can_post_topic,
  43. has_permission,
  44. permission_with_identity)
  45. # whooshees
  46. from flaskbb.utils.search import (ForumWhoosheer, PostWhoosheer,
  47. TopicWhoosheer, UserWhoosheer)
  48. # app specific configurations
  49. from flaskbb.utils.settings import flaskbb_config
  50. from flaskbb.utils.translations import FlaskBBDomain
  51. from . import markup # noqa
  52. from .auth import views as auth_views # noqa
  53. from .deprecation import FlaskBBDeprecation
  54. from .display.navigation import NavigationContentType
  55. from .forum import views as forum_views # noqa
  56. from .management import views as management_views # noqa
  57. from .user import views as user_views # noqa
  58. logger = logging.getLogger(__name__)
  59. def create_app(config=None, instance_path=None):
  60. """Creates the app.
  61. :param instance_path: An alternative instance path for the application.
  62. By default the folder ``'instance'`` next to the
  63. package or module is assumed to be the instance
  64. path.
  65. See :ref:`Instance Folders <flask:instance-folders>`.
  66. :param config: The configuration file or object.
  67. The environment variable is weightet as the heaviest.
  68. For example, if the config is specified via an file
  69. and a ENVVAR, it will load the config via the file and
  70. later overwrite it from the ENVVAR.
  71. """
  72. app = Flask(
  73. "flaskbb", instance_path=instance_path, instance_relative_config=True
  74. )
  75. # instance folders are not automatically created by flask
  76. if not os.path.exists(app.instance_path):
  77. os.makedirs(app.instance_path)
  78. configure_app(app, config)
  79. configure_celery_app(app, celery)
  80. configure_extensions(app)
  81. load_plugins(app)
  82. configure_blueprints(app)
  83. configure_template_filters(app)
  84. configure_context_processors(app)
  85. configure_before_handlers(app)
  86. configure_errorhandlers(app)
  87. configure_migrations(app)
  88. configure_translations(app)
  89. app.pluggy.hook.flaskbb_additional_setup(app=app, pluggy=app.pluggy)
  90. return app
  91. def configure_app(app, config):
  92. """Configures FlaskBB."""
  93. # Use the default config and override it afterwards
  94. app.config.from_object("flaskbb.configs.default.DefaultConfig")
  95. config = get_flaskbb_config(app, config)
  96. # Path
  97. if isinstance(config, str):
  98. app.config.from_pyfile(config)
  99. # Module
  100. else:
  101. # try to update the config from the object
  102. app.config.from_object(config)
  103. # Add the location of the config to the config
  104. app.config["CONFIG_PATH"] = config
  105. # Environment
  106. # Get config file from envvar
  107. app.config.from_envvar("FLASKBB_SETTINGS", silent=True)
  108. # Parse the env for FLASKBB_ prefixed env variables and set
  109. # them on the config object
  110. app_config_from_env(app, prefix="FLASKBB_")
  111. # Migrate Celery 4.x config to Celery 6.x
  112. old_celery_config = app.config.get_namespace('CELERY_')
  113. celery_config = {}
  114. for key, value in old_celery_config.items():
  115. # config is the new format
  116. if key != "config":
  117. config_key = f"CELERY_{key.upper()}"
  118. celery_config[key] = value
  119. try:
  120. del app.config[config_key]
  121. except KeyError:
  122. pass
  123. # merge the new config with the old one
  124. new_celery_config = app.config.get("CELERY_CONFIG")
  125. new_celery_config.update(celery_config)
  126. app.config.update({"CELERY_CONFIG": new_celery_config})
  127. # Setting up logging as early as possible
  128. configure_logging(app)
  129. if not isinstance(config, str) and config is not None:
  130. config_name = "{}.{}".format(config.__module__, config.__name__)
  131. else:
  132. config_name = config
  133. logger.info("Using config from: {}".format(config_name))
  134. deprecation_level = app.config.get("DEPRECATION_LEVEL", "default")
  135. # never set the deprecation level during testing, pytest will handle it
  136. if not app.testing: # pragma: no branch
  137. warnings.simplefilter(deprecation_level, FlaskBBDeprecation)
  138. debug_panels = app.config.setdefault(
  139. "DEBUG_TB_PANELS",
  140. [
  141. "flask_debugtoolbar.panels.versions.VersionDebugPanel",
  142. "flask_debugtoolbar.panels.timer.TimerDebugPanel",
  143. "flask_debugtoolbar.panels.headers.HeaderDebugPanel",
  144. "flask_debugtoolbar.panels.request_vars.RequestVarsDebugPanel",
  145. "flask_debugtoolbar.panels.config_vars.ConfigVarsDebugPanel",
  146. "flask_debugtoolbar.panels.template.TemplateDebugPanel",
  147. "flask_debugtoolbar.panels.sqlalchemy.SQLAlchemyDebugPanel",
  148. "flask_debugtoolbar.panels.logger.LoggingPanel",
  149. "flask_debugtoolbar.panels.route_list.RouteListDebugPanel",
  150. "flask_debugtoolbar.panels.profiler.ProfilerDebugPanel",
  151. ],
  152. )
  153. if all("WarningsPanel" not in p for p in debug_panels):
  154. debug_panels.append("flask_debugtoolbar_warnings.WarningsPanel")
  155. app.pluggy = FlaskBBPluginManager("flaskbb")
  156. def configure_celery_app(app, celery):
  157. """Configures the celery app."""
  158. celery.conf.update(app.config.get("CELERY_CONFIG"))
  159. TaskBase = celery.Task
  160. class ContextTask(TaskBase):
  161. def __call__(self, *args, **kwargs):
  162. with app.app_context():
  163. return TaskBase.__call__(self, *args, **kwargs)
  164. celery.Task = ContextTask
  165. def configure_blueprints(app):
  166. app.pluggy.hook.flaskbb_load_blueprints(app=app)
  167. def configure_extensions(app):
  168. """Configures the extensions."""
  169. # Flask-Allows
  170. allows.init_app(app)
  171. allows.identity_loader(lambda: current_user)
  172. # Flask-WTF CSRF
  173. csrf.init_app(app)
  174. # Flask-SQLAlchemy
  175. db.init_app(app)
  176. # Flask-Alembic
  177. alembic.init_app(app, command_name="db")
  178. # Flask-Mail
  179. mail.init_app(app)
  180. # Flask-Cache
  181. cache.init_app(app)
  182. # Flask-Debugtoolbar
  183. debugtoolbar.init_app(app)
  184. # Flask-Themes
  185. themes.init_themes(app, app_identifier="flaskbb")
  186. # Flask-And-Redis
  187. redis_store.init_app(app)
  188. # Flask-Limiter
  189. limiter.init_app(app)
  190. # Flask-Whooshee
  191. whooshee.init_app(app)
  192. # not needed for unittests - and it will speed up testing A LOT
  193. if not app.testing:
  194. whooshee.register_whoosheer(PostWhoosheer)
  195. whooshee.register_whoosheer(TopicWhoosheer)
  196. whooshee.register_whoosheer(ForumWhoosheer)
  197. whooshee.register_whoosheer(UserWhoosheer)
  198. # Flask-Login
  199. login_manager.login_view = app.config["LOGIN_VIEW"]
  200. login_manager.refresh_view = app.config["REAUTH_VIEW"]
  201. login_manager.login_message_category = app.config["LOGIN_MESSAGE_CATEGORY"]
  202. login_manager.needs_refresh_message_category = app.config[
  203. "REFRESH_MESSAGE_CATEGORY"
  204. ]
  205. login_manager.anonymous_user = Guest
  206. @login_manager.user_loader
  207. def load_user(user_id):
  208. """Loads the user. Required by the `login` extension."""
  209. user_instance = User.query.filter_by(id=user_id).first()
  210. if user_instance:
  211. return user_instance
  212. else:
  213. return None
  214. login_manager.init_app(app)
  215. def configure_template_filters(app):
  216. """Configures the template filters."""
  217. filters = {}
  218. filters["crop_title"] = crop_title
  219. filters["format_date"] = format_date
  220. filters["format_datetime"] = format_datetime
  221. filters["forum_is_unread"] = forum_is_unread
  222. filters["is_online"] = is_online
  223. filters["time_since"] = time_since
  224. filters["topic_is_unread"] = topic_is_unread
  225. permissions = [
  226. ("is_admin", IsAdmin),
  227. ("is_moderator", IsAtleastModerator),
  228. ("is_admin_or_moderator", IsAtleastModerator),
  229. ("can_edit_user", CanEditUser),
  230. ("can_ban_user", CanBanUser),
  231. ]
  232. filters.update(
  233. (name, permission_with_identity(perm, name=name))
  234. for name, perm in permissions
  235. )
  236. filters["can_moderate"] = can_moderate
  237. filters["post_reply"] = can_post_reply
  238. filters["edit_post"] = can_edit_post
  239. filters["delete_post"] = can_edit_post
  240. filters["post_topic"] = can_post_topic
  241. filters["delete_topic"] = can_delete_topic
  242. filters["has_permission"] = has_permission
  243. app.jinja_env.filters.update(filters)
  244. app.jinja_env.globals["run_hook"] = template_hook
  245. app.jinja_env.globals["NavigationContentType"] = NavigationContentType
  246. app.pluggy.hook.flaskbb_jinja_directives(app=app)
  247. def configure_context_processors(app):
  248. """Configures the context processors."""
  249. @app.context_processor
  250. def inject_flaskbb_config():
  251. """Injects the ``flaskbb_config`` config variable into the
  252. templates.
  253. """
  254. return dict(flaskbb_config=flaskbb_config, format_date=format_date)
  255. @app.context_processor
  256. def inject_now():
  257. """Injects the current time."""
  258. return dict(now=datetime.utcnow())
  259. def configure_before_handlers(app):
  260. """Configures the before request handlers."""
  261. @app.before_request
  262. def update_lastseen():
  263. """Updates `lastseen` before every reguest if the user is
  264. authenticated."""
  265. if current_user.is_authenticated:
  266. current_user.lastseen = time_utcnow()
  267. db.session.add(current_user)
  268. db.session.commit()
  269. if app.config["REDIS_ENABLED"]:
  270. @app.before_request
  271. def mark_current_user_online():
  272. if current_user.is_authenticated:
  273. mark_online(current_user.username)
  274. else:
  275. mark_online(request.remote_addr, guest=True)
  276. app.pluggy.hook.flaskbb_request_processors(app=app)
  277. def configure_errorhandlers(app):
  278. """Configures the error handlers."""
  279. @app.errorhandler(403)
  280. def forbidden_page(error):
  281. return render_template("errors/forbidden_page.html"), 403
  282. @app.errorhandler(404)
  283. def page_not_found(error):
  284. return render_template("errors/page_not_found.html"), 404
  285. @app.errorhandler(500)
  286. def server_error_page(error):
  287. return render_template("errors/server_error.html"), 500
  288. app.pluggy.hook.flaskbb_errorhandlers(app=app)
  289. def configure_migrations(app):
  290. """Configure migrations."""
  291. plugin_dirs = app.pluggy.hook.flaskbb_load_migrations()
  292. version_locations = get_alembic_locations(plugin_dirs)
  293. app.config["ALEMBIC"]["version_locations"] = version_locations
  294. def configure_translations(app):
  295. """Configure translations."""
  296. # we have to initialize the extension after we have loaded the plugins
  297. # because we of the 'flaskbb_load_translations' hook
  298. babel.init_app(app=app, default_domain=FlaskBBDomain(app))
  299. @babel.localeselector
  300. def get_locale():
  301. # if a user is logged in, use the locale from the user settings
  302. if (
  303. current_user
  304. and current_user.is_authenticated
  305. and current_user.language
  306. ):
  307. return current_user.language
  308. # otherwise we will just fallback to the default language
  309. return flaskbb_config["DEFAULT_LANGUAGE"]
  310. def configure_logging(app):
  311. """Configures logging."""
  312. if app.config.get("USE_DEFAULT_LOGGING"):
  313. configure_default_logging(app)
  314. if app.config.get("LOG_CONF_FILE"):
  315. logging.config.fileConfig(
  316. app.config["LOG_CONF_FILE"], disable_existing_loggers=False
  317. )
  318. if app.config["SQLALCHEMY_ECHO"]:
  319. # Ref: http://stackoverflow.com/a/8428546
  320. @event.listens_for(Engine, "before_cursor_execute")
  321. def before_cursor_execute(
  322. conn, cursor, statement, parameters, context, executemany
  323. ):
  324. conn.info.setdefault("query_start_time", []).append(time.time())
  325. @event.listens_for(Engine, "after_cursor_execute")
  326. def after_cursor_execute(
  327. conn, cursor, statement, parameters, context, executemany
  328. ):
  329. total = time.time() - conn.info["query_start_time"].pop(-1)
  330. app.logger.debug("Total Time: %f", total)
  331. def configure_default_logging(app):
  332. # Load default logging config
  333. logging.config.dictConfig(app.config["LOG_DEFAULT_CONF"])
  334. if app.config["SEND_LOGS"]:
  335. configure_mail_logs(app)
  336. def configure_mail_logs(app, formatter):
  337. from logging.handlers import SMTPHandler
  338. formatter = logging.Formatter(
  339. "%(asctime)s %(levelname)-7s %(name)-25s %(message)s"
  340. )
  341. mail_handler = SMTPHandler(
  342. app.config["MAIL_SERVER"],
  343. app.config["MAIL_DEFAULT_SENDER"],
  344. app.config["ADMINS"],
  345. "application error, no admins specified",
  346. (app.config["MAIL_USERNAME"], app.config["MAIL_PASSWORD"]),
  347. )
  348. mail_handler.setLevel(logging.ERROR)
  349. mail_handler.setFormatter(formatter)
  350. app.logger.addHandler(mail_handler)
  351. def load_plugins(app):
  352. app.pluggy.add_hookspecs(spec)
  353. # have to find all the flaskbb modules that are loaded this way
  354. # otherwise sys.modules might change while we're iterating it
  355. # because of imports and that makes Python very unhappy
  356. # we are not interested in duplicated plugins or invalid ones
  357. # ('None' - appears on py2) and thus using a set
  358. flaskbb_modules = set(
  359. module
  360. for name, module in sys.modules.items()
  361. if name.startswith("flaskbb")
  362. )
  363. for module in flaskbb_modules:
  364. app.pluggy.register(module, internal=True)
  365. try:
  366. with app.app_context():
  367. plugins = PluginRegistry.query.all()
  368. except (OperationalError, ProgrammingError) as exc:
  369. logger.debug(
  370. "Database is not setup correctly or has not been " "setup yet.",
  371. exc_info=exc,
  372. )
  373. # load plugins even though the database isn't setup correctly
  374. # i.e. when creating the initial database and wanting to install
  375. # the plugins migration as well
  376. app.pluggy.load_setuptools_entrypoints("flaskbb_plugins")
  377. return
  378. for plugin in plugins:
  379. if not plugin.enabled:
  380. app.pluggy.set_blocked(plugin.name)
  381. app.pluggy.load_setuptools_entrypoints("flaskbb_plugins")
  382. app.pluggy.hook.flaskbb_extensions(app=app)
  383. loaded_names = set([p[0] for p in app.pluggy.list_name_plugin()])
  384. registered_names = set([p.name for p in plugins])
  385. unregistered = [
  386. PluginRegistry(name=name)
  387. for name in loaded_names - registered_names
  388. # ignore internal FlaskBB modules
  389. if not name.startswith("flaskbb.") and name != "flaskbb"
  390. ]
  391. with app.app_context():
  392. db.session.add_all(unregistered)
  393. db.session.commit()
  394. removed = 0
  395. if app.config["REMOVE_DEAD_PLUGINS"]:
  396. removed = remove_zombie_plugins_from_db()
  397. logger.info("Removed Plugins: {}".format(removed))
  398. # we need a copy of it because of
  399. # RuntimeError: dictionary changed size during iteration
  400. tasks = celery.tasks.copy()
  401. disabled_plugins = [
  402. p.__package__ for p in app.pluggy.get_disabled_plugins()
  403. ]
  404. for task_name, task in tasks.items():
  405. if task.__module__.split(".")[0] in disabled_plugins:
  406. logger.debug("Unregistering task: '{}'".format(task))
  407. celery.tasks.unregister(task_name)