flask_app.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. # -*- coding: utf-8 -*-
  2. """
  3. (c) 2014-2018 - Copyright Red Hat Inc
  4. Authors:
  5. Pierre-Yves Chibon <pingou@pingoured.fr>
  6. """
  7. from __future__ import absolute_import, unicode_literals
  8. import datetime
  9. import gc
  10. import logging
  11. import os
  12. import string
  13. import time
  14. import warnings
  15. import flask
  16. from flask.wrappers import Request
  17. import pygit2
  18. from six.moves.urllib.parse import urljoin
  19. from whitenoise import WhiteNoise
  20. import pagure.doc_utils
  21. import pagure.exceptions
  22. import pagure.forms
  23. import pagure.lib.git
  24. import pagure.lib.query
  25. import pagure.login_forms
  26. import pagure.mail_logging
  27. import pagure.proxy
  28. import pagure.utils
  29. from pagure.config import config as pagure_config
  30. from pagure.utils import get_repo_path
  31. if os.environ.get("PAGURE_PERFREPO"):
  32. import pagure.perfrepo as perfrepo
  33. else:
  34. perfrepo = None
  35. logger = logging.getLogger(__name__)
  36. REDIS = None
  37. if (
  38. pagure_config["EVENTSOURCE_SOURCE"]
  39. or pagure_config["WEBHOOK"]
  40. or pagure_config.get("PAGURE_CI_SERVICES")
  41. ):
  42. pagure.lib.query.set_redis(
  43. host=pagure_config.get("REDIS_HOST", None),
  44. port=pagure_config.get("REDIS_PORT", None),
  45. socket=pagure_config.get("REDIS_SOCKET", None),
  46. dbname=pagure_config["REDIS_DB"],
  47. )
  48. if pagure_config.get("PAGURE_CI_SERVICES"):
  49. pagure.lib.query.set_pagure_ci(pagure_config["PAGURE_CI_SERVICES"])
  50. # Enforce behavior of 'get_json' to return json even if
  51. # Content-Type application/json is missing as in werkzeug < v2.1.0
  52. # https://github.com/pallets/flask/issues/4552#issuecomment-1109785314
  53. class AnyJsonRequest(Request):
  54. def on_json_loading_failed(self, e):
  55. if e is not None:
  56. return super().on_json_loading_failed(e)
  57. def create_app(config=None):
  58. """Create the flask application."""
  59. app = flask.Flask(__name__)
  60. app.config = pagure_config
  61. app.request_class = AnyJsonRequest
  62. if config:
  63. app.config.update(config)
  64. if app.config.get("SESSION_TYPE", None) is not None:
  65. import flask_session
  66. flask_session.Session(app)
  67. pagure.utils.set_up_logging(app=app)
  68. @app.errorhandler(500)
  69. def fatal_error(error): # pragma: no cover
  70. """500 Fatal Error page"""
  71. logger.exception("Error while processing request")
  72. return flask.render_template("fatal_error.html", error=error), 500
  73. app.jinja_env.trim_blocks = True
  74. app.jinja_env.lstrip_blocks = True
  75. if perfrepo:
  76. # Do this as early as possible.
  77. # We want the perfrepo before_request to be the very first thing
  78. # to be run, so that we can properly setup the stats before the
  79. # request.
  80. app.before_request(perfrepo.reset_stats)
  81. auth = pagure_config.get("PAGURE_AUTH", None)
  82. if auth in ["fas", "openid"]:
  83. # Only import and set flask_fas_openid if it is needed
  84. from pagure.ui.fas_login import FAS
  85. FAS.init_app(app)
  86. elif auth == "oidc":
  87. # Only import and set flask_fas_openid if it is needed
  88. from pagure.ui.oidc_login import fas_user_from_oidc, oidc
  89. oidc.init_app(app)
  90. app.before_request(fas_user_from_oidc)
  91. if auth == "local":
  92. # Only import the login controller if the app is set up for local login
  93. import pagure.ui.login as login
  94. app.before_request(login._check_session_cookie)
  95. app.after_request(login._send_session_cookie)
  96. # Support proxy
  97. app.wsgi_app = pagure.proxy.ReverseProxied(app.wsgi_app)
  98. # Back port 'equalto' to older version of jinja2
  99. app.jinja_env.tests.setdefault(
  100. "equalto", lambda value, other: value == other
  101. )
  102. # Import the application
  103. from pagure.api import API # noqa: E402
  104. app.register_blueprint(API)
  105. from pagure.ui import UI_NS # noqa: E402
  106. app.register_blueprint(UI_NS)
  107. from pagure.internal import PV # noqa: E402
  108. app.register_blueprint(PV)
  109. # Import 3rd party blueprints
  110. plugin_config = flask.config.Config("")
  111. if "PAGURE_PLUGIN" in os.environ:
  112. # Warn the user about deprecated variable (defaults to stderr)
  113. warnings.warn(
  114. "The environment variable PAGURE_PLUGIN is deprecated and will be "
  115. "removed in future releases of Pagure. Please replace it with "
  116. "PAGURE_PLUGINS_CONFIG instead.",
  117. FutureWarning,
  118. )
  119. # Log usage of deprecated variable
  120. logger.warning(
  121. "Using deprecated variable PAGURE_PLUGIN. "
  122. "You should use PAGURE_PLUGINS_CONFIG instead."
  123. )
  124. plugin_config.from_envvar("PAGURE_PLUGIN")
  125. elif "PAGURE_PLUGINS_CONFIG" in os.environ:
  126. plugin_config.from_envvar("PAGURE_PLUGINS_CONFIG")
  127. elif "PAGURE_PLUGINS_CONFIG" in app.config:
  128. # If the os.environ["PAGURE_PLUGINS_CONFIG"] is not set, we try to load
  129. # it from the pagure config file.
  130. plugin_config.from_pyfile(app.config.get("PAGURE_PLUGINS_CONFIG"))
  131. for blueprint in plugin_config.get("PLUGINS") or []:
  132. logger.info("Loading blueprint: %s", blueprint.name)
  133. app.register_blueprint(blueprint)
  134. themename = pagure_config.get("THEME", "default")
  135. here = os.path.abspath(
  136. os.path.join(os.path.dirname(os.path.abspath(__file__)))
  137. )
  138. themeblueprint = flask.Blueprint(
  139. "theme",
  140. __name__,
  141. static_url_path="/theme/static",
  142. static_folder=os.path.join(here, "themes", themename, "static"),
  143. )
  144. # Jinja can be told to look for templates in different folders
  145. # That's what we do here
  146. template_folders = os.path.join(
  147. app.root_path,
  148. app.template_folder,
  149. os.path.join(here, "themes", themename, "templates"),
  150. )
  151. import jinja2
  152. # Jinja looks for the template in the order of the folders specified
  153. templ_loaders = [
  154. jinja2.FileSystemLoader(template_folders),
  155. app.jinja_loader,
  156. ]
  157. app.jinja_loader = jinja2.ChoiceLoader(templ_loaders)
  158. app.register_blueprint(themeblueprint)
  159. # Setup WhiteNoise for serving static files
  160. app.wsgi_app = WhiteNoise(
  161. app.wsgi_app, root=os.path.join(here, "static"), prefix="/static"
  162. )
  163. app.before_request(set_request)
  164. app.after_request(after_request)
  165. app.teardown_request(end_request)
  166. if perfrepo:
  167. # Do this at the very end, so that this after_request comes last.
  168. app.after_request(perfrepo.print_stats)
  169. app.add_url_rule("/login/", view_func=auth_login, methods=["GET", "POST"])
  170. app.add_url_rule("/logout/", view_func=auth_logout)
  171. return app
  172. def admin_session_timedout():
  173. """Check if the current user has been authenticated for more than what
  174. is allowed (defaults to 15 minutes).
  175. If it is the case, the user is logged out and the method returns True,
  176. otherwise it returns False.
  177. """
  178. timedout = False
  179. if not pagure.utils.authenticated():
  180. return True
  181. login_time = flask.g.fas_user.login_time
  182. # This is because flask_fas_openid will store this as a posix timestamp
  183. if not isinstance(login_time, datetime.datetime):
  184. login_time = datetime.datetime.utcfromtimestamp(login_time)
  185. if (datetime.datetime.utcnow() - login_time) > pagure_config.get(
  186. "ADMIN_SESSION_LIFETIME", datetime.timedelta(minutes=15)
  187. ):
  188. timedout = True
  189. logout()
  190. return timedout
  191. def logout():
  192. """Log out the user currently logged in in the application"""
  193. auth = pagure_config.get("PAGURE_AUTH", None)
  194. if auth in ["fas", "openid"]:
  195. if hasattr(flask.g, "fas_user") and flask.g.fas_user is not None:
  196. from pagure.ui.fas_login import FAS
  197. FAS.logout()
  198. elif auth == "oidc":
  199. from pagure.ui.oidc_login import oidc_logout
  200. oidc_logout()
  201. elif auth == "local":
  202. import pagure.ui.login as login
  203. login.logout()
  204. def set_request():
  205. """Prepare every request."""
  206. flask.session.permanent = True
  207. if not hasattr(flask.g, "session") or not flask.g.session:
  208. flask.g.session = pagure.lib.model_base.create_session(
  209. flask.current_app.config["DB_URL"]
  210. )
  211. flask.g.main_app = flask.current_app
  212. flask.g.version = pagure.__version__
  213. flask.g.confirmationform = pagure.forms.ConfirmationForm()
  214. flask.g.nonce = pagure.lib.login.id_generator(
  215. size=25, chars=string.ascii_letters + string.digits
  216. )
  217. flask.g.issues_enabled = pagure_config.get("ENABLE_TICKETS", True)
  218. # The API namespace has its own way of getting repo and username and
  219. # of handling errors
  220. if flask.request.blueprint == "api_ns":
  221. return
  222. flask.g.forkbuttonform = None
  223. if pagure.utils.authenticated():
  224. flask.g.forkbuttonform = pagure.forms.ConfirmationForm()
  225. # Force logout if current session started before users'
  226. # refuse_sessions_before
  227. login_time = flask.g.fas_user.login_time
  228. # This is because flask_fas_openid will store this as a posix timestamp
  229. if not isinstance(login_time, datetime.datetime):
  230. login_time = datetime.datetime.utcfromtimestamp(login_time)
  231. user = _get_user(username=flask.g.fas_user.username)
  232. if (
  233. user.refuse_sessions_before
  234. and login_time < user.refuse_sessions_before
  235. ):
  236. logout()
  237. return flask.redirect(flask.url_for("ui_ns.index"))
  238. flask.g.justlogedout = flask.session.get("_justloggedout", False)
  239. if flask.g.justlogedout:
  240. flask.session["_justloggedout"] = None
  241. flask.g.new_user = False
  242. if flask.session.get("_new_user"):
  243. flask.g.new_user = True
  244. flask.session["_new_user"] = False
  245. flask.g.authenticated = pagure.utils.authenticated()
  246. flask.g.admin = pagure.utils.is_admin()
  247. # Retrieve the variables in the URL
  248. args = flask.request.view_args or {}
  249. # Check if there is a `repo` and an `username`
  250. repo = args.get("repo")
  251. username = args.get("username")
  252. namespace = args.get("namespace")
  253. # If there isn't a `repo` in the URL path, or if there is but the
  254. # endpoint called is part of the API, just don't do anything
  255. if repo:
  256. flask.g.repo = pagure.lib.query.get_authorized_project(
  257. flask.g.session, repo, user=username, namespace=namespace
  258. )
  259. if flask.g.authenticated:
  260. flask.g.repo_forked = pagure.lib.query.get_authorized_project(
  261. flask.g.session,
  262. repo,
  263. user=flask.g.fas_user.username,
  264. namespace=namespace,
  265. )
  266. flask.g.repo_starred = pagure.lib.query.has_starred(
  267. flask.g.session, flask.g.repo, user=flask.g.fas_user.username
  268. )
  269. # Block all POST request from blocked users
  270. if flask.g.repo and flask.request.method != "GET":
  271. if flask.g.fas_user.username in flask.g.repo.block_users:
  272. flask.abort(
  273. 403,
  274. description="You have been blocked from this project",
  275. )
  276. if (
  277. not flask.g.repo
  278. and namespace
  279. and pagure_config.get("OLD_VIEW_COMMIT_ENABLED", False)
  280. and len(repo) == 40
  281. ):
  282. return flask.redirect(
  283. flask.url_for(
  284. "ui_ns.view_commit",
  285. repo=namespace,
  286. commitid=repo,
  287. username=username,
  288. namespace=None,
  289. )
  290. )
  291. if flask.g.repo is None:
  292. flask.abort(404, description="Project not found")
  293. # If issues are not globally enabled, there is no point in continuing
  294. if flask.g.issues_enabled:
  295. ticket_namespaces = pagure_config.get("ENABLE_TICKETS_NAMESPACE")
  296. if ticket_namespaces and flask.g.repo.namespace:
  297. if flask.g.repo.namespace in (ticket_namespaces or []):
  298. # If the namespace is in the allowed list
  299. # issues are enabled
  300. flask.g.issues_enabled = True
  301. else:
  302. # If the namespace isn't in the list of namespaces
  303. # issues are disabled
  304. flask.g.issues_enabled = False
  305. flask.g.issues_project_disabled = False
  306. if not flask.g.repo.settings.get("issue_tracker", True):
  307. # If the project specifically disabled its issue tracker,
  308. # disable issues
  309. flask.g.issues_project_disabled = True
  310. flask.g.issues_enabled = False
  311. flask.g.reponame = get_repo_path(flask.g.repo)
  312. flask.g.repo_obj = pygit2.Repository(flask.g.reponame)
  313. flask.g.repo_admin = pagure.utils.is_repo_admin(flask.g.repo)
  314. flask.g.repo_committer = pagure.utils.is_repo_committer(flask.g.repo)
  315. if flask.g.authenticated and not flask.g.repo_committer:
  316. flask.g.repo_committer = flask.g.fas_user.username in [
  317. u.user.username for u in flask.g.repo.collaborators
  318. ]
  319. flask.g.repo_user = pagure.utils.is_repo_user(flask.g.repo)
  320. flask.g.branches = sorted(flask.g.repo_obj.listall_branches())
  321. repouser = flask.g.repo.user.user if flask.g.repo.is_fork else None
  322. fas_user = flask.g.fas_user if pagure.utils.authenticated() else None
  323. flask.g.repo_watch_levels = pagure.lib.query.get_watch_level_on_repo(
  324. flask.g.session,
  325. fas_user,
  326. flask.g.repo.name,
  327. repouser=repouser,
  328. namespace=namespace,
  329. )
  330. items_per_page = pagure_config["ITEM_PER_PAGE"]
  331. flask.g.offset = 0
  332. flask.g.page = 1
  333. flask.g.limit = items_per_page
  334. page = flask.request.args.get("page")
  335. limit = flask.request.args.get("n")
  336. if limit:
  337. try:
  338. limit = int(limit)
  339. except ValueError:
  340. limit = 10
  341. if limit > 500 or limit <= 0:
  342. limit = items_per_page
  343. flask.g.limit = limit
  344. if page:
  345. try:
  346. page = abs(int(page))
  347. except ValueError:
  348. page = 1
  349. if page <= 0:
  350. page = 1
  351. flask.g.page = page
  352. flask.g.offset = (page - 1) * flask.g.limit
  353. def auth_login(): # pragma: no cover
  354. """Method to log into the application using FAS OpenID."""
  355. return_point = flask.url_for("ui_ns.index")
  356. if "next" in flask.request.args:
  357. if pagure.utils.is_safe_url(flask.request.args["next"]):
  358. return_point = urljoin(
  359. flask.request.host_url, flask.request.args["next"]
  360. )
  361. authenticated = pagure.utils.authenticated()
  362. auth = pagure_config.get("PAGURE_AUTH", None)
  363. if not authenticated and auth == "oidc":
  364. from pagure.ui.oidc_login import fas_user_from_oidc, oidc, set_user
  365. # If oidc is used and user hits this endpoint, it will redirect
  366. # to IdP with destination=<pagure>/login?next=<location>
  367. # After confirming user identity, the IdP will redirect user here
  368. # again, but this time oidc.user_loggedin will be True and thus
  369. # execution will go through the else clause, making the Pagure
  370. # authentication machinery pick the user up
  371. if not oidc.user_loggedin:
  372. return oidc.redirect_to_auth_server(flask.request.url)
  373. else:
  374. flask.session["oidc_logintime"] = time.time()
  375. fas_user_from_oidc()
  376. authenticated = pagure.utils.authenticated()
  377. set_user()
  378. if authenticated:
  379. return flask.redirect(return_point)
  380. admins = pagure_config["ADMIN_GROUP"]
  381. if admins:
  382. if isinstance(admins, list):
  383. admins = set(admins)
  384. else: # pragma: no cover
  385. admins = set([admins])
  386. else:
  387. admins = set()
  388. if auth in ["fas", "openid"]:
  389. from pagure.ui.fas_login import FAS
  390. groups = set()
  391. if not pagure_config.get("ENABLE_GROUP_MNGT", False):
  392. groups = [
  393. group.group_name
  394. for group in pagure.lib.query.search_groups(
  395. flask.g.session, group_type="user"
  396. )
  397. ]
  398. groups = set(groups).union(admins)
  399. if auth == "fas":
  400. groups.add("signed_fpca")
  401. ext_committer = set(pagure_config.get("EXTERNAL_COMMITTER", {}))
  402. groups = set(groups).union(ext_committer)
  403. flask.g.unsafe_javascript = True
  404. return FAS.login(return_url=return_point, groups=groups)
  405. elif auth == "local":
  406. form = pagure.login_forms.LoginForm()
  407. return flask.render_template(
  408. "login/login.html", next_url=return_point, form=form
  409. )
  410. def auth_logout(): # pragma: no cover
  411. """Method to log out from the application."""
  412. return_point = flask.url_for("ui_ns.index")
  413. if "next" in flask.request.args:
  414. if pagure.utils.is_safe_url(flask.request.args["next"]):
  415. return_point = urljoin(
  416. flask.request.host_url, flask.request.args["next"]
  417. )
  418. if not pagure.utils.authenticated():
  419. return flask.redirect(return_point)
  420. logout()
  421. flask.flash("You have been logged out")
  422. flask.session["_justloggedout"] = True
  423. return flask.redirect(return_point)
  424. # pylint: disable=unused-argument
  425. def end_request(exception=None):
  426. """This method is called at the end of each request.
  427. Remove the DB session at the end of each request.
  428. Runs a garbage collection to get rid of any open pygit2 handles.
  429. Details: https://pagure.io/pagure/issue/2302
  430. """
  431. flask.g.session.remove()
  432. gc.collect()
  433. def after_request(response):
  434. """After request callback, adjust the headers returned"""
  435. if not hasattr(flask.g, "nonce"):
  436. return response
  437. csp_headers = pagure_config["CSP_HEADERS"]
  438. try:
  439. style_csp = "nonce-" + flask.g.nonce
  440. script_csp = (
  441. "unsafe-inline"
  442. if "unsafe_javascript" in flask.g and flask.g.unsafe_javascript
  443. else "nonce-" + flask.g.nonce
  444. )
  445. csp_headers = csp_headers.format(
  446. nonce_script=script_csp, nonce_style=style_csp
  447. )
  448. except (KeyError, IndexError):
  449. pass
  450. response.headers.set(str("Content-Security-Policy"), csp_headers)
  451. return response
  452. def _get_user(username):
  453. """Check if user exists or not"""
  454. try:
  455. return pagure.lib.query.get_user(flask.g.session, username)
  456. except pagure.exceptions.PagureException as e:
  457. flask.abort(404, description="%s" % e)