flask_app.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  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 generate_user_key_files():
  173. """Regenerate the key files used by gitolite."""
  174. gitolite_home = pagure_config.get("GITOLITE_HOME", None)
  175. if gitolite_home:
  176. users = pagure.lib.query.search_user(flask.g.session)
  177. for user in users:
  178. pagure.lib.query.update_user_ssh(
  179. flask.g.session,
  180. user,
  181. None,
  182. pagure_config.get("GITOLITE_KEYDIR", None),
  183. update_only=True,
  184. )
  185. pagure.lib.git.generate_gitolite_acls(project=None)
  186. def admin_session_timedout():
  187. """Check if the current user has been authenticated for more than what
  188. is allowed (defaults to 15 minutes).
  189. If it is the case, the user is logged out and the method returns True,
  190. otherwise it returns False.
  191. """
  192. timedout = False
  193. if not pagure.utils.authenticated():
  194. return True
  195. login_time = flask.g.fas_user.login_time
  196. # This is because flask_fas_openid will store this as a posix timestamp
  197. if not isinstance(login_time, datetime.datetime):
  198. login_time = datetime.datetime.utcfromtimestamp(login_time)
  199. if (datetime.datetime.utcnow() - login_time) > pagure_config.get(
  200. "ADMIN_SESSION_LIFETIME", datetime.timedelta(minutes=15)
  201. ):
  202. timedout = True
  203. logout()
  204. return timedout
  205. def logout():
  206. """Log out the user currently logged in in the application"""
  207. auth = pagure_config.get("PAGURE_AUTH", None)
  208. if auth in ["fas", "openid"]:
  209. if hasattr(flask.g, "fas_user") and flask.g.fas_user is not None:
  210. from pagure.ui.fas_login import FAS
  211. FAS.logout()
  212. elif auth == "oidc":
  213. from pagure.ui.oidc_login import oidc_logout
  214. oidc_logout()
  215. elif auth == "local":
  216. import pagure.ui.login as login
  217. login.logout()
  218. def set_request():
  219. """Prepare every request."""
  220. flask.session.permanent = True
  221. if not hasattr(flask.g, "session") or not flask.g.session:
  222. flask.g.session = pagure.lib.model_base.create_session(
  223. flask.current_app.config["DB_URL"]
  224. )
  225. flask.g.main_app = flask.current_app
  226. flask.g.version = pagure.__version__
  227. flask.g.confirmationform = pagure.forms.ConfirmationForm()
  228. flask.g.nonce = pagure.lib.login.id_generator(
  229. size=25, chars=string.ascii_letters + string.digits
  230. )
  231. flask.g.issues_enabled = pagure_config.get("ENABLE_TICKETS", True)
  232. # The API namespace has its own way of getting repo and username and
  233. # of handling errors
  234. if flask.request.blueprint == "api_ns":
  235. return
  236. flask.g.forkbuttonform = None
  237. if pagure.utils.authenticated():
  238. flask.g.forkbuttonform = pagure.forms.ConfirmationForm()
  239. # Force logout if current session started before users'
  240. # refuse_sessions_before
  241. login_time = flask.g.fas_user.login_time
  242. # This is because flask_fas_openid will store this as a posix timestamp
  243. if not isinstance(login_time, datetime.datetime):
  244. login_time = datetime.datetime.utcfromtimestamp(login_time)
  245. user = _get_user(username=flask.g.fas_user.username)
  246. if (
  247. user.refuse_sessions_before
  248. and login_time < user.refuse_sessions_before
  249. ):
  250. logout()
  251. return flask.redirect(flask.url_for("ui_ns.index"))
  252. flask.g.justlogedout = flask.session.get("_justloggedout", False)
  253. if flask.g.justlogedout:
  254. flask.session["_justloggedout"] = None
  255. flask.g.new_user = False
  256. if flask.session.get("_new_user"):
  257. flask.g.new_user = True
  258. flask.session["_new_user"] = False
  259. flask.g.authenticated = pagure.utils.authenticated()
  260. flask.g.admin = pagure.utils.is_admin()
  261. # Retrieve the variables in the URL
  262. args = flask.request.view_args or {}
  263. # Check if there is a `repo` and an `username`
  264. repo = args.get("repo")
  265. username = args.get("username")
  266. namespace = args.get("namespace")
  267. # If there isn't a `repo` in the URL path, or if there is but the
  268. # endpoint called is part of the API, just don't do anything
  269. if repo:
  270. flask.g.repo = pagure.lib.query.get_authorized_project(
  271. flask.g.session, repo, user=username, namespace=namespace
  272. )
  273. if flask.g.authenticated:
  274. flask.g.repo_forked = pagure.lib.query.get_authorized_project(
  275. flask.g.session,
  276. repo,
  277. user=flask.g.fas_user.username,
  278. namespace=namespace,
  279. )
  280. flask.g.repo_starred = pagure.lib.query.has_starred(
  281. flask.g.session, flask.g.repo, user=flask.g.fas_user.username
  282. )
  283. # Block all POST request from blocked users
  284. if flask.g.repo and flask.request.method != "GET":
  285. if flask.g.fas_user.username in flask.g.repo.block_users:
  286. flask.abort(
  287. 403,
  288. description="You have been blocked from this project",
  289. )
  290. if (
  291. not flask.g.repo
  292. and namespace
  293. and pagure_config.get("OLD_VIEW_COMMIT_ENABLED", False)
  294. and len(repo) == 40
  295. ):
  296. return flask.redirect(
  297. flask.url_for(
  298. "ui_ns.view_commit",
  299. repo=namespace,
  300. commitid=repo,
  301. username=username,
  302. namespace=None,
  303. )
  304. )
  305. if flask.g.repo is None:
  306. flask.abort(404, description="Project not found")
  307. # If issues are not globally enabled, there is no point in continuing
  308. if flask.g.issues_enabled:
  309. ticket_namespaces = pagure_config.get("ENABLE_TICKETS_NAMESPACE")
  310. if ticket_namespaces and flask.g.repo.namespace:
  311. if flask.g.repo.namespace in (ticket_namespaces or []):
  312. # If the namespace is in the allowed list
  313. # issues are enabled
  314. flask.g.issues_enabled = True
  315. else:
  316. # If the namespace isn't in the list of namespaces
  317. # issues are disabled
  318. flask.g.issues_enabled = False
  319. flask.g.issues_project_disabled = False
  320. if not flask.g.repo.settings.get("issue_tracker", True):
  321. # If the project specifically disabled its issue tracker,
  322. # disable issues
  323. flask.g.issues_project_disabled = True
  324. flask.g.issues_enabled = False
  325. flask.g.reponame = get_repo_path(flask.g.repo)
  326. flask.g.repo_obj = pygit2.Repository(flask.g.reponame)
  327. flask.g.repo_admin = pagure.utils.is_repo_admin(flask.g.repo)
  328. flask.g.repo_committer = pagure.utils.is_repo_committer(flask.g.repo)
  329. if flask.g.authenticated and not flask.g.repo_committer:
  330. flask.g.repo_committer = flask.g.fas_user.username in [
  331. u.user.username for u in flask.g.repo.collaborators
  332. ]
  333. flask.g.repo_user = pagure.utils.is_repo_user(flask.g.repo)
  334. flask.g.branches = sorted(flask.g.repo_obj.listall_branches())
  335. repouser = flask.g.repo.user.user if flask.g.repo.is_fork else None
  336. fas_user = flask.g.fas_user if pagure.utils.authenticated() else None
  337. flask.g.repo_watch_levels = pagure.lib.query.get_watch_level_on_repo(
  338. flask.g.session,
  339. fas_user,
  340. flask.g.repo.name,
  341. repouser=repouser,
  342. namespace=namespace,
  343. )
  344. items_per_page = pagure_config["ITEM_PER_PAGE"]
  345. flask.g.offset = 0
  346. flask.g.page = 1
  347. flask.g.limit = items_per_page
  348. page = flask.request.args.get("page")
  349. limit = flask.request.args.get("n")
  350. if limit:
  351. try:
  352. limit = int(limit)
  353. except ValueError:
  354. limit = 10
  355. if limit > 500 or limit <= 0:
  356. limit = items_per_page
  357. flask.g.limit = limit
  358. if page:
  359. try:
  360. page = abs(int(page))
  361. except ValueError:
  362. page = 1
  363. if page <= 0:
  364. page = 1
  365. flask.g.page = page
  366. flask.g.offset = (page - 1) * flask.g.limit
  367. def auth_login(): # pragma: no cover
  368. """Method to log into the application using FAS OpenID."""
  369. return_point = flask.url_for("ui_ns.index")
  370. if "next" in flask.request.args:
  371. if pagure.utils.is_safe_url(flask.request.args["next"]):
  372. return_point = urljoin(
  373. flask.request.host_url, flask.request.args["next"]
  374. )
  375. authenticated = pagure.utils.authenticated()
  376. auth = pagure_config.get("PAGURE_AUTH", None)
  377. if not authenticated and auth == "oidc":
  378. from pagure.ui.oidc_login import fas_user_from_oidc, oidc, set_user
  379. # If oidc is used and user hits this endpoint, it will redirect
  380. # to IdP with destination=<pagure>/login?next=<location>
  381. # After confirming user identity, the IdP will redirect user here
  382. # again, but this time oidc.user_loggedin will be True and thus
  383. # execution will go through the else clause, making the Pagure
  384. # authentication machinery pick the user up
  385. if not oidc.user_loggedin:
  386. return oidc.redirect_to_auth_server(flask.request.url)
  387. else:
  388. flask.session["oidc_logintime"] = time.time()
  389. fas_user_from_oidc()
  390. authenticated = pagure.utils.authenticated()
  391. set_user()
  392. if authenticated:
  393. return flask.redirect(return_point)
  394. admins = pagure_config["ADMIN_GROUP"]
  395. if admins:
  396. if isinstance(admins, list):
  397. admins = set(admins)
  398. else: # pragma: no cover
  399. admins = set([admins])
  400. else:
  401. admins = set()
  402. if auth in ["fas", "openid"]:
  403. from pagure.ui.fas_login import FAS
  404. groups = set()
  405. if not pagure_config.get("ENABLE_GROUP_MNGT", False):
  406. groups = [
  407. group.group_name
  408. for group in pagure.lib.query.search_groups(
  409. flask.g.session, group_type="user"
  410. )
  411. ]
  412. groups = set(groups).union(admins)
  413. if auth == "fas":
  414. groups.add("signed_fpca")
  415. ext_committer = set(pagure_config.get("EXTERNAL_COMMITTER", {}))
  416. groups = set(groups).union(ext_committer)
  417. flask.g.unsafe_javascript = True
  418. return FAS.login(return_url=return_point, groups=groups)
  419. elif auth == "local":
  420. form = pagure.login_forms.LoginForm()
  421. return flask.render_template(
  422. "login/login.html", next_url=return_point, form=form
  423. )
  424. def auth_logout(): # pragma: no cover
  425. """Method to log out from the application."""
  426. return_point = flask.url_for("ui_ns.index")
  427. if "next" in flask.request.args:
  428. if pagure.utils.is_safe_url(flask.request.args["next"]):
  429. return_point = urljoin(
  430. flask.request.host_url, flask.request.args["next"]
  431. )
  432. if not pagure.utils.authenticated():
  433. return flask.redirect(return_point)
  434. logout()
  435. flask.flash("You have been logged out")
  436. flask.session["_justloggedout"] = True
  437. return flask.redirect(return_point)
  438. # pylint: disable=unused-argument
  439. def end_request(exception=None):
  440. """This method is called at the end of each request.
  441. Remove the DB session at the end of each request.
  442. Runs a garbage collection to get rid of any open pygit2 handles.
  443. Details: https://pagure.io/pagure/issue/2302
  444. """
  445. flask.g.session.remove()
  446. gc.collect()
  447. def after_request(response):
  448. """After request callback, adjust the headers returned"""
  449. if not hasattr(flask.g, "nonce"):
  450. return response
  451. csp_headers = pagure_config["CSP_HEADERS"]
  452. try:
  453. style_csp = "nonce-" + flask.g.nonce
  454. script_csp = (
  455. "unsafe-inline"
  456. if "unsafe_javascript" in flask.g and flask.g.unsafe_javascript
  457. else "nonce-" + flask.g.nonce
  458. )
  459. csp_headers = csp_headers.format(
  460. nonce_script=script_csp, nonce_style=style_csp
  461. )
  462. except (KeyError, IndexError):
  463. pass
  464. response.headers.set(str("Content-Security-Policy"), csp_headers)
  465. return response
  466. def _get_user(username):
  467. """Check if user exists or not"""
  468. try:
  469. return pagure.lib.query.get_user(flask.g.session, username)
  470. except pagure.exceptions.PagureException as e:
  471. flask.abort(404, description="%s" % e)