# -*- coding: utf-8 -*- """ (c) 2014-2015 - Copyright Red Hat Inc Authors: Pierre-Yves Chibon """ # These two lines are needed to run on EL6 __requires__ = ['SQLAlchemy >= 0.8', 'jinja2 >= 2.4'] import pkg_resources __version__ = '2.6' __api_version__ = '0.7' import datetime import logging import os import subprocess import urlparse from logging.handlers import SMTPHandler import flask import pygit2 import werkzeug from functools import wraps from sqlalchemy.exc import SQLAlchemyError from pygments import highlight from pygments.lexers.text import DiffLexer from pygments.formatters import HtmlFormatter from flask_multistatic import MultiStaticFlask from werkzeug.routing import BaseConverter import pagure.exceptions # Create the application. APP = MultiStaticFlask(__name__) APP.jinja_env.trim_blocks = True APP.jinja_env.lstrip_blocks = True # set up FAS APP.config.from_object('pagure.default_config') if 'PAGURE_CONFIG' in os.environ: APP.config.from_envvar('PAGURE_CONFIG') if APP.config.get('THEME_TEMPLATE_FOLDER', False): # Jinja can be told to look for templates in different folders # That's what we do here template_folder = APP.config['THEME_TEMPLATE_FOLDER'] if template_folder[0] != '/': template_folder = os.path.join( APP.root_path, APP.template_folder, template_folder) import jinja2 # Jinja looks for the template in the order of the folders specified templ_loaders = [ jinja2.FileSystemLoader(template_folder), APP.jinja_loader, ] APP.jinja_loader = jinja2.ChoiceLoader(templ_loaders) if APP.config.get('THEME_STATIC_FOLDER', False): static_folder = APP.config['THEME_STATIC_FOLDER'] if static_folder[0] != '/': static_folder = os.path.join( APP.root_path, 'static', static_folder) # Unlike templates, to serve static files from multiples folders we # need flask-multistatic APP.static_folder = [ static_folder, os.path.join(APP.root_path, 'static'), ] import pagure.doc_utils import pagure.forms import pagure.lib import pagure.lib.git import pagure.login_forms import pagure.mail_logging import pagure.proxy # Only import flask_fas_openid if it is needed if APP.config.get('PAGURE_AUTH', None) in ['fas', 'openid']: from flask_fas_openid import FAS FAS = FAS(APP) @FAS.postlogin def set_user(return_url): ''' After login method. ''' try: pagure.lib.set_up_user( session=SESSION, username=flask.g.fas_user.username, fullname=flask.g.fas_user.fullname, default_email=flask.g.fas_user.email, ssh_key=flask.g.fas_user.get('ssh_key'), keydir=APP.config.get('GITOLITE_KEYDIR', None), ) # If groups are managed outside pagure, set up the user at login if not APP.config.get('ENABLE_GROUP_MNGT', False): user = pagure.lib.search_user( SESSION, username=flask.g.fas_user.username) groups = set(user.groups) fas_groups = set(flask.g.fas_user.groups) # Add the new groups for group in fas_groups - groups: group = pagure.lib.search_groups( SESSION, group_name=group) if not group: continue try: pagure.lib.add_user_to_group( session=SESSION, username=flask.g.fas_user.username, group=group, user=flask.g.fas_user.username, is_admin=is_admin(), ) except pagure.exceptions.PagureException as err: LOG.debug(err) # Remove the old groups for group in groups - fas_groups: try: pagure.lib.delete_user_of_group( session=SESSION, username=flask.g.fas_user.username, groupname=group, user=flask.g.fas_user.username, is_admin=is_admin(), force=True, ) except pagure.exceptions.PagureException as err: LOG.debug(err) SESSION.commit() except SQLAlchemyError as err: SESSION.rollback() LOG.debug(err) LOG.exception(err) flask.flash( 'Could not set up you as a user properly, please contact ' 'an admin', 'error') return flask.redirect(return_url) SESSION = pagure.lib.create_session(APP.config['DB_URL']) REDIS = None if APP.config['EVENTSOURCE_SOURCE'] \ or APP.config['WEBHOOK'] \ or APP.config['PAGURE_CI_SERVICES']: pagure.lib.set_redis( host=APP.config['REDIS_HOST'], port=APP.config['REDIS_PORT'], dbname=APP.config['REDIS_DB'] ) if APP.config['PAGURE_CI_SERVICES']: pagure.lib.set_pagure_ci(APP.config['PAGURE_CI_SERVICES']) if not APP.debug: APP.logger.addHandler(pagure.mail_logging.get_mail_handler( smtp_server=APP.config.get('SMTP_SERVER', '127.0.0.1'), mail_admin=APP.config.get('MAIL_ADMIN', APP.config['EMAIL_ERROR']) )) # Send classic logs into syslog SHANDLER = logging.StreamHandler() SHANDLER.setLevel(APP.config.get('LOG_LEVEL', 'INFO')) APP.logger.addHandler(SHANDLER) LOG = APP.logger LOG.setLevel(APP.config.get('LOG_LEVEL', 'INFO')) APP.wsgi_app = pagure.proxy.ReverseProxied(APP.wsgi_app) def authenticated(): ''' Utility function checking if the current user is logged in or not. ''' return hasattr(flask.g, 'fas_user') and flask.g.fas_user is not None def logout(): auth = APP.config.get('PAGURE_AUTH', None) if auth in ['fas', 'openid']: if hasattr(flask.g, 'fas_user') and flask.g.fas_user is not None: FAS.logout() elif auth == 'local': import pagure.ui.login as login login.logout() def api_authenticated(): ''' Utility function checking if the current user is logged in or not in the API. ''' return hasattr(flask.g, 'fas_user') \ and flask.g.fas_user is not None \ and hasattr(flask.g, 'token') \ and flask.g.token is not None def admin_session_timedout(): ''' Check if the current user has been authenticated for more than what is allowed (defaults to 15 minutes). If it is the case, the user is logged out and the method returns True, otherwise it returns False. ''' timedout = False if not authenticated(): return True login_time = flask.g.fas_user.login_time # This is because flask_fas_openid will store this as a posix timestamp if not isinstance(login_time, datetime.datetime): login_time = datetime.datetime.utcfromtimestamp(login_time) if (datetime.datetime.utcnow() - login_time) > \ APP.config.get('ADMIN_SESSION_LIFETIME', datetime.timedelta(minutes=15)): timedout = True logout() return timedout def is_safe_url(target): # pragma: no cover """ Checks that the target url is safe and sending to the current website not some other malicious one. """ ref_url = urlparse.urlparse(flask.request.host_url) test_url = urlparse.urlparse( urlparse.urljoin(flask.request.host_url, target)) return test_url.scheme in ('http', 'https') and \ ref_url.netloc == test_url.netloc def is_admin(): """ Return whether the user is admin for this application or not. """ if not authenticated(): return False user = flask.g.fas_user auth_method = APP.config.get('PAGURE_AUTH', None) if auth_method == 'fas': if not user.cla_done or len(user.groups) < 1: return False admin_users = APP.config.get('PAGURE_ADMIN_USERS', []) if not isinstance(admin_users, list): admin_users = [admin_users] if user.username in admin_users: return True admins = APP.config['ADMIN_GROUP'] if isinstance(admins, basestring): admins = [admins] admins = set(admins or []) groups = set(flask.g.fas_user.groups) return not groups.isdisjoint(admins) def is_repo_admin(repo_obj): """ Return whether the user is an admin of the provided repo. """ if not authenticated(): return False user = flask.g.fas_user.username admin_users = APP.config.get('PAGURE_ADMIN_USERS', []) if not isinstance(admin_users, list): admin_users = [admin_users] if user in admin_users: return True usergrps = [ usr.user for grp in repo_obj.groups for usr in grp.users] return user == repo_obj.user.user or ( user in [usr.user for usr in repo_obj.users] ) or (user in usergrps) def generate_user_key_files(): """ Regenerate the key files used by gitolite. """ gitolite_home = APP.config.get('GITOLITE_HOME', None) if gitolite_home: users = pagure.lib.search_user(SESSION) for user in users: pagure.lib.update_user_ssh(SESSION, user, user.public_ssh_key, APP.config.get('GITOLITE_KEYDIR', None)) pagure.lib.git.generate_gitolite_acls() def login_required(function): """ Flask decorator to retrict access to logged in user. If the auth system is ``fas`` it will also require that the user sign the FPCA. """ auth_method = APP.config.get('PAGURE_AUTH', None) @wraps(function) def decorated_function(*args, **kwargs): """ Decorated function, actually does the work. """ if flask.session.get('_justloggedout', False): return flask.redirect(flask.url_for('.index')) elif not authenticated(): return flask.redirect( flask.url_for('auth_login', next=flask.request.url)) elif auth_method == 'fas' and not flask.g.fas_user.cla_done: flask.flash('You must sign the FPCA (Fedora Project Contributor ' 'Agreement) to use pagure', 'errors') return flask.redirect(flask.url_for('.index')) return function(*args, **kwargs) return decorated_function @APP.context_processor def inject_variables(): """ With this decorator we can set some variables to all templates. """ user_admin = is_admin() forkbuttonform = None if authenticated(): forkbuttonform = pagure.forms.ConfirmationForm() justlogedout = flask.session.get('_justloggedout', False) if justlogedout: flask.session['_justloggedout'] = None def is_watching(reponame, username=None, namespace=None): watch = False if authenticated(): watch = pagure.lib.is_watching( SESSION, flask.g.fas_user, reponame, repouser=username, namespace=namespace) return watch return dict( version=__version__, admin=user_admin, authenticated=authenticated(), forkbuttonform=forkbuttonform, is_watching=is_watching, ) @APP.before_request def set_session(): """ Set the flask session as permanent. """ flask.session.permanent = True @APP.before_request def set_variables(): """ This method retrieves the repo and username set in the URLs and provides some of the variables that are most often used. """ # The API namespace has its own way of getting repo and username and # of handling errors if flask.request.blueprint == 'api_ns': return # Retrieve the variables in the URL args = flask.request.view_args or {} # Check if there is a `repo` and an `username` repo = args.get('repo') username = args.get('username') namespace = args.get('namespace') # If there isn't a `repo` in the URL path, or if there is but the # endpoint called is part of the API, just don't do anything if repo: flask.g.repo = pagure.lib.get_project( SESSION, repo, user=username, namespace=namespace) if not flask.g.repo \ and APP.config.get('OLD_VIEW_COMMIT_ENABLED', False) \ and len(repo) == 40: return flask.redirect(flask.url_for( 'view_commit', repo=namespace, commitid=repo, username=username, namespace=None)) if flask.g.repo is None: flask.abort(404, 'Project not found') flask.g.reponame = get_repo_path(flask.g.repo) flask.g.repo_obj = pygit2.Repository(flask.g.reponame) flask.g.repo_admin = is_repo_admin(flask.g.repo) flask.g.branches = sorted(flask.g.repo_obj.listall_branches()) items_per_page = 100 flask.g.offset = 0 flask.g.page = 1 flask.g.limit = items_per_page page = flask.request.args.get('page') limit = flask.request.args.get('n') if limit: try: limit = int(limit) except ValueError: limit = 10 if limit > 500 or limit <= 0: limit = items_per_page flask.g.limit = limit if page: try: page = abs(int(page)) except ValueError: page = 1 if page <= 0: page = 1 flask.g.page = page flask.g.offset = (page - 1) * flask.g.limit @APP.errorhandler(404) def not_found(error): """404 Not Found page""" return flask.render_template('not_found.html', error=error), 404 @APP.errorhandler(500) def fatal_error(error): # pragma: no cover """500 Fatal Error page""" return flask.render_template('fatal_error.html', error=error), 500 @APP.errorhandler(401) def unauthorized(error): # pragma: no cover """401 Unauthorized page""" return flask.render_template('unauthorized.html', error=error), 401 @APP.route('/login/', methods=('GET', 'POST')) def auth_login(): # pragma: no cover """ Method to log into the application using FAS OpenID. """ return_point = flask.url_for('index') if 'next' in flask.request.args: if is_safe_url(flask.request.args['next']): return_point = flask.request.args['next'] if authenticated(): return flask.redirect(return_point) admins = APP.config['ADMIN_GROUP'] if isinstance(admins, list): admins = set(admins) else: # pragma: no cover admins = set([admins]) if APP.config.get('PAGURE_AUTH', None) in ['fas', 'openid']: groups = set() if not APP.config.get('ENABLE_GROUP_MNGT', False): groups = [ group.group_name for group in pagure.lib.search_groups( SESSION, group_type='user') ] groups = set(groups).union(admins) return FAS.login(return_url=return_point, groups=groups) elif APP.config.get('PAGURE_AUTH', None) == 'local': form = pagure.login_forms.LoginForm() return flask.render_template( 'login/login.html', next_url=return_point, form=form, ) @APP.route('/logout/') def auth_logout(): # pragma: no cover """ Method to log out from the application. """ return_point = flask.url_for('index') if 'next' in flask.request.args: if is_safe_url(flask.request.args['next']): return_point = flask.request.args['next'] if not authenticated(): return flask.redirect(return_point) logout() flask.flash("You have been logged out") flask.session['_justloggedout'] = True return flask.redirect(return_point) def __get_file_in_tree(repo_obj, tree, filepath, bail_on_tree=False): ''' Retrieve the entry corresponding to the provided filename in a given tree. ''' filename = filepath[0] if isinstance(tree, pygit2.Blob): return for entry in tree: fname = entry.name.decode('utf-8') if fname == filename: if len(filepath) == 1: blob = repo_obj.get(entry.id) # If we can't get the content (for example: an empty folder) if blob is None: return # If we get a tree instead of a blob, let's escape if isinstance(blob, pygit2.Tree) and bail_on_tree: return blob content = blob.data # If it's a (sane) symlink, we try a single-level dereference if entry.filemode == pygit2.GIT_FILEMODE_LINK \ and os.path.normpath(content) == content \ and not os.path.isabs(content): try: dereferenced = tree[content] except KeyError: pass else: if dereferenced.filemode == pygit2.GIT_FILEMODE_BLOB: blob = repo_obj[dereferenced.oid] return blob else: nextitem = repo_obj[entry.oid] # If we can't get the content (for example: an empty folder) if nextitem is None: return return __get_file_in_tree( repo_obj, nextitem, filepath[1:], bail_on_tree=bail_on_tree) def get_repo_path(repo): """ Return the path of the git repository corresponding to the provided Repository object from the DB. """ repopath = os.path.join(APP.config['GIT_FOLDER'], repo.path) if not os.path.exists(repopath): flask.abort(404, 'No git repo found') return repopath def get_remote_repo_path(remote_git, branch_from, loop=False): """ Return the path of the remote git repository corresponding to the provided information. """ repopath = os.path.join( APP.config['REMOTE_GIT_FOLDER'], werkzeug.secure_filename('%s_%s' % (remote_git, branch_from)) ) if not os.path.exists(repopath): try: pygit2.clone_repository( remote_git, repopath, checkout_branch=branch_from) except Exception as err: LOG.debug(err) LOG.exception(err) flask.abort(500, 'Could not clone the remote git repository') else: repo = pagure.lib.repo.PagureRepo(repopath) try: repo.pull(branch=branch_from, force=True) except pagure.exceptions.PagureException as err: LOG.debug(err) LOG.exception(err) flask.abort(500, err.message) return repopath # Import the application import pagure.ui.app import pagure.ui.admin import pagure.ui.fork import pagure.ui.groups if APP.config.get('ENABLE_TICKETS', True): import pagure.ui.issues import pagure.ui.plugins import pagure.ui.repo from pagure.api import API APP.register_blueprint(API) import pagure.internal APP.register_blueprint(pagure.internal.PV) # Only import the login controller if the app is set up for local login if APP.config.get('PAGURE_AUTH', None) == 'local': import pagure.ui.login as login APP.before_request_funcs[None].insert(0, login._check_session_cookie) APP.after_request(login._send_session_cookie) # pylint: disable=unused-argument @APP.teardown_request def shutdown_session(exception=None): """ Remove the DB session at the end of each request. """ SESSION.remove()